MCP

 view release on metacpan or  search on metacpan

Changes  view on Meta::CPAN

0.11  2026-06-19
  - Added OAuth scope support, so MCP servers can act as OAuth 2.0 resource servers.
  - Added scopes attribute to MCP::Primitive, and therefore to MCP::Tool, MCP::Prompt, and MCP::Resource.
  - Added scopes and insufficient_scope attributes, and a has_scope method, to MCP::Server::Context.
  - Added auth and metadata_url attributes to MCP::Server::Transport::HTTP.
  - Added headers attribute to MCP::Client.
  - Added oauth_metadata method to MCP::Server.
  - Added INSUFFICIENT_SCOPE constant to MCP::Constants.

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/lite_app.pl
t/apps/mojolicious.png
t/apps/stdio.pl
t/auth_scopes_app.t
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
t/tool_validation.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

      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. OAuth scopes can be enforced per
tool, prompt and resource. 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::Util   qw(dumper);
use Scalar::Util qw(blessed weaken);

use constant DEBUG => $ENV{MCP_DEBUG} || 0;

has 'auth';
has heartbeat => 30;
has 'metadata_url';
has session_timeout => 3600;
has sessions        => sub { {} };
has streaming       => 0;

sub notifications ($self) { $self->streaming ? 1 : 0 }

sub handle_request ($self, $c) {
  if (my $auth = $self->auth) {
    return $self->_unauthorized($c) unless my $info = $auth->($c);
    $c->stash('mcp.auth' => $info);
  }

  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 _challenge_header ($self, %extra) {
  my @parts;

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(scopes => $self->_scopes($c)));
  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,

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 auth

lib/MCP/Server/Transport/HTTP.pm  view on Meta::CPAN

as L<MCP::Server::Context/"scopes">. Token validation is left to the application, so this is where you verify an
OAuth 2.0 access token; when not set, requests are not authenticated.

=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 metadata_url

  my $url = $http->metadata_url;
  $http   = $http->metadata_url('https://example.com/.well-known/oauth-protected-resource');

URL of the OAuth 2.0 Protected Resource Metadata document. When set, it is included as the C<resource_metadata>
parameter of the C<WWW-Authenticate> challenge sent with C<401> and C<403> responses, so clients can discover the
authorization server. Use an absolute URL so remote clients can fetch it. See L<MCP::Server/"oauth_metadata">.

=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 1.954 second using v1.01-cache-2.11-cpan-df04353d9ac )