NAME

Catalyst::Plugin::MCP - Model Context Protocol server plugin for Catalyst

SYNOPSIS

package MyApp;
use Catalyst qw/
    +Catalyst::Plugin::JSONRPC::Server
    +Catalyst::Plugin::MCP
/;
__PACKAGE__->setup;

# in a controller action mounted at your MCP endpoint
sub mcp :Path('/mcp') :Args(0) {
    my ( $self, $c ) = @_;
    $c->mcp_register_provider( $c->model('MCP::Resources') );
    $c->mcp_register_provider( $c->model('MCP::Tools') );
    $c->mcp_dispatch;
}

REQUIRED PLUGINS

This plugin builds on Catalyst::Plugin::JSONRPC::Server and calls its jsonrpc_dispatch_with method. The consuming application must load Catalyst::Plugin::JSONRPC::Server in its plugin list, before Catalyst::Plugin::MCP (as in the SYNOPSIS). Declaring the distribution as a prerequisite installs it but does not load it into the application class: a Catalyst plugin is only mixed into $c when listed in use Catalyst qw/+.../. Omitting it yields a runtime Can't locate object method "jsonrpc_dispatch_with" at the first MCP request.

DESCRIPTION

Adds a Model Context Protocol (revision 2025-06-18) server to a Catalyst application, layered on Catalyst::Plugin::JSONRPC::Server. The protocol engine lives in Catalyst::Plugin::MCP::Server; this module is the thin Catalyst seam.

Each call to mcp_dispatch builds a fresh Catalyst::Plugin::JSONRPC::Server::Dispatcher containing only the handlers registered by the providers in the current request's stash. The shared per-application dispatcher provided by Catalyst::Plugin::JSONRPC::Server is never written to by this plugin, so there is no cross-request verb leakage and no concurrency hazard between simultaneous requests.

METHODS

mcp_register_provider( $obj )

Add a provider object to the current request's provider set. $obj must be blessed and must consume at least one of the provider roles (Catalyst::Plugin::MCP::Role::ResourceProvider, Catalyst::Plugin::MCP::Role::PromptProvider, Catalyst::Plugin::MCP::Role::ToolProvider); one provider per kind. Returns $c, so calls chain.

Providers are validated when mcp_dispatch builds the engine, not here, so a bad provider dies at dispatch. Registration is per-request (it lives in the stash), so each request must register its own providers.

mcp_dispatch( $body )

Run the MCP request: build an engine from the registered providers and config, route the verb, and write the response. Call it last in the action.

$body is optional. Omit it and the request body is read and size-checked for you, which is what you want. Pass a raw (still-encoded) JSON string only if you have already consumed the body yourself. A supplied $body bypasses Catalyst::Plugin::JSONRPC::Server's oversize check, which makes the size limit your problem.

A POST carrying only notifications or responses has no reply to send, and gets HTTP 202 with an empty body as the Streamable HTTP transport requires.

CONFIGURATION

Under the Catalyst::Plugin::MCP key; both are optional and are passed to "new" in Catalyst::Plugin::MCP::Server:

__PACKAGE__->config(
    'Catalyst::Plugin::MCP' => {
        protocol_versions => ['2025-06-18'],
        server_info       => { name => 'myapp', version => '1.0' },
    },
);
protocol_versions

Supported MCP revisions, newest-first. The first is preferred, and is what a client gets when it asks for a version you do not support.

server_info

Advertised at initialize. Defaults to a generic name and this plugin's version, so set it to your own.

SECURITY

This plugin ships no authentication and no Origin validation, and mcp_dispatch does not add any. A tools/call runs your provider's code, so an endpoint mounted as in the SYNOPSIS executes tools for anyone who can POST to it. Guarding it is the application's job, and both of these are on you:

Authenticate the endpoint

The MCP Streamable HTTP transport says servers SHOULD authenticate connections. Put your own authentication (a Catalyst authentication plugin, an auto action, or middleware) in front of mcp_dispatch, and let the request reach it only once it is authorised. Consider also what a provider is allowed to reach: the engine does not scope tools or resources to a user.

Validate the Origin header

The transport says servers MUST validate Origin on incoming connections, to stop a browser on another site from driving your endpoint via DNS rebinding. Check it against an allow-list and reject anything else before dispatching.

Binding to localhost rather than 0.0.0.0 is worth it for a local server, but it is not a substitute for either of the above.

EXAMPLES

A runnable example lives in examples/: a small Catalyst app loading Catalyst::Plugin::JSONRPC::Server then Catalyst::Plugin::MCP, mounting an echo tool and a static resource at /mcp, plus a core-Perl client that replays the initialize / tools/list / tools/call / resources/read handshake. Start it with plackup examples/app.psgi and run perl examples/client.pl. See examples/README.md.

AUTHOR

Mike Whitaker <mike@altrion.org>

Built with tool assistance from Claude Code/(mostly) Opus 4.8 to accelerate code generation and maximise test coverage (and reduce typing :D).

With thanks to

for providing an agentic development framework that keeps code authority firmly where it belongs.

Iteratively reviewed by Finn Kempers <finn@shadow.cat> with analysis from ZCode/GLM-5.2.

LICENSE

This library is free software; you can redistribute it and/or modify it under the terms of the Artistic License, as distributed with Perl.