Published: Jan 13, 2026 by Isaac Johnson
Following the post from last week where I looked into COBOL and FORTRAN, I wanted to try a language I have more at my fingertips; perl. I started my coding life with Perl. In the 90s I had a few treasured Perl based t-shirts from Thinkgeek that lasted until I completely wore them out. My favourite one had the famous Larry Wall quote: The three great virtues of a programmer were “laziness, impatience, and hubris”
ThreeVirtues gives a better explanation as to why he said that:
- Laziness: It’s the quality that makes you go through a lot of work just to reduce energy expenditure - such as writing scripts and tools to reduce toil or docs to keep people from pestering you
- Impatience: The rage you get when you feel the computer is being lazy. It makes you want to write programs that can anticipate your needs (or at least pretend to)
- Hubris: The quality that makes you write (and maintain) great programs that will make others think you are smart and won’t crap on.
So with that, let’s start with the SDK, but then move on to doing something cool and useful.
And in this case, I’m going to really minimize the GenAI side of the house. I want to see how much I just remember and can code myself (or code how I used to which is to use SO examples, documentation, and test scripts).
Perl MCP SDK
I started down the path of two different libraries. However, the one that gave me the best response was “Mojolicious” with “MCP::Server”
You can see the first “echo” tool in my first push to Codeberg
As I worked out the Dockerfile I started to stash it in Codeberg
Testing locally with the MCP Inspector would show the tool
As well as Gemini CLI
I can build the dockerfile
$ docker build -t perlmcp:0.1 .
[+] Building 165.3s (8/9) docker:default
[+] Building 177.6s (10/10) FINISHED docker:default
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 385B 0.0s
=> [internal] load metadata for docker.io/library/perl:5.34 0.3s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [1/5] FROM docker.io/library/perl:5.34@sha256:6beebc97f4b6779637a112b3f0a5ecbbe25b1392cd1db8f0c0018bde9ad4c067 0.0s
=> [internal] load build context 0.0s
=> => transferring context: 2.75kB 0.0s
=> CACHED [2/5] RUN mkdir -p /usr/src/app 0.0s
=> [3/5] COPY . /usr/src/app 0.1s
=> [4/5] WORKDIR /usr/src/app 0.1s
=> [5/5] RUN cpanm --installdeps . 176.2s
=> exporting to image 0.7s
=> => exporting layers 0.6s
=> => writing image sha256:266af322681e48ce0d8b3d89325cf75e2ecd22b92b41f8acd38d0706cebd8dd3 0.0s
=> => naming to docker.io/library/perlmcp:0.1
And run it locally
$ docker run -d --name perlmcp02 -p 8080:8080 perlmcp:0.2
e5d730d18008d910521ed044dfc9c0716ecdd6a90af021d6d52b259fd56d8e64
One change I did need to make to my initial Dockerfile was to explicitly expose the port. I don’t often need to do that.
FROM perl:5.34
RUN mkdir -p /usr/src/app
COPY . /usr/src/app
WORKDIR /usr/src/app
#COPY . .
RUN cpanm --installdeps .
#RUN ["cpanm", "Carp", "Carp::Heavy", "Class::Data::Inheritable"]
EXPOSE 8080
#perl ./server.pl daemon -m production -l http://127.0.0.1:8080
CMD [ "perl", "./server.pl", "daemon", "-m", "production", "-l", "http://*:8080" ]
Adding Google Sheets API
The idea I came up with was inspired by seeing some of the Improv sessions from Luke S where he would be doing yet another great on-the-fly Firebase-based build out but would iterate through a lot of preconfigured GCP environments.
I thought - I bet a lot of trainers and developer advocates have that issue - recalling “what are my session keys, project ids, etc for this session”.
What if we stored those in a sheet and I could just ask Gemini CLI “find the GCP credentials.json, project ID and name and set as standard env vars in my .env file (or as environment variables).”
Could be kind of fun.
And because he does Javascript so well, I had to show how incredibly “seasoned” I am whip it all up in Perl
Let’s do it…
Get a proper SA with JSON
We need an identity that can hit up the Sheets API and likely Drive API as well.
$ export MYPROJECTID=myanthosproject2
$ gcloud iam service-accounts create perlmcpapi \
--display-name "Service Account for Google Sheets and Drive for Perl MCP server" \
--project $MYPROJECTID
Created service account [perlmcpapi].
I can assume the email ID for this service account, but better to just ask:
$ gcloud iam service-accounts list --filter="displayName:Service Account for Google Sheets and Drive for Perl MCP server" --format="value(email)" --project $MYPROJECTID
WARNING: --filter : operator evaluation is changing for consistency across Google APIs. displayName:Service currently matches but will not match in the near future. Run `gcloud topic filters` for details.
perlmcpapi@myanthosproject2.iam.gserviceaccount.com
Now I can get the SA keys
$ gcloud iam service-accounts keys create credentials.json \
--iam-account perlmcpapi@myanthosproject2.iam.gserviceaccount.com \
--project $MYPROJECTID
created key [abb1d87edb1ea7ae2466d2245c610ea00c2ceecb] of type [json] as [credentials.json] for [perlmcpapi@myanthosproject2.iam.gserviceaccount.com]
Next, I’ll create a worksheet with a table that has dates, session IDs and the things I might want like Project ID and SA JSON
I can now share the sheet with my SA
I ran into a lot of 403 errors until i did a lot of debugging to figure out that I neglected to enable the Sheets API in my GCP project. So for whichever project your PerlMCP SA was created, ensure Sheets API is enabled at https://console.cloud.google.com/apis/library/sheets.googleapis.com?
Testing a new script
I started with an example script I found on SO (which I left the header for attribution).
# Source - https://stackoverflow.com/a/72860974
# Posted by Håkon Hægland, modified by community. See post 'Timeline' for change history
# Retrieved 2026-01-07, License - CC BY-SA 4.0
use feature qw(say);
use strict;
use warnings;
use WWW::Google::Cloud::Auth::ServiceAccount;
use LWP::UserAgent;
use URI;
use HTTP::Request;
use Mojolicious::Lite;
use Data::Dumper;
use JSON;
use experimental qw(declared_refs refaliasing signatures);
{
my @scopes = ('https://www.googleapis.com/auth/spreadsheets',
'https://www.googleapis.com/auth/drive');
my $auth = WWW::Google::Cloud::Auth::ServiceAccount->new(
credentials_path => 'credentials.json',
scope => join ' ', @scopes
);
get '/' => sub {
my $c = shift;
my $token = $auth->get_token();
my ($return_code, $files) = get_all_spreadsheets($token);
$c->render(template => 'index', return_code => $return_code);
};
app->start;
}
sub get_all_spreadsheets($token) {
my $url = 'https://www.googleapis.com/drive/v3/files';
my $query = 'mimeType="application/vnd.google-apps.spreadsheet"';
my $params = {
"q" => $query,
"pageSize" => 1000,
"supportsAllDrives" => 1,
"includeItemsFromAllDrives" => 1,
"fields" => "kind,nextPageToken,"
. "files(id,name,createdTime,modifiedTime)",
};
my $more_pages = 1;
my $page_token = '';
my $status_line;
my @files;
while ($more_pages) {
$params->{pageToken} = $page_token if $page_token;
my $result = send_google_drive_get_request($url, $params, $token);
$status_line = $result->status_line;
if (!$result->is_success) {
return $status_line;
}
my $hash = decode_json($result->content);
push @files, $hash->{files};
if (exists $hash->{nextPageToken}) {
$page_token = $hash->{nextPageToken};
}
else {
$more_pages = 0;
}
}
return $status_line, \@files;
}
sub send_google_drive_get_request( $url, $params, $token ) {
my $uri = URI->new( $url );
$uri->query_form($params);
my $str = $uri->as_string();
my @headers = get_headers($token);
my $req = HTTP::Request->new(
'GET',
$uri->as_string(),
\@headers,
);
my $ua = LWP::UserAgent->new();
my $res = $ua->request($req);
return $res;
}
sub get_headers($token) {
return 'Accept-Encoding' => 'gzip, deflate',
'Accept' => '*/*',
'Connection' => 'keep-alive',
"Authorization" => "Bearer $token";
}
__DATA__
@@ index.html.ep
<!DOCTYPE html>
<html>
<head><title>Testing access to google sheets...</title></head>
<body>
<h1>Return code = <%= $return_code %></h1>
</body>
</html>
I then modified it a lot to fetch all the rows and put them into a “finalHash” structure I plan to use for my MCP work in a few.
<gripe>
I am trying to do this without AI but I swear it won’t shut up.
Every time I’m trying to type or do a new line it keeps trying to be “helpful”.
I’m to the point of trying to find another editor just to get this one to piss off for 5 minutes and let me work.
All these suggestions just derail my brain
</gripe>
I got the script working mojolicious app that can query a worksheet and give back the rows.
# Source - https://stackoverflow.com/a/72860974
# Posted by Håkon Hægland, modified by community. See post 'Timeline' for change history
# Retrieved 2026-01-06, License - CC BY-SA 4.0
use feature qw(say);
use strict;
use warnings;
use WWW::Google::Cloud::Auth::ServiceAccount;
use LWP::UserAgent;
use URI;
use URI::Encode;
use HTTP::Request;
use Mojolicious::Lite;
use Data::Dumper;
use JSON;
use experimental qw(declared_refs refaliasing signatures);
{
my @scopes = ('https://www.googleapis.com/auth/spreadsheets',
'https://www.googleapis.com/auth/drive');
my $auth = WWW::Google::Cloud::Auth::ServiceAccount->new(
credentials_path => 'credentials.json',
scope => join " ", @scopes
);
get '/' => sub {
my $c = shift;
my $token = $auth->get_token();
print "token: $token\n";
app->log->debug(app->dumper( "chuckles" ));
app->log->debug(app->dumper( $token ));
my $sheet_id = '1Ux5joXRMFBpKbWY215wmUx_T_JDOwtvc0CedXulqJzA';
my $max_sheet_cellletter = 'Z';
my $max_sheet_cellnumber = 100;
my ($return_code, $cell_value) =
read_spreadsheet_cell($token, $sheet_id, $max_sheet_cellletter, $max_sheet_cellnumber);
#app->log->debug(app->dumper( { cell_value => $cell_value } ));
$c->render(template => 'index', return_code => $return_code);
};
app->start;
}
sub read_spreadsheet_cell($token, $id, $cell_max_letter, $cell_max_number) {
my $encoder = URI::Encode->new();
my $finalHash = {};
my $value_range = 'A:Z'; # get all rows and all columns
### for just one row, column: #$encoder->encode(sprintf "'Sheet1'!%s%s", $cell_letter, $cell_number);
my $url = sprintf 'https://sheets.googleapis.com/v4/spreadsheets/%s/values/%s',
$id, $value_range;
print "Request URL: $url\n";
my $result = send_google_drive_get_request($url, $token);
my $status_line = $result->status_line;
if (!$result->is_success) {
return $status_line;
} else {
print $status_line, "\n";
}
my $result_hash = decode_json( $result->content );
if (exists $result_hash->{values}) {
foreach my $row_index (0 .. $#{ $result_hash->{values} }) {
my $row = $result_hash->{values}[$row_index];
if ($row_index == 0) {
# store the keys from the table header
foreach my $col_index (0 .. $#{ $row }) {
$finalHash->{headers}[$col_index] = $row->[$col_index];
print "Header Cell value: ", $row->[$col_index], "\n";
}
} else {
# store the data values
foreach my $col_index (0 .. $#{ $row }) {
my $cell_value = $row->[$col_index];
$finalHash->{values}[$col_index]->{$finalHash->{headers}[$col_index]} = $cell_value;
print "Storing in hash: values $col_index of key ", $finalHash->{headers}[$col_index], " => $cell_value\n";
}
}
}
} else {
print "No values found in the response.\n";
}
#print "result_hash: ", Dumper($result_hash), "\n";
print "result_final_hash: ", Dumper($finalHash), "\n";
#app->log->debug(app->dumper( $result_hash ));
return $status_line, $result_hash->{values}[0][0];
}
sub send_google_drive_get_request( $url, $token ) {
my @headers = get_headers($token);
my $req = HTTP::Request->new('GET', $url, \@headers);
my $ua = LWP::UserAgent->new();
my $res = $ua->request($req);
return $res;
}
sub get_headers($token) {
return
'User-Agent' => 'Mozilla/8.0',
'Accept-Encoding' => 'gzip, deflate',
'Accept' => '*/*',
'Connection' => 'keep-alive',
"Authorization" => "Bearer $token";
}
__DATA__
@@ index.html.ep
<!DOCTYPE html>
<html>
<head><title>Testing access to google sheets...</title></head>
<body>
<h1>Return code = <%= $return_code %></h1>
</body>
</html>
When I do work in Javascript, Python or even Perl, I like to that the “thinking bits” - the “business logic” and just work it as a script until I know it’s good. Only then do I try and figure out how to move that logic back into the MCP server portion.
I’ll leave the branch alive for learning, but here you can see the steps in WORKSHEET.md
I soon merged in the relevant code and worked through how to pass in vars.
I did a test with the MCP inspector:
I started to think about how to handle creds when dockerized.
I could look for a local file (for dev) and a mounted file (e.g. /creds/credentials.json) that would be easy to mount in Docker and just test for the file in the code:
$server->tool(
name => 'fetch_google_sheet',
description => 'Fetch a full google sheet',
input_schema => {type => 'object', properties => {sheetid => {type => 'string'}}, required => ['sheetid']},
code => sub ($tool, $args) {
my $credsfile = 'credentials.json';
# If we mounted in as a directory with Docker, use that path
if ((-d "/creds") && (-e "/creds/credentials.json")) {
$credsfile = '/creds/credentials.json';
}
my @scopes = ('https://www.googleapis.com/auth/spreadsheets',
'https://www.googleapis.com/auth/drive');
my $auth = WWW::Google::Cloud::Auth::ServiceAccount->new(
credentials_path => $credsfile,
scope => join " ", @scopes
);
my $c = shift;
my $token = $auth->get_token();
print "token: $token\n";
my $sheet_id = $args->{sheetid};
my ($return_code, $contents) =
read_spreadsheet_cell($token, $sheet_id);
print "Return code: $return_code\n";
print "Contents: ", $contents, "\n";
return $contents;
}
);
But this makes me think - if I serve this up publicly, do I want my credentials stored? This is a bit of a challenge - if we require the user to pass in credentials in a tool call, that could be a PITA.
So I thought maybe a simple token might suffice. Something that if we set a password in as an env var (easy to rotate in Docker/K8s) then it will check, otherwise it will be skipped
my $server = MCP::Server->new;
my $USAGEPASS = $ENV{'MCP_USAGE_PASSWORD'} || 'nopassword';
my $DEBUG_MODE = 1;
$server->tool(
name => 'echo',
description => 'Echo the input text',
input_schema => {type => 'object', properties => {msg => {type => 'string'}}, required => ['msg']},
code => sub ($tool, $args) {
return "Echo: $args->{msg}";
}
);
$server->tool(
name => 'fetch_google_sheet',
description => 'Fetch a full google sheet',
input_schema => {type => 'object', properties => {sheetid => {type => 'string'}, password => {type => 'string'}}, required => ['sheetid']},
code => sub ($tool, $args) {
my $credsfile = 'credentials.json';
# If we mounted in as a directory with Docker, use that path
if ((-d "/creds") && (-e "/creds/credentials.json")) {
$credsfile = '/creds/credentials.json';
}
# Check on Password if required
if ($USAGEPASS ne 'nopassword') {
if (exists $args->{password}) {
if ($args->{password} ne $USAGEPASS) {
print "Invalid password provided. Rejecting request.\n";
return "Invalid password provided for fetch_google_sheet tool.\n";
} else {
print "Password accepted.\n";
}
} else {
print "No password provided. Rejecting request.\n";
return "No password provided for fetch_google_sheet tool.\n";
}
} else {
print "No usage password set, skipping password check.\n";
}
#... snip ...
But if I set a password first
builder@DESKTOP-QADGF36:~/Workspaces/perlmcp$ export MCP_USAGE_PASSWORD=p@ssw0rd && perl ./server.pl daemon -m production -l http://*:8088
The signatures feature is experimental at ./server.pl line 24.
The signatures feature is experimental at ./server.pl line 33.
The signatures feature is experimental at ./server.pl line 81.
The signatures feature is experimental at ./server.pl line 122.
The signatures feature is experimental at ./server.pl line 130.
[2026-01-09 08:19:47.04760] [1387] [info] Listening at "http://*:8088"
Web application available at http://127.0.0.1:8088
No password provided. Rejecting request.
Then I get rejected
but a proper password works (sadly there is not a “password” type)
But I could just make a very long password that would be too long to show the whole thing in a presentation and i think that might suffice as a way to hide the full value.
e.g.
$ export MCP_USAGE_PASSWORD=cfb45f8a1b88992a00e04b5c8cd840d0c49beb7b11daf78310ff43ba905f83e2cfb45f8a1b88992a00e04b5c8cd840d0c49beb7b11daf78310ff43ba905f83e2cfb45f8a1b88992a00e04b5c8cd840d0c49beb7b11daf78310ff43ba905f83e2 && perl ./server.pl daemon -m production -l http://*:8088
The signatures feature is experimental at ./server.pl line 24.
The signatures feature is experimental at ./server.pl line 33.
The signatures feature is experimental at ./server.pl line 81.
The signatures feature is experimental at ./server.pl line 122.
The signatures feature is experimental at ./server.pl line 130.
[2026-01-09 08:24:36.54826] [4187] [info] Listening at "http://*:8088"
Web application available at http://127.0.0.1:8088
I want to test in Gemini CLI next, so let’s create a gemini-extension.json file
builder@DESKTOP-QADGF36:~/Workspaces/perlmcp$ cat ./gemini-extension.json
{
"name": "perlmcp",
"version": "1.0.0",
"description": "Perl based MCP for Google Sheet work",
"mcpServers": {
"perlMCP": {
"httpUrl": "http://localhost:8088/mcp",
"timeout": 5000
}
}
}
I’ll test locally first
$ gemini extensions link /home/builder/Workspaces/perlmcp/
Installing extension "perlmcp".
**The extension you are about to install may have been created by a third-party developer and sourced from a public repository. Google does not vet, endorse, or guarantee the functionality or security of extensions. Please carefully inspect any extension and its source code before installing to understand the permissions it requires and the actions it may perform.**
This extension will run the following MCP servers:
* perlMCP (remote): http://localhost:8088/mcp
Do you want to continue? [Y/n]: Y
Extension "perlmcp" linked successfully and enabled.
I can now see it listed
Testing
Let’s assume I put my password off in a tmp file somewhere just so i don’t have to use the clipboard later
$ vi /tmp/mypass
$ cat /tmp/mypass
cfb45f8a1b88992a00e04b5c8cd840d0c49beb7b11daf78310ff43ba905f83e2cfb45f8a1b88992a00e04b5c8cd840d0c49beb7b11daf78310ff43ba905f83e2cfb45f8a1b88992a00e04b5c8cd840d0c49beb7b11daf78310ff43ba905f83e2
I asked Gemini CLI to get the password, but it did display it, which kind of defeats the purpose
But it did work and do the ask.
I tried to tell Gemini CLI to hide the values
use the perlMCP mcp server tool fetch_google_sheet to fetch the sheetid 1Ux5joXRMFBpKbWY215wmUx_T_JDOwtvc0CedXulqJzA. This tool needs a password, which you can retrieve from /tmp/mypass. Do not display the contents of /tmp/mypass. Do not display the contents of the returned JSON objects field “private_key”.
But as you see, it kind of failed at the assignment
I was able to use a mix of YOLO mode with a prompt to run it and store into a file (but it still brings up the UI in the terminal)
$ gemini -y -i "use the perlMCP mcp server tool fetch_google_sheet
to fetch the sheetid 1Ux5joXRMFBpKbWY215wmUx_T_JDOwtvc0CedXulqJzA.
This tool needs a password, which you can retrieve from /tmp/mypass.
Please take the returned contents and store in a local file MYRETVALS"
Aggy
Let’s test in Antigravity next. I can add a block to the mcp_servers.json file (you can see it under “view raw config” in settings)
NOTE: in Gemini CLI we use “httpURL” but in Aggy it is “serverURL” for the field!
I can now see the tool listed
I’ll now ask it to fetch the contents of a sheet. I’ve set the tool password into a simple file in the workspace and use the @ reference to pick it:
Oh this is nice… It wisely hid the password and did not show the client_secret in the output.
Good job Aggy and Gemini 3.0 Flash!
Perhaps I might just want to see the service account email for some work, easy enough to ask the agent:
I tried another sheet for which exists, but it has no access.
The tool does respond appropriately, but Aggy did steps like “attempting to crack password” and just kept going. Not sure how to get it to understand my user is wrong and needs to get access added.
Even though I coded up a 404, an invalid sheet comes back as invalid pass
Other uses
Here is a sheet that is public for all that is the CNCF list of Kubernetes Distributions and Platforms
I started to test and realized I had artificially limited my range to A:Z when some sheets go beyond that.
Furthermore, I was trying to be “smart” on the sheet and return essentially one result per column meaning i was just getting one row of data.
I built out a new tool that would get the full sheet provided a range was provided:
# Google Worksheet Helper Functions
sub read_spreadsheet_raw($token, $id, $range) {
my $encoder = URI::Encode->new();
my $finalHash = {};
my $url = sprintf 'https://sheets.googleapis.com/v4/spreadsheets/%s/values/%s',
$id, $range;
print "Request URL: $url\n" if ($DEBUG_MODE > 0);
my $result = send_google_drive_get_request($url, $token);
my $status_line = $result->status_line;
if (!$result->is_success) {
return $status_line;
} else {
print $status_line, "\n" if ($DEBUG_MODE > 0);
}
my $result_hash = decode_json( $result->content );
print "status_line: $status_line\n" if ($DEBUG_MODE > 0);
print "result_hash: ", Dumper($result_hash), "\n" if ($DEBUG_MODE > 0);
if (exists $result_hash->{values}) {
return $status_line, Dumper($result_hash->{values});
}
return $status_line, Dumper($result_hash);
}
I then gave Gemini 3.0 Flash in Aggy a challenge:
using the contents of worksheetpass for password, use the perlMCP mcp server tool fetch_google_sheet to fetch the sheetid 1uF9BoDzzisHSQemXHIKegMhuythuq_GL3N1mlUUK2h0 and the range of ‘A1:AK146’. This sheet lists Kubernetes Distributions and Platforms by Company. I want to find a Product (Product Name) that supports 1.33 and 1.34 and is of type Distribution
At first I thought it was borked, calling the MCP tool over and over, until I realized it was just being smart and getting chunks at a time to process
It wrapped up and gave me a solid answer:
If I want the latest two certified release of K8s and to host it myself, my choices are K3s, k0s, Nutanix NKP and RKE Government.
Good to know. I’m already running k3s and k0s is more for single node instances. The other two are paid commercial options.
I then revisited the first problem - the Project sheet. I made a couple entries, one now for today
I then asked it to fetch the value for the projectID for todays session
What is the purpose of my first search then? I’m now thinking of the situation where I have a VERY large sheet. Could I trim it up perhaps?
I changed it up to be a search type which would work on larger sheets:
I finally cleaned up the echo, added some notes, parameterized the debug flag and felt it was ready to ship
The resulting server.pl:
use Data::Dumper;
use HTTP::Request;
use JSON;
use LWP::UserAgent;
use MCP::Server;
use Mojolicious::Lite -signatures;
use URI::Encode;
use URI;
use WWW::Google::Cloud::Auth::ServiceAccount;
use experimental qw(declared_refs refaliasing signatures);
use feature qw(say);
use strict;
use warnings;
my $server = MCP::Server->new;
my $USAGEPASS = $ENV{'MCP_USAGE_PASSWORD'} || 'nopassword';
my $DEBUGMODE = $ENV{'MCP_DEBUG'} + 0;
# Tool: fetch_google_sheet_row
# Description: Used to fetch a row from a google sheet based on search criteria
$server->tool(
name => 'fetch_google_sheet_row',
description => 'Fetch a row from a google sheet based on search. Range is optional (default A:Z). You can specify it with columns and rows, e.g. A1:Z378',
input_schema => {type => 'object', properties => {sheetid => {type => 'string'}, password => {type => 'string'}, range => {type => 'string'}, searchfield => {type => 'string'}, searchvalue => {type => 'string'}}, required => ['sheetid','searchfield','searchvalue']},
code => sub ($tool, $args) {
my $credsfile = 'credentials.json';
# If we mounted in as a directory with Docker, use that path
if ((-d "/creds") && (-e "/creds/credentials.json")) {
$credsfile = '/creds/credentials.json';
}
# Check on Password if required
if ($USAGEPASS ne 'nopassword') {
if (exists $args->{password}) {
if ($args->{password} ne $USAGEPASS) {
print "Invalid password provided. Rejecting request.\n";
return "Invalid password provided for fetch_google_sheet tool.\n";
} else {
print "Password accepted.\n";
}
} else {
print "No password provided. Rejecting request.\n";
return "No password provided for fetch_google_sheet tool.\n";
}
} else {
print "No usage password set, skipping password check.\n";
}
my @scopes = ('https://www.googleapis.com/auth/spreadsheets',
'https://www.googleapis.com/auth/drive');
my $auth = WWW::Google::Cloud::Auth::ServiceAccount->new(
credentials_path => $credsfile,
scope => join " ", @scopes
);
my $c = shift;
my $token = $auth->get_token();
print "token: $token\n" if ($DEBUGMODE > 0);
my $sheet_id = $args->{sheetid};
my $range = "A:Z"; # default range
if (exists $args->{range}) {
$range = $args->{range};
}
my ($return_code, $contents) =
read_spreadsheet_search($token, $sheet_id, $range, $args->{searchfield}, $args->{searchvalue});
print "Return code: $return_code\n" if ($DEBUGMODE > 0);
print "Contents: ", $contents, "\n" if ($DEBUGMODE > 0);
if ($return_code =~ /403/) {
return "You are forbidden from accessing this spreadsheet.\n";
} elsif ($return_code =~ /404/) {
return "Spreadsheet not found. Please check the Sheet ID.\n";
} else {
return $contents;
}
}
);
# Tool: fetch_google_sheet
# Description: Used to fetch a full google sheet
$server->tool(
name => 'fetch_google_sheet',
description => 'Fetch a full google sheet',
input_schema => {type => 'object', properties => {sheetid => {type => 'string'}, password => {type => 'string'}, range => {type => 'string'}}, required => ['sheetid']},
code => sub ($tool, $args) {
my $credsfile = 'credentials.json';
# If we mounted in as a directory with Docker, use that path
if ((-d "/creds") && (-e "/creds/credentials.json")) {
$credsfile = '/creds/credentials.json';
}
# Check on Password if required
if ($USAGEPASS ne 'nopassword') {
if (exists $args->{password}) {
if ($args->{password} ne $USAGEPASS) {
print "Invalid password provided. Rejecting request.\n";
return "Invalid password provided for fetch_google_sheet tool.\n";
} else {
print "Password accepted.\n";
}
} else {
print "No password provided. Rejecting request.\n";
return "No password provided for fetch_google_sheet tool.\n";
}
} else {
print "No usage password set, skipping password check.\n";
}
my @scopes = ('https://www.googleapis.com/auth/spreadsheets',
'https://www.googleapis.com/auth/drive');
my $auth = WWW::Google::Cloud::Auth::ServiceAccount->new(
credentials_path => $credsfile,
scope => join " ", @scopes
);
my $c = shift;
my $token = $auth->get_token();
print "token: $token\n" if ($DEBUGMODE > 0);
my $sheet_id = $args->{sheetid};
my $range = "A:Z"; # default range
if (exists $args->{range}) {
$range = $args->{range};
}
my ($return_code, $contents) =
read_spreadsheet_raw($token, $sheet_id, $range);
print "Return code: $return_code\n" if ($DEBUGMODE > 0);
print "Contents: ", $contents, "\n" if ($DEBUGMODE > 0);
if ($return_code =~ /403/) {
return "You are forbidden from accessing this spreadsheet.\n";
} elsif ($return_code =~ /404/) {
return "Spreadsheet not found. Please check the Sheet ID.\n";
} else {
return $contents;
}
}
);
# Google Worksheet Helper Functions
sub read_spreadsheet_search($token, $id, $range, $field, $match) {
my $encoder = URI::Encode->new();
my $finalHash = {};
my $url = sprintf 'https://sheets.googleapis.com/v4/spreadsheets/%s/values/%s',
$id, $range;
print "Request URL: $url\n" if ($DEBUGMODE > 0);
my $result = send_google_drive_get_request($url, $token);
my $status_line = $result->status_line;
if (!$result->is_success) {
return $status_line;
} else {
print $status_line, "\n" if ($DEBUGMODE > 0);
}
my $result_hash = decode_json( $result->content );
$finalHash->{match_field_index} = -1;
print "INITIAL RETURN: ", Dumper($result_hash), "\n" if ($DEBUGMODE > 0);
if (exists $result_hash->{values}) {
foreach my $row_index (0 .. $#{ $result_hash->{values} }) {
my $row = $result_hash->{values}[$row_index];
if ($row_index == 0) {
# store the keys from the table header
foreach my $col_index (0 .. $#{ $row }) {
$finalHash->{headers}[$col_index] = $row->[$col_index];
if ($row->[$col_index] =~ /$field/) {
# store the index of this column
# this is used for matching to match
$finalHash->{match_field_index} = $col_index;
}
print "Header Cell value: ", $row->[$col_index], "\n" if ($DEBUGMODE > 0);
}
} else {
# store the data values
if ($finalHash->{match_field_index} >= 0) {
# only store rows that match the match criteria
if ($row->[$finalHash->{match_field_index}] !~ /$match/) {
print "Skipping row $row_index since it does not match $match on field index ", $finalHash->{match_field_index}, "\n" if ($DEBUGMODE > 0);
next;
} else {
print "Row $row_index matches $match on field index ", $finalHash->{match_field_index}, "\n" if ($DEBUGMODE > 0);
foreach my $col_index (0 .. $#{ $row }) {
my $cell_value = $row->[$col_index];
$finalHash->{values}[$col_index]->{$finalHash->{headers}[$col_index]} = $cell_value;
print "Storing in hash: values $col_index of key ", $finalHash->{headers}[$col_index], " => $cell_value\n" if ($DEBUGMODE > 0);
}
}
}
}
}
} else {
print "No values found in the response.\n";
}
print "result_final_hash: ", Dumper($finalHash), "\n" if ($DEBUGMODE > 0);
return $status_line, Dumper($finalHash);
}
# Google Worksheet Helper Functions
sub read_spreadsheet_raw($token, $id, $range) {
my $encoder = URI::Encode->new();
my $finalHash = {};
my $url = sprintf 'https://sheets.googleapis.com/v4/spreadsheets/%s/values/%s',
$id, $range;
print "Request URL: $url\n" if ($DEBUGMODE > 0);
my $result = send_google_drive_get_request($url, $token);
my $status_line = $result->status_line;
if (!$result->is_success) {
return $status_line;
} else {
print $status_line, "\n" if ($DEBUGMODE > 0);
}
my $result_hash = decode_json( $result->content );
print "status_line: $status_line\n" if ($DEBUGMODE > 0);
print "result_hash: ", Dumper($result_hash), "\n" if ($DEBUGMODE > 0);
if (exists $result_hash->{values}) {
return $status_line, Dumper($result_hash->{values});
}
return $status_line, Dumper($result_hash);
}
sub send_google_drive_get_request( $url, $token ) {
my @headers = get_headers($token);
my $req = HTTP::Request->new('GET', $url, \@headers);
my $ua = LWP::UserAgent->new();
my $res = $ua->request($req);
return $res;
}
sub get_headers($token) {
return
'User-Agent' => 'Mozilla/8.0',
'Accept-Encoding' => 'gzip, deflate',
'Accept' => '*/*',
'Connection' => 'keep-alive',
"Authorization" => "Bearer $token";
}
any '/mcp' => $server->to_action;
app->start;
I then updated the Dockerfile to allow some passed in ENV vars:
FROM perl:5.34
RUN mkdir -p /usr/src/app
COPY . /usr/src/app
WORKDIR /usr/src/app
RUN cpanm --installdeps .
EXPOSE 8080
ENV MCP_USAGE_PASSWORD nopassword
ENV MCP_DEBUG 0
#perl ./server.pl daemon -m production -l http://127.0.0.1:8080
CMD [ "perl", "./server.pl", "daemon", "-m", "production", "-l", "http://*:8080" ]
To test the container version, I built and push to Dockerhub
$ docker build -t idjohnson/perlmcp:0.1 .
[+] Building 552.0s (11/11) FINISHED docker:default
=> [internal] load build definition from Dockerfile 0.1s
=> => transferring dockerfile: 364B 0.0s
=> [internal] load metadata for docker.io/library/perl:5.34 2.0s
=> [auth] library/perl:pull token for registry-1.docker.io 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [1/5] FROM docker.io/library/perl:5.34@sha256:6beebc97f4b6779637a112b3f0a5ecbbe25b1392cd1db8f0c0018bde9ad4c067 47.6s
=> => resolve docker.io/library/perl:5.34@sha256:6beebc97f4b6779637a112b3f0a5ecbbe25b1392cd1db8f0c0018bde9ad4c067 0.0s
=> => sha256:7900f209ef0a44813d409903a689107f7196935710ff70481ecef917847aee8e 4.63kB / 4.63kB 0.0s
=> => sha256:3d53ef4019fc129ba03f90790f8f7f28fd279b9357cf3a71423665323b8807d3 55.10MB / 55.10MB 4.7s
=> => sha256:08f0bf643eb6745d5c7e9bada33de1786ab2350240206a1956fa506a1b47b129 15.76MB / 15.76MB 1.6s
=> => sha256:6b037c2b46ab4e54a261a0ca65b12b93e00ca052e72765c9cc4caf1262a2b86c 54.59MB / 54.59MB 4.6s
=> => sha256:6beebc97f4b6779637a112b3f0a5ecbbe25b1392cd1db8f0c0018bde9ad4c067 10.30kB / 10.30kB 0.0s
=> => sha256:9974a8a6c9b8dc85e964bfc87e684711bc77a84c5bee8918c7e19eb13708c078 2.31kB / 2.31kB 0.0s
=> => sha256:6043113e1c69109f845390049c3534bbf0249241ce681aafd8e6d4bc4306dcb2 197.01MB / 197.01MB 13.0s
=> => sha256:eea815a5c8a00266affbb5c782debae7a4ab2497e1cc12d7f99b6c950f208132 15.08MB / 15.08MB 6.0s
=> => sha256:092b91f57f7d8478edfdad68b69af0bd2eebfd0d3ff12903d6ee0f7a73153af5 133B / 133B 5.0s
=> => extracting sha256:3d53ef4019fc129ba03f90790f8f7f28fd279b9357cf3a71423665323b8807d3 7.4s
=> => sha256:c2da6768c842b55cd2e3fff16b720603454beaf341fe3d537ab515c1c7465cf6 130B / 130B 5.3s
=> => extracting sha256:08f0bf643eb6745d5c7e9bada33de1786ab2350240206a1956fa506a1b47b129 1.8s
=> => extracting sha256:6b037c2b46ab4e54a261a0ca65b12b93e00ca052e72765c9cc4caf1262a2b86c 8.3s
=> => extracting sha256:6043113e1c69109f845390049c3534bbf0249241ce681aafd8e6d4bc4306dcb2 21.8s
=> => extracting sha256:092b91f57f7d8478edfdad68b69af0bd2eebfd0d3ff12903d6ee0f7a73153af5 0.0s
=> => extracting sha256:eea815a5c8a00266affbb5c782debae7a4ab2497e1cc12d7f99b6c950f208132 2.4s
=> => extracting sha256:c2da6768c842b55cd2e3fff16b720603454beaf341fe3d537ab515c1c7465cf6 0.0s
=> [internal] load build context 0.1s
=> => transferring context: 67.06kB 0.0s
=> [2/5] RUN mkdir -p /usr/src/app 1.2s
=> [3/5] COPY . /usr/src/app 0.1s
=> [4/5] WORKDIR /usr/src/app 0.0s
=> [5/5] RUN cpanm --installdeps . 498.6s
=> exporting to image 2.2s
=> => exporting layers 2.2s
=> => writing image sha256:0ad304d4d519bb2a4471325bddbb80f5ff1773a5ab818aabc93cad426ddd9457 0.0s
=> => naming to docker.io/idjohnson/perlmcp:0.1 0.0s
3 warnings found (use docker --debug to expand):
- LegacyKeyValueFormat: "ENV key=value" should be used instead of legacy "ENV key value" format (line 13)
- SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "MCP_USAGE_PASSWORD") (line 13)
- LegacyKeyValueFormat: "ENV key=value" should be used instead of legacy "ENV key value" format (line 14)
$ docker push idjohnson/perlmcp:0.1
The push refers to repository [docker.io/idjohnson/perlmcp]
80fb513771cc: Pushed
5f70bf18a086: Mounted from idjohnson/pdfding
1a79b699cc05: Pushed
e334f41581cf: Pushed
51d356c14f7b: Mounted from library/perl
9538f213ec9d: Mounted from library/perl
cf72c54274ff: Mounted from library/perl
8074245e25ed: Mounted from library/perl
71e1aa306a5a: Mounted from library/perl
69f16cc74eb0: Mounted from library/perl
82677505c894: Mounted from library/perl
0.1: digest: sha256:10e56869de81ed16d021d980beabd97ca115c69904e306be65e8c3f27229d5f6 size: 2625
I’ll make an A record with the gcloud CLI
$ gcloud dns --project=myanthosproject2 record-sets create perlmcp.steeped.icu --zone="steepedicu" --type="A" --ttl="300" --rrdatas="174.53.161.33"
NAME TYPE TTL DATA
perlmcp.steeped.icu. A 300 174.53.161.33
I can now build out a helm chart and use it to deploy.
It has Ingress disabled by default so I’ll need to turn that on in a local values file:
replicaCount: 1
image:
repository: idjohnson/perlmcp
pullPolicy: Always
# Overrides the image tag whose default is the chart appVersion.
tag: "0.1"
ingress:
enabled: true
className: "nginx"
annotations:
cert-manager.io/cluster-issuer: gcpleprod2
ingress.kubernetes.io/proxy-body-size: "0"
ingress.kubernetes.io/ssl-redirect: "true"
kubernetes.io/tls-acme: "true"
nginx.ingress.kubernetes.io/proxy-body-size: "0"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
hosts:
- host: perlmcp.steeped.icu
paths:
- path: /
pathType: ImplementationSpecific
tls:
- secretName: perlmcp-tls
hosts:
- perlmcp.steeped.icu
env:
MCP_USAGE_PASSWORD: "p@ssw0rd1234"
MCP_DEBUG: "0"
resources:
limits:
cpu: 250m
memory: 256Mi
requests:
cpu: 100m
memory: 128Mi
autoscaling:
enabled: true
minReplicas: 1
maxReplicas: 10
targetCPUUtilizationPercentage: 80
I can now install the chart
builder@DESKTOP-QADGF36:~/Workspaces/perlmcp$ helm upgrade --install perlmcp -f ./values.yml ./charts/perlmcp/
Release "perlmcp" does not exist. Installing it now.
NAME: perlmcp
LAST DEPLOYED: Fri Jan 9 16:37:38 2026
NAMESPACE: default
STATUS: deployed
REVISION: 1
NOTES:
1. Get the application URL by running these commands:
https://perlmcp.steeped.icu/
Once I saw the pod running
$ kubectl get po | grep perl
perlmcp-7994dc9fc-c2khv 1/1 Running 0 24s
and the cert was satisfied
$ kubectl get cert perlmcp-tls
NAME READY SECRET AGE
perlmcp-tls True perlmcp-tls 75s
I updated the gemini-extension to use the external ingress
{
"name": "perlmcp",
"version": "1.0.1",
"description": "Perl based MCP for Google Sheet work",
"mcpServers": {
"perlMCP": {
"httpUrl": "https://perlmcp.steeped.icu/mcp",
"timeout": 5000
}
}
}
To test, I made sure the current service was off
builder@DESKTOP-QADGF36:~/Workspaces/perlmcp$ docker ps | grep perl
builder@DESKTOP-QADGF36:~/Workspaces/perlmcp$
The Gemini Extensions showed it was using my local file which you see I updated above
$ gemini extensions list
✗ forgejomcp (1.0.2)
ID: b57a2cd1842e34ae3d8c2967ad3882f5bc79b9379d4567b83d5962043b94a147
name: a202c25a26c94dcaf5b367d46e174ac32b23810fb1939206ed9d5537d4b39722
Path: /home/builder/.gemini/extensions/forgejomcp
Source: https://codeberg.org/idjohnson/nodejsmcp.git (Type: git)
Enabled (User): false
Enabled (Workspace): false
MCP servers:
forgejomcp
✓ nanobanana (1.0.10)
ID: b919d82ef04bd903ccdbe0b1065b4910352166e9c4ca581e9da9dbe017598967
name: 090af99262e4c4334405d83c2142a3e15ebc7e4540a71e230b484b083262d8e1
Path: /home/builder/.gemini/extensions/nanobanana
Source: https://github.com/gemini-cli-extensions/nanobanana (Type: github-release)
Release tag: v1.0.10
Enabled (User): true
Enabled (Workspace): true
Context files:
/home/builder/.gemini/extensions/nanobanana/GEMINI.md
MCP servers:
nanobanana
✓ perlmcp (1.0.1)
ID: 827361b731c309c3b0b45cb01be7d63af4bcbd03115982001e23789346949512
name: ef93e10be69898d0dc3202560f0ba4b75130eb7aa48126fc4dc814b133a08ca5
Path: /home/builder/Workspaces/perlmcp/
Source: /home/builder/Workspaces/perlmcp/ (Type: link)
Enabled (User): true
Enabled (Workspace): true
MCP servers:
perlMCP
✓ vikunja (1.0.26)
ID: 786583fd8ef94d9b0a113479109fa9f5500b40e98e64ae48205eac00a917b4c3
name: 8a9937ead06ccf815cc59205ff1cc69bfe87a421bef20e446b93f494418048b4
Path: /home/builder/.gemini/extensions/vikunja
Source: https://forgejo.freshbrewed.science/builderadmin/vikunjamcp (Type: git)
Enabled (User): true
Enabled (Workspace): true
MCP servers:
nodeServer
So when I launched it and listed, I did see the proper tools at play.
My first test revealed I neglected to sort out the credentials.json in my chart
The logs showed the error in Kubernetes
$ kubectl logs perlmcp-7994dc9fc-c2khv
The signatures feature is experimental at ./server.pl line 25.
The signatures feature is experimental at ./server.pl line 88.
The signatures feature is experimental at ./server.pl line 146.
The signatures feature is experimental at ./server.pl line 205.
The signatures feature is experimental at ./server.pl line 230.
The signatures feature is experimental at ./server.pl line 238.
[2026-01-09 22:49:15.03984] [1] [info] Listening at "http://*:8080"
[2026-01-09 23:01:12.26752] [1] [error] [OF0_wV7VBTB2] Can't open credentials file: No such file or directory at /usr/local/lib/perl5/site_perl/5.34.3/WWW/Google/Cloud/Auth/ServiceAccount.pm line 84.
First, I’ll fetch my secret and add it as a K8s secret we’ll reference in the values
builder@DESKTOP-QADGF36:~/Workspaces/perlmcp$ getpass.sh gcp-sa-myanthosproj2-perlmcp idjakv | base64 --decode > ./credentials.json
builder@DESKTOP-QADGF36:~/Workspaces/perlmcp$ kubectl create secret generic perlmcp-credentials --from-file=credentials.json=./credentials.json
secret/perlmcp-credentials created
Then I can use it in the volumes and volumesMounts blocks that are already in the chart (just set to empty right now)
volumes:
- name: credentials
secret:
secretName: perlmcp-credentials
volumeMounts:
- name: credentials
mountPath: /creds
readOnly: true
A quick upgrade to make live
$ helm upgrade --install perlmcp -f ./values.yml ./charts/perlmcp/
Release "perlmcp" has been upgraded. Happy Helming!
NAME: perlmcp
LAST DEPLOYED: Fri Jan 9 17:08:36 2026
NAMESPACE: default
STATUS: deployed
REVISION: 5
NOTES:
1. Get the application URL by running these commands:
https://perlmcp.steeped.icu/
Just to be certain it worked, I’ll check the pod for the file
$ kubectl get po | grep perlmcp
perlmcp-b4c6785-gsw8c 1/1 Running 0 39s
$ kubectl exec -it perlmcp-b4c6785-gsw8c -- /bin/bash
root@perlmcp-b4c6785-gsw8c:/usr/src/app# cd /
root@perlmcp-b4c6785-gsw8c:/# cd creds/
root@perlmcp-b4c6785-gsw8c:/creds# ls
credentials.json
root@perlmcp-b4c6785-gsw8c:/creds# exit
exit
My next issue came about that fetching the UA token fails because we are now using HTTPS for ingress (even though I have TLS termination)
$ kubectl logs perlmcp-b4c6785-gsw8c
The signatures feature is experimental at ./server.pl line 25.
The signatures feature is experimental at ./server.pl line 88.
The signatures feature is experimental at ./server.pl line 146.
The signatures feature is experimental at ./server.pl line 205.
The signatures feature is experimental at ./server.pl line 230.
The signatures feature is experimental at ./server.pl line 238.
[2026-01-09 23:08:42.95105] [1] [info] Listening at "http://*:8080"
[2026-01-09 23:11:36.66976] [1] [error] [FaYoks0WsPQW] 501 Protocol scheme 'https' is not supported (LWP::Protocol::https not installed) LWP will support https URLs if the LWP::Protocol::https module
is installed.
at ./server.pl line 122.
Before I crank in to fix, I better save my work. It’s been a while
builder@DESKTOP-QADGF36:~/Workspaces/perlmcp$ git add README.md
builder@DESKTOP-QADGF36:~/Workspaces/perlmcp$ git add WORKSHEET.md
builder@DESKTOP-QADGF36:~/Workspaces/perlmcp$ git add server.pl
builder@DESKTOP-QADGF36:~/Workspaces/perlmcp$ git add cpanfile
builder@DESKTOP-QADGF36:~/Workspaces/perlmcp$ git add gemini-extension.json
builder@DESKTOP-QADGF36:~/Workspaces/perlmcp$ git add charts/
builder@DESKTOP-QADGF36:~/Workspaces/perlmcp$ git commit -m "working locally and with http, testing https"
[wip-server-updates 66d9453] working locally and with http, testing https
17 files changed, 591 insertions(+), 29 deletions(-)
create mode 100644 charts/perlmcp/Chart.yaml
create mode 100644 charts/perlmcp/templates/NOTES.txt
create mode 100644 charts/perlmcp/templates/_helpers.tpl
create mode 100644 charts/perlmcp/templates/deployment.yaml
create mode 100644 charts/perlmcp/templates/hpa.yaml
create mode 100644 charts/perlmcp/templates/ingress.yaml
create mode 100644 charts/perlmcp/templates/service.yaml
create mode 100644 charts/perlmcp/templates/serviceaccount.yaml
create mode 100644 charts/perlmcp/templates/tests/test-connection.yaml
create mode 100644 charts/perlmcp/templates/tests/test-connection.yaml:Zone.Identifier
create mode 100644 charts/perlmcp/values.yaml
create mode 100644 gemini-extension.json
I added the library and built and pushed a new container
$ export VER=0.2 && docker build -t idjohnson/perlmcp:$VER . && docker push idjohnson/perlmcp:$VER
[+] Building 468.8s (11/11) FINISHED docker:default
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 364B 0.0s
=> [internal] load metadata for docker.io/library/perl:5.34 0.7s
=> [auth] library/perl:pull token for registry-1.docker.io 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [1/5] FROM docker.io/library/perl:5.34@sha256:6beebc97f4b6779637a112b3f0a5ecbbe25b1392cd1db8f0c0018bde9ad4c067 0.0s
=> [internal] load build context 0.0s
=> => transferring context: 46.58kB 0.0s
=> CACHED [2/5] RUN mkdir -p /usr/src/app 0.0s
=> [3/5] COPY . /usr/src/app 0.1s
=> [4/5] WORKDIR /usr/src/app 0.0s
=> [5/5] RUN cpanm --installdeps . 466.0s
=> exporting to image 1.9s
=> => exporting layers 1.9s
=> => writing image sha256:66148b84a034194448c04b4e8eb4b9861c93a0e6d6274421d36932d239cc8803 0.0s
=> => naming to docker.io/idjohnson/perlmcp:0.2 0.0s
3 warnings found (use docker --debug to expand):
- LegacyKeyValueFormat: "ENV key=value" should be used instead of legacy "ENV key value" format (line 13)
- SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "MCP_USAGE_PASSWORD") (line 13)
- LegacyKeyValueFormat: "ENV key=value" should be used instead of legacy "ENV key value" format (line 14)
The push refers to repository [docker.io/idjohnson/perlmcp]
ad0c738bb887: Pushed
5f70bf18a086: Layer already exists
2b3c07fe9632: Pushed
e334f41581cf: Layer already exists
51d356c14f7b: Layer already exists
9538f213ec9d: Layer already exists
cf72c54274ff: Layer already exists
8074245e25ed: Layer already exists
71e1aa306a5a: Layer already exists
69f16cc74eb0: Layer already exists
82677505c894: Layer already exists
0.2: digest: sha256:ba4f656d9dafafc069fb9c6328f5758cefd3773b390c6bcbdb27bb8074516237 size: 2625
To switch up containers is just a one-liner in the values file (change the tag)
builder@DESKTOP-QADGF36:~/Workspaces/perlmcp$ cat values.yml | head -n8
replicaCount: 1
image:
repository: idjohnson/perlmcp
pullPolicy: Always
# Overrides the image tag whose default is the chart appVersion.
tag: "0.2"
builder@DESKTOP-QADGF36:~/Workspaces/perlmcp$ helm upgrade --install perlmcp -f ./values.yml ./charts/perlmcp/
Release "perlmcp" has been upgraded. Happy Helming!
NAME: perlmcp
LAST DEPLOYED: Sat Jan 10 07:20:30 2026
NAMESPACE: default
STATUS: deployed
REVISION: 6
NOTES:
1. Get the application URL by running these commands:
https://perlmcp.steeped.icu/
builder@DESKTOP-QADGF36:~/Workspaces/perlmcp$ kubectl get po | grep perlmcp
perlmcp-6d6d46f4d4-wc8lh 0/1 Running 0 13s
perlmcp-b4c6785-gsw8c 1/1 Running 0 14h
builder@DESKTOP-QADGF36:~/Workspaces/perlmcp$ kubectl get po | grep perlmcp
perlmcp-6d6d46f4d4-wc8lh 1/1 Running 0 30s
Let’s test with the public K8s sheet
A quick test can be performed with the MCP Inspector tool:
We can try with Aggy
With the prompt
Using p@ssw0rd1234 for password, use the perlMCP mcp server tool fetch_google_sheet to fetch the sheetid 1uF9BoDzzisHSQemXHIKegMhuythuq_GL3N1mlUUK2h0 and the range of ‘A1:AK146’. This sheet lists Kubernetes Distributions and Platforms by Company. I want to find a Product (Product Name) that supports 1.30 and 1.31 and the Certified value is greater than 0
That was blazing fast.
Lastly, let’s use Gemini CLI for a private sheet my Service Account can access.
It’s still a bit leaky with secrets so I did use a video editor to hide a credential, but I didn’t trim or speed it up.
I was surprised it remembered (or figured out from context) the SheetID because I forgot to mention it on my request above.
Sharing this version
At this point, I have an MCP Server I would use. I’m happy with it and it’s quite scalable.
I pushed up the code thus far into a PR
While there is a Gemini-extension.json file
The extensions directory, to date, only looks at Github so this is only useful for local testing presently.
Next steps
This had me thinking a bit about the requirements. This is tied to my private GCP Service Account and what it can access and is just hidden by a password (which I may or may not rotate). It could be used by others, in it’s present state, to access public repos or private if they added my SA to their worksheets
I’m not too worried about API Key usage costs, but it seems like a design that might not be great for everyone.
Our choices might be:
- Leave as is and document how people can use it for their own work
- Docker locally with private gemini link or MCP server entry to http://localhost:8080/mcp
- hosted in their own K8s with their own Password
- Change it to allow a credentials.json instead of a password
- (both/and) change it to require either a password (to use the baked in creds) OR a credentials.json (bring your own key).
My gut is leaning to the last one.
However, at this point we’ve really gone a long way so let’s push this PR1 through and call version 1 done.
This is public, but I’ll likely keep going so I made a nice version1.0 tag you can use to fetch/review







































