MCP
view release on metacpan or search on metacpan
0.10 2026-05-06
- Added opt-in server-to-client streaming and session termination to the HTTP transport. Not compatible with
pre-forking web servers.
- Added support for list_changed notifications.
- Added support for progress notifications.
- Added MCP::Primitive class.
- Added MCP::Server::Context class.
- Added MCP::Server::Session class.
- Added heartbeat, session_timeout, sessions, and streaming attributes, and a notify method, to
MCP::Server::Transport::HTTP.
- Added notify method to MCP::Server::Transport::Stdio.
- Added notifications method to MCP::Server::Transport.
- Added notify_all method to MCP::Server::Transport::HTTP and MCP::Server::Transport::Stdio.
- Added notify_list_changed method to MCP::Server.
- Added delete_session method to MCP::Client.
0.08 2026-02-17
- Added support for tool annotations. (d3flex)
- Added get_prompt and list_prompts methods to MCP::Client.
- Added prompt method to MCP::Server.
0.04 2025-08-04
- Added support for structured content.
- Added output_schema attribute to MCP::Tool.
- Added structured_result method to MCP::Tool.
0.03 2025-08-01
- Added image_result method to MCP::Tool.
- Improved streaming HTTP transport to use SSE for async responses.
0.02 2025-08-01
- Fixed support for tool calls without arguments.
0.01 2025-08-01
- First release.
.perltidyrc
Changes
examples/echo_http.pl
examples/echo_stdio.pl
examples/streaming_http.pl
lib/MCP.pm
lib/MCP/Client.pm
lib/MCP/Constants.pm
lib/MCP/Primitive.pm
lib/MCP/Prompt.pm
lib/MCP/Resource.pm
lib/MCP/Server.pm
lib/MCP/Server/Context.pm
lib/MCP/Server/Session.pm
lib/MCP/Server/Transport.pm
t/apps/empty.wav
t/apps/lite_app.pl
t/apps/mojolicious.png
t/apps/stdio.pl
t/lib/MCPStdioTest.pm
t/lite_app.t
t/pod.t
t/pod_coverage.t
t/session_specific_app.t
t/stdio.t
t/streaming.t
META.yml Module YAML meta-data (added by MakeMaker)
META.json Module JSON meta-data (added by MakeMaker)
$server->tool(
name => 'echo',
description => 'Echo the input text',
input_schema => {type => 'object', properties => {msg => {type => 'string'}}, required => ['msg']},
code => sub ($tool, $args) {
$tool->context->notify('notifications/message', {level => 'info', data => "Echoing: $args->{msg}"});
return "Echo: $args->{msg}";
}
);
any '/mcp' => $server->to_action({streaming => 1});
app->start;
```
## Stdio Transport
Build local command line applications and use the stdio transport for testing with the `to_stdio` method.
```perl
use Mojo::Base -strict, -signatures;
examples/streaming_http.pl view on Meta::CPAN
$context->notify_progress($done, $total, "Processed item $done of $total");
return if $done < $total;
Mojo::IOLoop->remove($id);
$promise->resolve("Processed $total items");
}
);
return $promise;
}
);
any '/mcp' => $server->to_action({streaming => 1});
app->start;
code => sub ($tool, $args) {
return "Echo: $args->{msg}";
}
);
any '/mcp' => $server->to_action;
app->start;
Authentication can be added by the web application, just like for any other route. To allow for MCP applications to
scale with prefork web servers, server to client streaming is currentlly avoided when possible.
=head3 Stdio Transport
Build local command line applications and use the stdio transport for testing with the L<MCP::Server/"to_stdio">
method.
use Mojo::Base -strict, -signatures;
use MCP::Server;
lib/MCP/Client.pm view on Meta::CPAN
my $result = $client->call_tool('tool_name', {arg1 => 'value1'});
Calls a tool on the MCP server with the specified name and arguments, returning the result.
=head2 delete_session
my $bool = $client->delete_session;
Send a C<DELETE> request to terminate the current session on the MCP server, and clear the local
L</"session_id">. Returns true on success, or C<undef> if no session is active. The server only honors this when it
was configured with C<< streaming => 1 >>.
=head2 get_prompt
my $result = $client->get_prompt('prompt_name');
my $result = $client->get_prompt('prompt_name', {arg1 => 'value1'});
Get a prompt from the MCP server with the specified name and arguments, returning the result.
=head2 initialize_session
lib/MCP/Server.pm view on Meta::CPAN
description => 'A sample resource',
mime_type => 'text/plain',
code => sub ($resource) { ... }
);
Register a new resource with the server.
=head2 to_action
my $action = $server->to_action;
my $action = $server->to_action({streaming => 1});
Convert the server to a L<Mojolicious> action. Any options are passed through to the constructor of
L<MCP::Server::Transport::HTTP>; in particular, C<< streaming => 1 >> opts in to the server-to-client SSE stream
and explicit session termination.
=head2 to_stdio
$server->to_stdio;
Handles JSON-RPC requests over standard input/output.
=head2 tool
lib/MCP/Server/Transport/HTTP.pm view on Meta::CPAN
use Mojo::IOLoop;
use Mojo::JSON qw(to_json true);
use Mojo::Util qw(dumper);
use Scalar::Util qw(blessed weaken);
use constant DEBUG => $ENV{MCP_DEBUG} || 0;
has heartbeat => 30;
has session_timeout => 3600;
has sessions => sub { {} };
has streaming => 0;
sub notifications ($self) { $self->streaming ? 1 : 0 }
sub handle_request ($self, $c) {
my $method = $c->req->method;
return $self->_handle_post($c) if $method eq 'POST';
return $self->_handle_get($c) if $method eq 'GET' && $self->streaming;
return $self->_handle_delete($c) if $method eq 'DELETE' && $self->streaming;
return $c->render(json => {error => 'Method not allowed'}, status => 405);
}
sub notify ($self, $session_id, $method, $params = {}) {
return undef unless my $session = $self->sessions->{$session_id};
return undef unless my $stream = $session->stream;
$stream->write_sse({text => to_json({jsonrpc => '2.0', method => $method, params => $params})});
return 1;
}
sub notify_all ($self, $method, $params = {}) {
return undef unless $self->streaming;
my $payload = {text => to_json({jsonrpc => '2.0', method => $method, params => $params})};
for my $session (values %{$self->sessions}) {
next unless my $stream = $session->stream;
$stream->write_sse($payload);
}
return 1;
}
sub _extract_session_id ($self, $c) { return $c->req->headers->header('Mcp-Session-Id') }
lib/MCP/Server/Transport/HTTP.pm view on Meta::CPAN
return unless my $session = $self_weak->sessions->{$session_id};
return unless ($session->stream // 0) == $c;
$session->stream(undef)->touch;
}
);
}
sub _handle_initialization ($self, $c, $data) {
my $session_id = random_v4uuid;
my $result = $self->_handle($data, MCP::Server::Context->new);
if ($self->streaming) {
$self->sessions->{$session_id} = MCP::Server::Session->new(id => $session_id);
$self->_start_sweep;
}
$c->res->headers->header('Mcp-Session-Id' => $session_id);
$c->render(json => $result, status => 200);
}
sub _handle_post ($self, $c) {
my $session_id = $self->_extract_session_id($c);
return $c->render(json => {error => 'Invalid JSON'}, status => 400) unless my $data = $c->req->json;
return $c->render(json => {error => 'Invalid JSON', status => 400}) unless ref $data eq 'HASH';
if ($data->{method} && $data->{method} eq 'initialize') { $self->_handle_initialization($c, $data) }
else { $self->_handle_regular_request($c, $data, $session_id) }
}
sub _handle_regular_request ($self, $c, $data, $session_id) {
return $c->render(json => {error => 'Missing session ID'}, status => 400) unless $session_id;
if ($self->streaming) {
return $c->render(json => {error => 'Session not found'}, status => 404)
unless my $session = $self->sessions->{$session_id};
$session->touch;
}
$c->res->headers->header('Mcp-Session-Id' => $session_id);
my $context = MCP::Server::Context->new(transport => $self, session_id => $session_id, controller => $c);
return $c->render(data => '', status => 202) unless defined(my $result = $self->_handle($data, $context));
# Sync
lib/MCP/Server/Transport/HTTP.pm view on Meta::CPAN
use MCP::Server::Transport::HTTP;
my $http = MCP::Server::Transport::HTTP->new;
=head1 DESCRIPTION
L<MCP::Server::Transport::HTTP> is a transport for MCP (Model Context Protocol) server that uses HTTP as the
underlying transport mechanism.
By default only C<POST> requests are handled. When L</"streaming"> is enabled, the transport additionally supports
the server-to-client SSE stream (C<GET>) and explicit session termination (C<DELETE>) defined by the Streamable
HTTP transport. Note that this requires per-process state and is therefore not compatible with pre-forking web
servers.
=head1 ATTRIBUTES
L<MCP::Server::Transport::HTTP> inherits all attributes from L<MCP::Server::Transport> and implements the following
new ones.
=head2 heartbeat
my $seconds = $http->heartbeat;
$http = $http->heartbeat(30);
Interval in seconds at which a keep-alive comment is sent on each open server-to-client stream. Defaults to C<30>;
set to C<0> to disable. Useful when running behind reverse proxies that close idle connections. Only used when
L</"streaming"> is enabled.
=head2 session_timeout
my $seconds = $http->session_timeout;
$http = $http->session_timeout(3600);
Idle timeout in seconds for sessions without an open server-to-client stream. Defaults to C<3600>; set to C<0> to
disable. A periodic sweep removes sessions whose last activity is older than this value, so the effective lifetime
of an idle session is up to twice the configured timeout. Only used when L</"streaming"> is enabled.
=head2 sessions
my $sessions = $http->sessions;
$http = $http->sessions({});
Per-process registry of active L<MCP::Server::Session> objects, keyed by session ID. Only used when L</"streaming">
is enabled.
=head2 streaming
my $bool = $http->streaming;
$http = $http->streaming(1);
Enable server-to-client streaming and session lifecycle management. Defaults to false. When enabled, the transport
tracks all sessions in L</"sessions">, accepts C<GET> requests to open a long-lived SSE stream the server can push
notifications to, and accepts C<DELETE> requests to terminate a session. Requests for unknown sessions are rejected
with status C<404>.
=head1 METHODS
L<MCP::Server::Transport::HTTP> inherits all methods from L<MCP::Server::Transport> and implements the following new
ones.
=head2 handle_request
$http->handle_request(Mojolicious::Controller->new);
Handles an incoming HTTP request.
=head2 notifications
my $bool = $http->notifications;
True when L</"streaming"> is enabled, false otherwise.
=head2 notify
my $bool = $http->notify($session_id, $method);
my $bool = $http->notify($session_id, $method, {foo => 'bar'});
Send a JSON-RPC notification to the open SSE stream of a session. Returns true on success, or C<undef> if the
session does not exist or has no open stream. Only available when L</"streaming"> is enabled.
=head2 notify_all
my $bool = $http->notify_all($method);
my $bool = $http->notify_all($method, {foo => 'bar'});
Send a JSON-RPC notification to the open SSE stream of every active session. Returns true on success, or C<undef>
when L</"streaming"> is disabled.
=head1 SEE ALSO
L<MCP>, L<https://mojolicious.org>, L<https://modelcontextprotocol.io>.
=cut
t/lite_app.t view on Meta::CPAN
use MCP::Client;
use MCP::Constants qw(PROTOCOL_VERSION);
use MCP::Server;
my $t = Test::Mojo->new(curfile->sibling('apps', 'lite_app.pl'));
subtest 'Normal HTTP endpoint' => sub {
$t->get_ok('/')->status_is(200)->content_like(qr/Hello MCP!/);
};
subtest 'List changed without streaming' => sub {
my $server = MCP::Server->new;
$server->to_action;
is $server->notify_list_changed('tools'), undef, 'no broadcast without streaming';
};
subtest 'MCP endpoint' => sub {
$t->get_ok('/mcp')->status_is(405)->content_like(qr/Method not allowed/);
$t->delete_ok('/mcp')->status_is(405)->content_like(qr/Method not allowed/);
my $client = MCP::Client->new(ua => $t->ua, url => $t->ua->server->url->path('/mcp'));
subtest 'Initialize session' => sub {
is $client->session_id, undef, 'no session id';
t/streaming.t view on Meta::CPAN
Mojo::IOLoop->timer(
0.1 => sub {
$context->notify_progress(1, 2, 'late');
$promise->resolve('done');
}
);
return $promise;
}
);
any '/mcp' => $server->to_action({streaming => 1, heartbeat => 0, session_timeout => 0.5});
my $t = Test::Mojo->new;
subtest 'No session' => sub {
$t->get_ok('/mcp')->status_is(400)->json_is('/error' => 'Missing session ID');
$t->delete_ok('/mcp')->status_is(400)->json_is('/error' => 'Missing session ID');
};
subtest 'Unknown session' => sub {
$t->get_ok('/mcp' => {'Mcp-Session-Id' => 'nope'})->status_is(404);
t/streaming.t view on Meta::CPAN
my $open = MCP::Client->new(ua => $t->ua, url => $t->ua->server->url->path('/mcp'));
$open->initialize_session;
my $open_id = $open->session_id;
my $url = $t->ua->server->url->path('/mcp');
my $tx = $t->ua->build_tx(GET => $url => {Accept => 'text/event-stream', 'Mcp-Session-Id' => $open_id});
$t->ua->start_p($tx)->catch(sub { });
Mojo::IOLoop->one_tick until $tx->res->code || $tx->error;
ok exists $sessions->{$idle_id}, 'idle session registered';
ok exists $sessions->{$open_id}, 'streaming session registered';
my $tick = Mojo::Promise->new;
Mojo::IOLoop->timer(1.5 => sub { $tick->resolve });
$tick->wait;
ok !exists $sessions->{$idle_id}, 'idle session swept';
ok exists $sessions->{$open_id}, 'streaming session survives sweep';
$open->delete_session;
eval { $idle->ping };
like $@, qr/404 response/, 'POST for swept session is rejected';
};
done_testing;
( run in 0.962 second using v1.01-cache-2.11-cpan-5735350b133 )