NAME
Punk::MCP - any Punk application as a Model Context Protocol server
VERSION
Version 0.01
SYNOPSIS
package MyApp;
use Punk;
use Punk::Plugin::MCP;
plugin 'MCP' => { path => '/mcp', name => 'acme' };
mcp tool => search_books => {
description => 'Full-text search over the catalogue.',
input => { type => 'object',
properties => { q => { type => 'string' } },
required => ['q'] },
} => 'Books#search';
DESCRIPTION
Punk::MCP lets a Punk application answer the Model Context Protocol, so MCP clients can call its tools, read its resources and fetch its prompts. A tool call runs the same controller action a web route runs, so guards, validation, models, logging and observers apply to it as they already do.
This is the documentation module of the Punk-MCP distribution. The plugin is Punk::Plugin::MCP, and the punk mcp command is Punk::Command::Mcp.
The example
example/ in the distribution is a small bookshop: web routes, and the same actions as tools, resources and a prompt. Its add_book tool runs an action an admin guard protects on the web, so it declares that guard too (see "The guard rule").
plackup -s Hyperman example/app.psgi
claude mcp add --transport http bookshop http://localhost:5000/mcp
claude mcp add bookshop -- punk mcp --dir example # over stdio
add_book wants the admin key in X-Admin-Key: BOOKSHOP_ADMIN_KEY in the environment, change-me without it.
OPTIONS
plugin 'MCP' => { ... } takes these, and an unknown one is a boot croak naming the ones it accepts.
- path
-
The endpoint, default
/mcp. - name, title, version, description, website, icons, instructions
-
What
initializesays about the server.namedefaults to the application's class andversionto its$VERSION, else0.websiteis an http or https URL,iconsa list of{ src, mimeType, sizes, theme }, andinstructionsis the text a client may hand its model. - auth, oauth, principal
-
Who may call. See "Who may call".
- openapi, ops
-
Tools from an OpenAPI mount. See "TOOLS FROM AN OPENAPI MOUNT".
- session, session_ttl, max_sessions
-
sessionis0(the default, stateless) or the name of a shared Punk::Cache store.session_ttlis in seconds, default 3600.max_sessionsbounds how many are made in onesession_ttl, default 10000. See "Sessions". - stream
-
auto(the default),jsonoralways. See "Streaming". - origins
-
The origins a browser client may call from. See "Origin".
- page_size
-
How many tools, resources or prompts a list answers with before
nextCursor, default 50, at most 1000. - max_body
-
The largest request body in bytes, default 1048576. A larger one is 413.
- logging
-
True to advertise the
loggingcapability, so$c->mcp_logsends. It needs a session, or stdio. - timeout
-
The default for every declaration, in seconds. Without one a call may run as long as its action does.
- stdio_trust
-
True to skip
authandoauthoverpunk mcp. See Punk::Command::Mcp.
DECLARING
mcp tool => $name => \%spec => $target;
mcp resource => $uri => \%spec => $target;
mcp prompt => $name => \%spec => $target;
%spec may be left out. $target is a code ref or a 'Controller#method' string, as a route's is, and the action gets a context of its own whose param holds the arguments (a tool's), the template's variables (a resource's) or the arguments (a prompt's). The declarations are fixed when the application compiles: none are added or removed while it runs, so no listChanged is claimed.
A tool's %spec: title, description, input and output (JSON Schemas), annotations (title, readOnlyHint, destructiveHint, idempotentHint, openWorldHint), stream, guard, icons, timeout. A name is 1 to 128 of A-Z a-z 0-9 _ - ..
A resource's %spec: name, title, description, mime, size, annotations (audience, priority, lastModified), guard, icons, timeout. A URI with {var} in it is a template (level 1 of RFC 6570: no operators). Two templates that could match one URI are a boot croak.
A prompt's %spec: title, description, arguments (a list of { name, description, required }), guard, icons, timeout. A required argument that is missing is -32602 before the action runs.
Template values are not to be trusted
A template's variables come from the URI the client sent. They are decoded, never hold a raw /, ? or #, and are otherwise whatever the client chose. Treat them as you would a route's captures: look the record up, never build a path or a query from one.
The guard rule
A tool calls its action directly, not through the route that serves it on the web. So a declaration whose action sits behind a guarded route must say how it is guarded, or to_app croaks naming the route:
mcp tool => delete_book => { guard => 'Auth#admin' } => 'Books#delete';
mcp tool => list_books => { guard => 'none' } => 'Books#list';
guard takes what auth takes, and 'none' exposes the action to anyone past the endpoint's own auth. A caller a tool's guard refuses does not see the tool in tools/list, and calling it anyway is a tool error. A resource's or prompt's guard makes the thing look absent.
WHAT AN ACTION RETURNS
A tool
a string: one text content block
undef: no content
a hash with
content: a tool result as is,isErrormade a booleanany other hash:
structuredContent, with its JSON as text too. With anoutputschema it must match, and a mismatch is a tool error whose text names what failedan array: its JSON as text
a PSGI triplet (what
$c->jsonand$c->rendergive): a JSON object body is structured, anything else is text, a status of 400 or more isisError, and the status is in_meta.statusa Punk::Future: awaited, then any of the above. With a
timeoutit races a timer, and losing is a tool error. A blocking action cannot be interrupteda die: a tool error with the message
A tool with an output schema must return a hash or a triplet. Arguments that fail input are a tool error on 2025-11-25 and -32602 before it.
A resource
A string is one content entry: text when mime is textual or the bytes are UTF-8, else base64 blob. A hash or an array is its JSON, as application/json. A hash with contents is the answer as is. A triplet gives its body, and a 404 is -32002. undef is -32002 too, and a die is -32603.
A prompt
A string is one user message. An array of { role, content } is the messages, and a hash with messages may add a description. role is user or assistant, and content a string or a content block.
CONTEXT HELPERS
Inside a tool, resource or prompt:
$c->mcp-
The call:
method,name,arguments,protocol,progress_token,session,clientandcancelled. $c->mcp_user-
What
principalreturned, else$c->current_user. $c->mcp_progress($done, $total, $message)$c->mcp_log($level, $data, $logger)-
See "Streaming".
$c->mcp_resource_updated($uri)
OPENTELEMETRY
With Punk::Plugin::OpenTelemetry registered, each MCP request gets a span of its own under the request's POST /mcp span, named as the OpenTelemetry MCP conventions name it: tools/call search_books, prompts/get greet, resources/read. It carries mcp.method.name, gen_ai.tool.name or gen_ai.prompt.name, mcp.resource.uri, jsonrpc.request.id, mcp.session.id and mcp.protocol.version. A JSON-RPC error sets error.type to its code and the status to error. A tool error sets error.type to tool_error and leaves the status alone, since the call itself worked. A request that was not sampled, a notification and a message over stdio make none.
TOOLS FROM AN OPENAPI MOUNT
An application that already mounts an OpenAPI document gets its operations as tools without declaring them again:
my $api = api 'openapi.json' => { security => { bearer => $checker } };
plugin 'MCP' => { openapi => $api }; # or '*', the spec, or the prefix
plugin 'MCP' => { openapi => '*', ops => [qw(listBooks getBook)] };
Each operation is a tool named after its operationId, lowercased, with anything but letters, digits, _ and - made _, and _2 and on when two land on one name. A tool the mcp keyword declares by the same name is a boot croak. The description is the summary and the description, the input schema is the parameters with the request body under body, and the annotations come from the method alone. An operation marked x-mcp: false is left out, and x-mcp: { name, title, description, annotations } overrides those.
A call is a request the application answers: built from the arguments, marked pmcp.inner and pmcp.tool in its env, and handed to the application inside its own middleware. The mount's security checker, its validation and its guards all run on it, and its answer becomes the tool's result, a 4xx or 5xx being a tool error with the status in _meta.status. The caller's Authorization goes to this mount and nowhere else. With oauth, the token was issued for the MCP endpoint, so the mount's own checker must accept that audience, or every call is the mount's 401. A cookie parameter is not passed.
OVER STDIO
punk mcp serves the same declarations to an MCP client that launches its servers as subprocesses:
claude mcp add acme -- punk mcp --dir /srv/acme --token "$TOKEN"
See Punk::Command::Mcp.
THE ENDPOINT
plugin 'MCP' adds one route, POST on path (default /mcp). It checks, in this order: the Origin (403), that Accept allows application/json (406), that the body is application/json (415), the MCP-Protocol-Version header (400 when it names a version this server does not speak, and read as 2025-03-26 when absent), the body size against max_body (413), and then the message. Every answer is Cache-Control: no-store. Other methods on the path are 405, from Punk's router.
Streaming
A request can be answered with text/event-stream: notifications first (progress, log messages), then the response as the last event, then the stream closes. It happens only when the client's Accept lists text/event-stream, and then under stream => 'auto' (the default) when the tool is declared stream => 1 or the request carries a progressToken, or under stream => 'always' for every request. stream => 'json' never streams.
JSON is the default because on HTTP/1.1 a streamed answer ends the connection, so every streamed call costs the client a reconnect.
Inside a call, $c->mcp_progress($done, $total, $message) sends progress against the request's token, and $c->mcp_log($level, $data, $logger) sends a log message at or above the level the client set with logging/setLevel (info until it does). Each returns 1 when it sent and 0 when there was nowhere to send. A progress value not above the last one is dropped with a warning.
mcp_log also writes the message to the request logger, $c->log, whatever level the client set and whether or not there is a client to send to, so an action that runs as a web route as well logs there too. The level is the nearest of Punk's: notice is info, warning is warn, and critical, alert and emergency are fatal. A plain hash's keys become the record's fields, a plain array is the data field, anything else is the message, and $logger is the logger field. $c->mcp->cancelled turns true when the client cancels a call that is streaming on this worker.
A streamed answer does not pass through after_dispatch hooks and gets no ETag, as any Punk::Stream answer does not.
Sessions
The endpoint is stateless unless session names a store:
cache mcp => { backend => 'file', dir => '/var/cache/mcp' };
plugin 'MCP' => { session => 'mcp' };
The store must be declared with cache and shared across the worker pool (the file backend is), or to_app croaks naming it. With a session:
initializeanswers with anMcp-Session-Id, and every later request must carry it: none is 400, and one that is unknown, expired or made with another credential is 404.The session keeps the negotiated protocol version, so a request without
MCP-Protocol-Versionuses it, and one naming another is 400.logging/setLevelapplies to the session alone.A session lives
session_ttlseconds from its last use (the expiry moves at most once a minute, so most requests only read).DELETEends it with 204.initializeis bounded: at mostmax_sessionsmade in onesession_ttl(503 withRetry-Afterbeyond it), and 30 a minute from one address (429).
Logging, resource subscriptions and anything else the server sends unasked need a session, so without one they are not advertised.
The server stream and subscriptions
With sessions, GET on the path (with Accept: text/event-stream and the session id) opens the session's server stream. A session has one: a second GET closes the first, on whichever worker held it, and DELETE closes it too.
A client subscribes with resources/subscribe to a URI the application declares (a template's match counts) and may see. When the resource changes, say so:
$c->mcp_resource_updated('books://7'); # in a request
Punk::Plugin::MCP->resource_updated('MyApp', 'books://7'); # anywhere
Every session subscribed to that URI gets notifications/resources/updated on its stream, on whichever worker holds it: this worker's streams directly, the others over Punk's bus. Each returns what the bus said: 1 when the pool will see it, 0 when there is no pool (no Hyperman) and only this worker did, -1 when the URI is too long for a bus slot (2KB), which is also warned.
Who may call
auth is any Punk guard, or a list of them: a code ref, a 'Controller#method' target, or a core guard such as api_key_guard(scope => 'mcp'). Every one must pass. The same list guards POST, GET and DELETE, and it runs before the body is read. A refusal goes out as the guard made it.
oauth makes the endpoint an OAuth 2.1 resource server, the kind the Claude.ai and ChatGPT connectors discover:
host 'https://acme.test';
plugin 'MCP' => { oauth => {
issuer => 'https://id.acme.test',
jwks_url => 'https://id.acme.test/jwks', # or key, or introspect
scopes => ['mcp'],
} };
It needs Punk::OAuth2 0.08 and the application's host, since a token's audience is this endpoint's URL (host and path), and a token issued for any other resource is refused. audience overrides it, and may be a list. With introspect the introspection answer must name the audience. A request without a good token gets 401 with an RFC 6750 challenge naming the scopes and the resource_metadata document, and one short of a scope gets 403 insufficient_scope. The claims are in $c->stash->{auth}{oauth}, and the RFC 9728 document is served at /.well-known/oauth-protected-resource and the same with path after it. The client's token is never sent anywhere.
principal runs once a request is let in, and what it returns is $c->mcp_user in every tool, resource and prompt. A bearer caller has no $c->current_user, so this is how a tool learns who called:
principal => sub { my ($c) = @_; $c->stash->{auth}{oauth}{sub} },
Origin
A request with no Origin is a client that is not a browser, and passes. With origins the Origin must be one of them. Without, it must be the server's own: the request's scheme and Host. Origin: null never passes.
CSRF
Punk's csrf check spares a request that carries a Bearer-style Authorization, and nothing else. An application with csrf on whose MCP clients authenticate any other way gets Punk's 403 before the plugin runs. The endpoint is not a CSRF target (it requires application/json, which a cross-site form cannot send, and it checks Origin), so exempt it:
csrf exempt => ['/mcp'];
CORS
A browser client needs the application's cors to cover the path. The plugin exposes Mcp-Session-Id, MCP-Protocol-Version and WWW-Authenticate on its own answers, so expose need not list them. A cors headers list must allow Authorization, Content-Type, Mcp-Session-Id, MCP-Protocol-Version and Last-Event-ID, and the plugin warns at to_app when one the first of origins would send is missing. With no headers list Punk allows what the preflight asks for.
cors origins => ['https://app.acme.test'];
AUTHOR
LNATION <email@lnation.org>
LICENSE AND COPYRIGHT
This software is Copyright (c) 2026 by LNATION <email@lnation.org>.
This is free software, licensed under:
The Artistic License 2.0 (GPL Compatible)