<!-- category: modern-perl -->
Building an MCP Server in Perl
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:- Your server publishes an OpenAPI spec at
/openapi.jsondescribing its tools - An AI agent reads that spec and learns what tools exist
- When a user asks something that needs a tool, the agent calls the matching HTTP endpoint
- Your server runs the command and returns JSON with a
resultkey - 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.That gives you:cpanm Mojolicious
- A web server (built in, no Apache or nginx needed for dev)
- JSON rendering
- Route handling
- CORS support through hooks
- Zero external dependencies beyond core Perl
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:Nothing fancy yet. Configuration, CORS, auth helper, two functions that run shell commands and return the output. The#!/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`; }
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:The# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # 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" }, }, }, }); };
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:Save the whole thing as# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # 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');
server-monitor.pl. Run it with perl server-monitor.pl. Done.
Part 7: TESTING WITH CURL
Verify everything works before connecting an AI:The tool endpoint returns real# 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
df -h output wrapped in JSON:
If that works, your MCP server is ready for an AI client.{"result":"Filesystem Size Used Avail Use% Mounted on\n/dev/sda1..."}
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.Add the parameter schema to your OpenAPI spec so the AI knows what to send: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 }); };
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."/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" } }, }, },
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.
Raw shell output in, clean formatted text out. Perl doing what Perl does.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; }
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:
Timeouts for slow commands. Backups, log analysis, anything that takes time:mcpuser ALL=(ALL) NOPASSWD: /usr/bin/df, /usr/bin/uptime, /usr/sbin/netstat
Audit everything. Every tool function shouldpost "/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 }); };
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 in the AI age, doing what Perl has always done: being the glue between things.perl server-monitor.pl
perl.ggAI 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.