perl.gg / modern-perl

<!-- category: modern-perl -->

Building an MCP Server in Perl

2026-03-14

AI assistants can write your code, summarize your docs, and explain quantum physics. But ask one to check your disk space and it shrugs. It has no hands. No access to your systems.

Unless you give it tools.

Model Context Protocol (MCP) is how AI agents discover and call external tools. You build a small HTTP server that wraps your system commands in discoverable endpoints. The AI reads a spec, sees what's available, calls the endpoints, and gets real data back. Building that server in Perl with Mojolicious takes about 80 lines.

+-----------+ +-------------------+ +--------+ | | HTTP | | shell | | | AI Agent | ------> | MCP Server (Perl) | -----> | System | | | <------ | | <----- | | +-----------+ JSON +-------------------+ text +--------+ "Check disk" POST /tools/check_disk df -h "Load avg?" POST /tools/check_load uptime

Part 1: WHAT IS MCP?

MCP is a protocol for AI tool discovery and invocation. The core idea is simple:
  1. Your server publishes an OpenAPI spec at /openapi.json describing its tools
  2. An AI agent reads that spec and learns what tools exist
  3. When a user asks something that needs a tool, the agent calls the matching HTTP endpoint
  4. Your server runs the command and returns JSON with a result key
  5. The agent incorporates the real output into its response

That's the whole loop. The AI goes from "I can't access your system" to "Your root partition is at 73%, you have 124GB free."

The spec is the contract. Without it, the AI doesn't know your tools exist. With it, the AI can choose the right tool for each request.

Part 2: THE STACK

You need one thing: Mojolicious.
cpanm Mojolicious
That gives you:

If you already have Mojo installed, you can have an MCP server running in five minutes. If you don't have it installed, you should. It's the best thing in the Perl ecosystem.

Part 3: THE SKELETON

Every MCP server needs these pieces. Skip one and something breaks.

CORS headers. Browser-based AI interfaces (like Open WebUI) make cross-origin requests. Without CORS, the browser blocks them silently and you'll spend an hour debugging "why isn't it working."

OPTIONS handler. Browsers send a preflight OPTIONS request before the actual POST. You need to return 200 or the real request never fires.

Bearer token auth. Every protected endpoint checks the Authorization header. Without this, anyone on the network can run commands on your box.

OpenAPI spec. Published at /openapi.json. The AI reads this to discover what tools you offer. No spec, no discovery, no tools.

Hostname in tool names. Running MCP servers on multiple machines? Tool names collide unless each includes the hostname. check_disk is ambiguous. check_disk_webserver01 is not.

The result key. MCP expects { "result": "..." } in responses. Not output, not data. Must be result.

Part 4: MINIMAL WORKING SERVER

Here's a complete MCP server with two system monitoring tools:
#!/usr/bin/env perl use Mojolicious::Lite -signatures; # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # Configuration # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ my $TOKEN = 'your-secret-token-here'; app->secrets(['mcp-app-secret']); my $HOST = `hostname`; chomp $HOST; # tool names include hostname to prevent collisions my $DISK_TOOL = "check_disk_$HOST"; my $LOAD_TOOL = "check_load_$HOST"; # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # CORS (required for browser UIs) # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ hook before_render => sub ($c, $args) { $c->res->headers->header('Access-Control-Allow-Origin' => '*'); $c->res->headers->header('Access-Control-Allow-Methods' => 'GET, POST, OPTIONS'); $c->res->headers->header('Access-Control-Allow-Headers' => 'Content-Type, Authorization'); }; options '*' => sub ($c) { $c->render(text => '', status => 200); }; # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # Auth check # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ sub check_auth ($c) { my $auth = $c->req->headers->authorization // ''; return $auth eq "Bearer $TOKEN"; } # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # Tool functions # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ sub disk_check { warn "[MCP] Running: df -h\n"; return `df -h 2>&1`; } sub load_check { warn "[MCP] Running: uptime\n"; return `uptime 2>&1`; }
Nothing fancy yet. Configuration, CORS, auth helper, two functions that run shell commands and return the output. The warn lines give you an audit trail in the Mojo log.

Part 5: DISCOVERY ENDPOINTS

The AI needs to find your tools. That happens through the OpenAPI spec:
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # Root and OpenAPI discovery # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ get '/' => sub ($c) { $c->render(json => { service => "Server Monitor", hostname => $HOST, tools => [$DISK_TOOL, $LOAD_TOOL], }); }; get '/openapi.json' => sub ($c) { return $c->render(json => { error => "Unauthorized" }, status => 401) unless check_auth($c); $c->render(json => { openapi => "3.0.0", info => { title => "Server Monitor", version => "1.0.0" }, paths => { "/tools/$DISK_TOOL" => { post => { summary => "Check disk usage on $HOST", operationId => $DISK_TOOL, security => [{ bearerAuth => [] }], responses => { 200 => { description => "Disk usage" } }, }, }, "/tools/$LOAD_TOOL" => { post => { summary => "Check system load on $HOST", operationId => $LOAD_TOOL, security => [{ bearerAuth => [] }], responses => { 200 => { description => "Load average" } }, }, }, }, components => { securitySchemes => { bearerAuth => { type => "http", scheme => "bearer" }, }, }, }); };
The summary field is what the AI sees when deciding which tool to call. Make it descriptive. "Check disk usage on webserver01" tells the AI exactly when this tool is relevant.

Part 6: TOOL ENDPOINTS

The actual tools. POST endpoints that run commands and return results:
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # Tool endpoints # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ post "/tools/$DISK_TOOL" => sub ($c) { return $c->render(json => { error => "Unauthorized" }, status => 401) unless check_auth($c); $c->render(json => { result => disk_check() }); }; post "/tools/$LOAD_TOOL" => sub ($c) { return $c->render(json => { error => "Unauthorized" }, status => 401) unless check_auth($c); $c->render(json => { result => load_check() }); }; # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # Health and tools list # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ get '/health' => sub ($c) { return $c->render(json => { error => "Unauthorized" }, status => 401) unless check_auth($c); $c->render(json => { status => "healthy", hostname => $HOST, tools => [$DISK_TOOL, $LOAD_TOOL], }); }; # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # Start the server # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ app->start('daemon', '-l', 'http://*:8080');
Save the whole thing as server-monitor.pl. Run it with perl server-monitor.pl. Done.

Part 7: TESTING WITH CURL

Verify everything works before connecting an AI:
# root endpoint (no auth needed) curl http://localhost:8080/ # health check (auth required) curl -H "Authorization: Bearer your-secret-token-here" \ http://localhost:8080/health # call a tool curl -X POST \ -H "Authorization: Bearer your-secret-token-here" \ http://localhost:8080/tools/check_disk_yourhostname
The tool endpoint returns real df -h output wrapped in JSON:
{"result":"Filesystem Size Used Avail Use% Mounted on\n/dev/sda1..."}
If that works, your MCP server is ready for an AI client.

Part 8: TOOLS THAT ACCEPT PARAMETERS

Some tools need input. A log searcher needs a pattern. A database query tool needs a table name. MCP handles this through the JSON request body.
my $LOG_SEARCH = "search_logs_$HOST"; post "/tools/$LOG_SEARCH" => sub ($c) { return $c->render(json => { error => "Unauthorized" }, status => 401) unless check_auth($c); my $params = $c->req->json // {}; my $pattern = $params->{pattern} // 'error'; my $lines = $params->{lines} // 50; # sanitize inputs (CRITICAL) $pattern =~ s~[^a-zA-Z0-9._\-\s]~~g; $lines = int($lines); $lines = 50 if $lines > 1000; warn "[MCP] Searching logs for: $pattern (max $lines)\n"; my $result = `grep -i '$pattern' /var/log/syslog | tail -$lines 2>&1`; $c->render(json => { result => $result }); };
Add the parameter schema to your OpenAPI spec so the AI knows what to send:
"/tools/$LOG_SEARCH" => { post => { summary => "Search system logs on $HOST", operationId => $LOG_SEARCH, security => [{ bearerAuth => [] }], requestBody => { content => { "application/json" => { schema => { type => "object", properties => { pattern => { type => "string", description => "Search pattern", }, lines => { type => "integer", description => "Max result lines", }, }, }, }, }, }, responses => { 200 => { description => "Search results" } }, }, },
Input sanitization is not optional. You're taking user input (from an AI, which takes it from a human) and passing it to a shell command. Whitelist characters. Cap ranges. Never pass raw input to backticks.

Part 9: PRACTICAL TOOL IDEAS

The pattern is always the same: write a function, add it to the spec, create a POST endpoint. Here are tools that make real sense:

File search tool. Expose find and grep with sanitized inputs. The AI can answer "where's the nginx config?" by actually searching.

Database query tool. Read-only queries against your monitoring database. The AI can pull real metrics instead of guessing.

Process list. Wrap ps aux or top -bn1. "What's eating CPU?" gets a real answer.

Service status. Check if nginx, postgres, redis are running. Restart them if authorized.

Disk cleanup. Find large files, old logs, temp directories. Report what can be safely removed.

The Perl advantage here is text processing. Every one of these tools returns text that needs parsing, filtering, or formatting. Perl was born for this. Your tool functions aren't just running commands, they're massaging the output into something useful.

sub find_large_files { my $raw = `find /var/log -size +100M -type f -exec ls -lh {} + 2>&1`; # clean up and format the output my @lines = split m~\n~, $raw; my @formatted = map { my @f = split m~\s+~, $_, 9; sprintf "%s %s %s", $f[4], $f[7], $f[8]; } @lines; return join "\n", sort @formatted; }
Raw shell output in, clean formatted text out. Perl doing what Perl does.

Part 10: SECURITY

Let's be direct. You're building an HTTP endpoint that runs shell commands. Powerful and dangerous.

Generate real tokens. Run openssl rand -hex 32. Not "your-secret-token-here." Store the token outside the script in an environment variable or config file.

HTTPS in production. Put nginx in front with a real certificate. The built-in Mojo server is fine for development, not for facing a network.

Restrict CORS. Change * to your actual domain. Open CORS means any website can call your tools if they somehow get the token.

Sudoers, not sudo. If a tool needs elevated privileges, create a sudoers entry for that specific command only:

mcpuser ALL=(ALL) NOPASSWD: /usr/bin/df, /usr/bin/uptime, /usr/sbin/netstat
Timeouts for slow commands. Backups, log analysis, anything that takes time:
post "/tools/$BACKUP_TOOL" => sub ($c) { return $c->render(json => { error => "Unauthorized" }, status => 401) unless check_auth($c); $c->inactivity_timeout(1800); # 30 minutes my $result = `tar czf /backup/full.tar.gz /data 2>&1`; $c->render(json => { result => $result }); };
Audit everything. Every tool function should warn what it ran. Those lines end up in the Mojo log with timestamps. You want a paper trail.

Part 11: WHY PERL FOR THIS?

You could build MCP servers in Python, Node, Go, Rust. But Perl has three things going for it here.

Mojolicious is batteries-included. Web server, JSON, routing, CORS, WebSocket support, all in one use statement. No virtual environments, no package.json, no dependency tree that takes five minutes to install.

Perl is already on the server. Every Linux and macOS box has it. Your MCP server lives right next to the systems it monitors. No container needed. No build step.

Shell integration is native. Backticks, system(), open(my $fh, '-|', ...), qx{}. Perl was born to wrap shell commands. That's literally what MCP tools do.

The entire server is one file. Copy it, run it, done. That's your deployment process.

perl server-monitor.pl
Perl in the AI age, doing what Perl has always done: being the glue between things.
AI Agent | "check disk space" | v +------+-------+ | Perl MCP | | Server | | (Mojo::Lite) | +------+-------+ | `df -h` | v Real Output | { "result": "..." } | v .--. |o_o | "You have 124GB free |:_/ | on /dev/sda1." // \ \ (| | ) /'\_ _/`\ \___)=(___/ Perl: gluing AI to reality.
perl.gg