ViewVC Help
View File | Revision Log | Show Annotations | Download File
/cvs/AnyEvent-MCP/lib/AnyEvent/MCP/Server.pm
Revision: 1.4
Committed: Tue Oct 13 10:17:31 2026 UTC (4 weeks, 1 day ago) by root
Branch: MAIN
Changes since 1.3: +10 -8 lines
Log Message:
*** empty log message ***

File Contents

# User Rev Content
1 root 1.1 =head1 NAME
2    
3     AnyEvent::MCP::Server - MCP request dispatch and JSON-RPC framing
4    
5     =head1 DESCRIPTION
6    
7     An MCP server needs to understand requests, but it does not necessarily need
8     to own a listening socket. This class handles the protocol: it accepts decoded
9     JSON-RPC objects (or UTF-8 JSON), dispatches requests and delivers responses
10     through callbacks. Its subclasses L<AnyEvent::MCP::Server::JSONL> and
11     L<AnyEvent::MCP::Server::HTTP> handle the actual connections. If you are
12     writing a standalone server, L<AnyEvent::MCP::Server::Simple> is usually
13     the easier starting point.
14    
15     The server implements discovery, initialization, ping and tool listing/calls.
16     The 2025 compatibility path supports ordinary requests and responses, but
17     not server-initiated requests. Permission asking supports modern input
18     challenges as well as local helpers. SSE, subscriptions and schema validation
19     are not implemented yet.
20    
21     =head1 METHODS
22    
23     =head2 $server = AnyEvent::MCP::Server->new (%config)
24    
25     C<toolsets> is an array reference of Toolsets searched in order.
26     C<server_info> contains the server name and version; C<instructions> is optional
27     human-readable guidance. C<capabilities> defaults to tool support.
28     C<versions> lists supported protocol revisions, newest first.
29    
30     C<permissions> supplies an L<AnyEvent::MCP::Server::Permissions> object for
31     C<< $req->require_permission >>. Permission-needed exceptions are handled
32     by the server: modern MCP callers receive an input challenge; local or legacy
33     asking retries the handler internally. Checks must precede side effects.
34    
35     C<instructions> is stripped of surrounding whitespace and common indentation.
36 root 1.2 If empty after normalization, the field is omitted from discovery/initialization.
37 root 1.1
38     =head2 $req = $server->rpc ($message, $reply->($message), %context)
39    
40     Accepts the decoded JSON-RPC hash reference C<$message>. C<$reply> receives
41     the decoded response, or undef when a notification is accepted. Returns the
42     Request object for a request, or undef for notifications and rejected messages.
43     C<$reply> can be called before this method returns, or later.
44    
45     C<peer> in C<%context> is a hash reference shared by messages on one connection.
46     It holds legacy initialization state and weak references to outstanding
47     requests for cancellation. Embedders supply a different peer for each
48     independent connection. Modern request metadata takes precedence over legacy
49     peer state.
50    
51     C<protocol_version> supplies the legacy HTTP protocol version.
52     C<on_notify> is an optional C<$callback->($req, $message)> receiving decoded
53     outgoing notifications. Without it, notifications are dropped.
54    
55     =head2 $req = $server->json_rpc ($json, $reply->($json), %context)
56    
57     Like C<rpc>, but C<$json> and the response are UTF-8 encoded JSON strings.
58     The notification callback still receives decoded objects. Parse errors are
59     returned as JSON-RPC errors.
60    
61     =head2 $server->dispatch ($req)
62    
63     Dispatches an already constructed Request. Normally called by C<rpc>.
64     Subclasses can override this method while retaining JSON-RPC handling.
65    
66     =head1 EVENTS
67    
68     =head2 on_request => $callback->($server, $req)
69    
70     Called before each dispatch attempt, including permission retries. This
71     synchronous hook can inspect the request or refresh application state.
72     It does not replace dispatch; avoid side effects that must happen only once.
73    
74     =head2 on_tools_list => $callback->($server, $req)
75    
76     Replaces the default Toolset listing. C<$callback> completes C<$req>.
77    
78     =head2 on_tools_call => $callback->($server, $req, $args)
79    
80     Replaces the default Toolset lookup and invocation. C<$args> is also available
81     as C<< $req->{args} >>. C<$callback> completes C<$req>.
82    
83     =head2 on_error => $callback->($server, $req, $error)
84    
85     Reports an exception caught during synchronous dispatch. A tool exception
86     becomes a text tool-error result; other exceptions become internal JSON-RPC
87     errors. Exceptions after a reply are propagated, as are exceptions from
88     asynchronous application callbacks.
89    
90     =cut
91    
92     package AnyEvent::MCP::Server;
93    
94     use common::sense;
95     use AnyEvent::MCP ();
96     use AnyEvent::MCP::Server::Request ();
97     use JSON::XS ();
98     use Scalar::Util qw(weaken);
99    
100     our @ISA = qw(AnyEvent::MCP::Base);
101    
102     sub new {
103     my $class = shift;
104    
105     my $self = $class->SUPER::new (
106     toolsets => [],
107     server_info => { name => "AnyEvent::MCP", version => $AnyEvent::MCP::VERSION },
108     capabilities => { tools => {} },
109     versions => [qw(2026-07-28 2025-11-25 2025-06-18 2025-03-26)],
110     @_,
111     );
112    
113     $self->{instructions} = AnyEvent::MCP::_clean_text $self->{instructions}
114     if exists $self->{instructions};
115    
116     $self
117     }
118    
119     sub _rpc_error {
120     my ($self, $reply, $id, $code, $message, $data) = @_;
121    
122     my $error = { code => $code, message => $message };
123     $error->{data} = $data if defined $data;
124    
125     $reply->({ jsonrpc => "2.0", id => $id, error => $error });
126     undef
127     }
128    
129     sub json_rpc {
130     my ($self, $json, $reply, @context) = @_;
131    
132     my $send = sub { $reply->(defined $_[0] ? JSON::XS::encode_json $_[0] : undef) };
133     my $message = eval { JSON::XS::decode_json $json };
134    
135     return $self->_rpc_error ($send, undef, -32700, "Parse error")
136     if $@;
137    
138     $self->rpc ($message, $send, @context)
139     }
140    
141     sub rpc {
142     my ($self, $message, $reply, %context) = @_;
143    
144     return $self->_rpc_error ($reply, undef, -32600, "Invalid request")
145     unless ref $message eq "HASH"
146     && $message->{jsonrpc} eq "2.0"
147     && defined $message->{method} && !ref $message->{method}
148     && (!exists $message->{id} || defined $message->{id} && !ref $message->{id});
149    
150     my $id = $message->{id};
151     my $key = $id; # Hash lookups must not stringify the wire ID.
152     my $method = $message->{method};
153     my $params = exists $message->{params} ? $message->{params} : {};
154     my $peer = $context{peer} || {};
155    
156     unless (exists $message->{id}) {
157     if ($method eq "notifications/cancelled" && ref $params eq "HASH" && defined $params->{requestId}) {
158     my $req = delete $peer->{requests}{$params->{requestId}};
159     $req->cancel ($params->{reason}) if $req;
160     }
161    
162     $reply->(undef);
163     return
164     }
165    
166     return $self->_rpc_error ($reply, $id, -32602, "Invalid params")
167     unless ref $params eq "HASH"
168     && (!exists $params->{_meta} || ref $params->{_meta} eq "HASH");
169    
170     my $meta = $params->{_meta} || {};
171     my $version = $meta->{"io.modelcontextprotocol/protocolVersion"}
172     || $context{protocol_version} || $peer->{protocol_version};
173    
174     if ($method eq "initialize") {
175     $version = $params->{protocolVersion};
176    
177     return $self->_rpc_error ($reply, $id, -32602, "Invalid initialize params")
178     unless defined $version && !ref $version
179     && ref $params->{capabilities} eq "HASH"
180     && ref $params->{clientInfo} eq "HASH";
181    
182     unless (grep $_ eq $version && $_ lt "2026", @{$self->{versions}}) {
183     ($version) = grep $_ lt "2026", @{$self->{versions}};
184     return $self->_rpc_error ($reply, $id, -32601, "Legacy initialization is not supported")
185     unless defined $version;
186     }
187     } else {
188     return $self->_rpc_error ($reply, $id, -32602, "Missing protocol version")
189     unless defined $version && !ref $version;
190    
191     return $self->_rpc_error ($reply, $id, -32022, "Unsupported protocol version", {
192     requested => $version,
193     supported => $self->{versions},
194     }) unless grep $_ eq $version, @{$self->{versions}};
195    
196     if ($version ge "2026") {
197     return $self->_rpc_error ($reply, $id, -32602, "Missing per-request metadata")
198     unless $meta->{"io.modelcontextprotocol/protocolVersion"} eq $version
199     && ref $meta->{"io.modelcontextprotocol/clientCapabilities"} eq "HASH";
200     }
201     }
202    
203     return $self->_rpc_error ($reply, $id, -32600, "Request id already outstanding")
204     if $peer->{requests}{$key};
205    
206 root 1.4 # the private multibroker protocol uses the normal MCP capability handshake.
207     # keep the negotiated version on this stream's peer, not on the server.
208 root 1.3 my $capabilities = $method eq "initialize"
209     ? $params->{capabilities} : $meta->{"io.modelcontextprotocol/clientCapabilities"};
210     if (ref $capabilities eq "HASH") {
211     my $extensions = $capabilities->{extensions};
212     if (ref $extensions eq "HASH") {
213     my $extension = $extensions->{"anyevent-mcp/multibroker"};
214     $peer->{aemcp_multibroker} = 1
215     if ref $extension eq "HASH" && ($extension->{version} || 0) eq "1";
216     }
217     }
218    
219 root 1.1 my $req = AnyEvent::MCP::Server::Request->new (
220 root 1.4 server => $self,
221     peer => $peer,
222     id => $id,
223     method => $method,
224     params => $params,
225 root 1.1 protocol_version => $version,
226 root 1.4 on_reply => sub {
227 root 1.1 my ($req, $result) = @_;
228    
229     delete $peer->{requests}{$key};
230    
231     if ($req->{protocol_version} ge "2026") {
232     $result = {
233     resultType => "complete",
234     %$result,
235     _meta => {
236     "io.modelcontextprotocol/serverInfo" => $self->{server_info},
237     %{$result->{_meta} || {}},
238     },
239     };
240     }
241    
242     $reply->({ jsonrpc => "2.0", id => $id, result => $result });
243     },
244     );
245    
246     if (my $notify = $context{on_notify}) {
247     $req->{on_notify} = sub {
248     my ($req, $method, $params) = @_;
249    
250     $notify->($req, { jsonrpc => "2.0", method => $method, params => $params });
251     };
252     }
253    
254     $peer->{requests}{$key} = $req;
255     weaken $peer->{requests}{$key};
256    
257     if ($method eq "tools/call") {
258     unless (defined $params->{name} && !ref $params->{name}
259     && (!exists $params->{arguments} || ref $params->{arguments} eq "HASH")) {
260     $self->_reject ($req, $reply, -32602, "Invalid tool call");
261     return $req
262     }
263    
264     $req->{tool} = $params->{name};
265     $req->{args} = exists $params->{arguments} ? $params->{arguments} : {};
266     }
267    
268     $req->{_reply} = $reply;
269    
270     $self->_dispatch ($req);
271    
272     $req
273     }
274    
275     sub _dispatch {
276     my ($self, $req) = @_;
277    
278     eval {
279     $self->{permissions}->_resume ($req)
280     if $self->{permissions} && $req->{method} eq "tools/call";
281     $self->event (request => $req);
282     $self->dispatch ($req) unless $req->{finished};
283     1
284     } or do {
285     my $error = $@;
286     die $error if $req->{finished};
287    
288     if (ref $error eq "AnyEvent::MCP::Server::Permissions::Required" && $self->{permissions}) {
289     eval {
290     $self->{permissions}->require ($req, $error->{request}, sub {
291     my ($success, $grant) = @_;
292 root 1.4
293 root 1.1 return if $req->{finished};
294     return $req->error ($grant) unless $success;
295    
296     $self->{permissions}->_remember ($req, $error->{request}, $grant);
297     $self->_dispatch ($req);
298     });
299     1
300     } or do {
301     my $failure = $@;
302     die $failure if $req->{finished};
303     $self->event (error => $req, $failure);
304     $req->error ($failure);
305     };
306     } else {
307     $self->event (error => $req, $error);
308    
309     if ($req->{method} eq "tools/call") {
310     $req->error ($error);
311     } else {
312     $self->_reject ($req, $req->{_reply}, -32603, "Internal error");
313     }
314     }
315     };
316     }
317    
318     sub _reject {
319     my ($self, $req, $reply, $code, $message) = @_;
320    
321     $req->{finished} = 1;
322     my $key = $req->{id};
323     delete $req->{peer}{requests}{$key};
324     delete @$req{qw(on_reply on_notify on_cancel _reply)};
325    
326     $self->_rpc_error ($reply, $req->{id}, $code, $message)
327     }
328    
329     sub dispatch {
330     my ($self, $req) = @_;
331 root 1.4
332 root 1.1 my $method = $req->{method};
333    
334     if ($method eq "initialize") {
335     $req->{peer}{protocol_version} = $req->{protocol_version};
336     $req->{peer}{client_info} = $req->{params}{clientInfo};
337     $req->{peer}{client_capabilities} = $req->{params}{capabilities};
338    
339     my $result = {
340     protocolVersion => $req->{protocol_version},
341     serverInfo => $self->{server_info},
342     capabilities => $self->{capabilities},
343     };
344 root 1.2 $result->{instructions} = $self->{instructions} if length $self->{instructions};
345 root 1.1
346     $req->reply ($result);
347    
348     } elsif ($method eq "server/discover") {
349     my $result = {
350     supportedVersions => $self->{versions},
351     capabilities => $self->{capabilities},
352     };
353 root 1.2 $result->{instructions} = $self->{instructions} if length $self->{instructions};
354 root 1.1
355     $req->reply ($result);
356    
357     } elsif ($method eq "ping") {
358     $req->reply ({});
359    
360     } elsif ($method eq "tools/list") {
361     if ($self->{on_tools_list}) {
362     $self->event (tools_list => $req);
363     } else {
364     $req->reply ({ tools => [map @{$_->list}, @{$self->{toolsets}}] });
365     }
366    
367     } elsif ($method eq "tools/call") {
368     if ($self->{on_tools_call}) {
369     $self->event (tools_call => $req, $req->{args});
370     } else {
371     my ($toolset) = grep exists $_->{tool}{$req->{tool}}, @{$self->{toolsets}};
372    
373     if ($toolset) {
374     $toolset->call ($req);
375     } else {
376     $self->_reject ($req, $req->{_reply}, -32602, "Unknown tool '$req->{tool}'");
377     }
378     }
379    
380     } else {
381     $self->_reject ($req, $req->{_reply}, -32601, "Method not found");
382     }
383     }
384    
385     1