MCP

 view release on metacpan or  search on metacpan

Changes  view on Meta::CPAN


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)

Changes  view on Meta::CPAN

  - 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.

MANIFEST  view on Meta::CPAN

.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

MANIFEST  view on Meta::CPAN

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)

README.md  view on Meta::CPAN

$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;

lib/MCP.pm  view on Meta::CPAN

    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 )