ViewVC Help
View File | Revision Log | Show Annotations | Download File
/cvs/AnyEvent-MCP/lib/AnyEvent/MCP/Client.pm
Revision: 1.10
Committed: Tue Oct 27 21:33:37 2026 UTC (25 hours, 16 minutes ago) by root
Branch: MAIN
CVS Tags: HEAD
Changes since 1.9: +3 -1 lines
Log Message:
*** empty log message ***

File Contents

# User Rev Content
1 root 1.1 =head1 NAME
2    
3     AnyEvent::MCP::Client - asynchronous MCP client protocol
4    
5     =head1 SYNOPSIS
6    
7     use AnyEvent::MCP::Client::HTTP;
8    
9     my $client = AnyEvent::MCP::Client::HTTP->new (
10     url => $url, bearer => $token,
11     );
12    
13     $client->call (echo => { text => "hello" }, sub {
14     my ($success, $result) = @_;
15     ...
16     });
17    
18     =head1 DESCRIPTION
19    
20 root 1.2 This is the common protocol implementation used by
21     L<AnyEvent::MCP::Client::HTTP> and L<AnyEvent::MCP::Client::JSONL>. Choose a
22     transport, keep the client object alive, and run an AnyEvent event loop.
23     You can call a tool without connecting explicitly: the client connects and
24     discovers or initializes the server as needed. Concurrent requests share that
25     work instead of starting independent connections.
26    
27     An interrupted connection does not mean the server did nothing. A tool might
28     have completed its side effects just before the connection failed. For that
29     reason, the client never replays a tool call automatically. Applications that
30     want retries must decide whether the operation is safe to repeat.
31 root 1.1
32     Modern clients use discovery and per-request metadata. Legacy clients use
33     initialization and ordinary request/response. Legacy server-initiated requests
34     are accepted on JSONL; legacy HTTP GET streams are not implemented.
35    
36 root 1.8 For a runnable call, see L<AnyEvent::MCP::Client::HTTP/FIRST CALL> when
37     connecting to an HTTP endpoint or
38     L<AnyEvent::MCP::Client::JSONL/FIRST CALL> when launching a subprocess.
39     Both use C<call> from this module. In its callback, C<$success> reports
40     JSON-RPC/transport success; a tool can still return C<isError> true.
41     Keep the client alive while its callbacks are pending.
42    
43 root 1.1 =head1 METHODS
44    
45 root 1.5 =over
46 root 1.1
47 root 1.5 =item $client = AnyEvent::MCP::Client->new (%config)
48 root 1.1
49 root 1.5 Normally construct a transport subclass. Its constructor accepts these
50     common options as well as the transport-specific options:
51    
52     =over
53    
54     =item client_info => { name => $name, version => $version }
55    
56     Advertised client identity. Defaults to C<AnyEvent::MCP> and this
57     distribution's version.
58    
59     =item client_capabilities => { %capabilities }
60    
61     Supported client interactions, defaulting to an empty hash. Advertising a
62     capability does not install a handler; supply C<on_request> when needed.
63    
64     =item versions => [$version...]
65    
66     Supported protocol revisions in preference order. Defaults to
67     C<2026-07-28>, C<2025-11-25>, C<2025-06-18> and C<2025-03-26>.
68     Here, modern means the 2026 discovery/input-challenge protocol; legacy
69     means the 2025 initialization protocol.
70    
71     =item request_timeout => $seconds
72    
73     Optional overall deadline for C<rpc> and C<call>, including connection
74     and user input. Omitted or zero means no overall deadline. This is separate
75     from HTTP's inactivity timeout.
76    
77     =item max_rounds => $count
78    
79     Maximum input-challenge rounds handled by C<call>, defaulting to 32.
80    
81     =item name => $name
82    
83     Optional local configuration name, independent of C<client_info>.
84     HTTP loads C<[client NAME]> defaults only when this is supplied;
85     JSONL does not load transport defaults.
86    
87     =back
88    
89     Event callbacks are also constructor options; see L</EVENTS>.
90    
91     =item $client->connect ($done->($success, $result))
92 root 1.1
93     Discovers or initializes the server. On success C<$result> is undef.
94     Any JSON-RPC error from discovery falls back to legacy initialization.
95     Transport failures do not trigger fallback.
96     After connecting, C<protocol_version>, C<server_info>, C<server_capabilities>
97     and C<instructions> are available directly in the client hash.
98    
99 root 1.5 =item $id = $client->rpc ($method, $params, $done->($success, $result))
100 root 1.1
101     Sends a request. C<$params> is a hash reference and is not modified.
102     C<$id> identifies the local operation for cancellation. Callbacks may run
103     before this method returns.
104    
105     On success C<$result> is the entire decoded result, including C<isError>,
106 root 1.5 C<_meta> and C<resultType>. A result with C<resultType> set to
107     C<input_required> is returned unchanged. C<$success> reports whether the
108     JSON-RPC request succeeded, not whether the tool succeeded: a tool result
109     with C<isError> true still has C<$success> true.
110 root 1.1 On failure C<$result> is an error hash: JSON-RPC errors retain their fields,
111     while local errors have C<message> and C<transport> true. Local cancellation
112     also has C<cancelled> true. Each completion callback runs once.
113    
114 root 1.5 =item $id = $client->call ($name, $args, $done->($success, $result), %params)
115 root 1.1
116     Calls a tool and handles modern C<input_required> results via C<on_request>.
117 root 1.5 C<$args> is a hash reference of tool arguments. C<%params> supplies
118     additional protocol parameters, such as C<_meta>. Set
119     C<< $params{_meta}{"io.modelcontextprotocol/clientCapabilities"} >>
120     to override the constructor capabilities for this call, allowing
121 root 1.1 brokers to advertise the upstream capabilities without changing shared state.
122     The original method and arguments are retained; only server-requested answers
123     and opaque C<requestState> are added for continuation. No connection failure
124     causes a retry. Without C<on_request>, an interaction fails locally.
125    
126     Permission challenges are input requests handled by C<on_request>. To ask
127     locally through the external permission helper:
128    
129     client_capabilities => { elicitation => { form => {} } },
130     on_request => sub {
131     AnyEvent::MCP::Permissions::Ask->elicitation ($_[1]);
132     },
133    
134     Load L<AnyEvent::MCP::Permissions::Ask> before using that callback. It handles
135     permission-tagged elicitation only; applications supporting other elicitation
136     requests dispatch those separately. The client does not silently approve
137     permissions. C<rpc> returns input challenges intact for broker forwarding.
138    
139 root 1.5 =item $client->tools ($done->($success, $tools))
140 root 1.1
141     Collects all pages of tool definitions into an array reference. Names and
142     definitions are preserved. Repeated pagination cursors are rejected.
143    
144 root 1.5 =item $client->notify ($method, $params, $done->($success, $result))
145 root 1.1
146     Sends a notification after connecting. C<$done> is optional. Success means
147     the transport accepted it, not that the server acted on it.
148    
149 root 1.5 =item $client->cancel ($id, $reason)
150 root 1.1
151     Cancels a pending local operation, completes its callback with an error and
152     sends best-effort protocol cancellation for an outstanding wire request.
153     C<$reason> is optional. Late responses are ignored.
154    
155 root 1.5 =item $client->close
156 root 1.1
157     Closes transport resources, cancels input handlers and fails pending callbacks.
158     New operations can reconnect. Previously sent calls are not repeated.
159    
160 root 1.5 =back
161    
162 root 1.1 =head1 EVENTS
163    
164 root 1.5 =over
165    
166     =item on_request => $callback->($client, $req)
167 root 1.1
168     Handles an input request asynchronously. C<$req> is a
169     L<AnyEvent::MCP::Client::Request> with C<method> and C<params>.
170     Declare the corresponding capabilities explicitly in C<client_capabilities>.
171     For a broker, C<rpc> can instead return the complete input challenge upstream.
172    
173 root 1.5 =item on_notification => $callback->($client, $method, $params)
174 root 1.1
175     Receives server notifications, including progress and tool-list changes.
176    
177 root 1.7 =item on_ready => $callback->($client)
178    
179     Called once after a successful MCP discovery or initialization, including
180     reconnections. It does not fire for repeated C<connect> calls on an already
181     connected client.
182    
183 root 1.5 =item on_close => $callback->($client, $error)
184 root 1.3
185     Called when an established connection closes unexpectedly, with a transport
186 root 1.6 error hash. C<pending> counts operations interrupted by the close, before
187     their callbacks were failed. An explicit C<close> without a reason does not
188     trigger this event.
189 root 1.3
190 root 1.5 =back
191    
192 root 1.1 =cut
193    
194     package AnyEvent::MCP::Client;
195    
196     use common::sense;
197     use AnyEvent::MCP ();
198     use AnyEvent ();
199     use AnyEvent::MCP::Client::Request ();
200     use Scalar::Util qw(weaken);
201    
202     our @ISA = qw(AnyEvent::MCP::Base);
203    
204     sub new {
205     my $class = shift;
206    
207     $class->SUPER::new (
208 root 1.9 client_info => { name => "AnyEvent::MCP", version => "$AnyEvent::MCP::VERSION" },
209 root 1.1 client_capabilities => {},
210     versions => [qw(2026-07-28 2025-11-25 2025-06-18 2025-03-26)],
211     @_,
212     )
213     }
214    
215     sub connect {
216     my ($self, $done) = @_;
217    
218     return $done->(1, undef) if $self->{connected};
219     if ($self->{connecting}) {
220     push @{$self->{connecting}}, $done;
221     return
222     }
223    
224     $self->{connecting} = [$done];
225     weaken $self;
226     $self->_open (sub {
227     my ($success, $error) = @_;
228 root 1.4
229 root 1.1 return unless $self;
230     return $self->_connected (0, $error) unless $success;
231    
232     my ($modern) = grep $_ ge "2026", @{$self->{versions}};
233     return $self->_initialize unless $modern;
234    
235     $self->{protocol_version} = $modern;
236     $self->_request ("server/discover", {}, sub {
237     my ($success, $result) = @_;
238 root 1.4
239 root 1.1 return unless $self;
240    
241     return $self->_initialize if !$success && !$result->{transport};
242     return $self->_connected (0, $result) unless $success;
243    
244     return $self->_connected (0, { transport => 1, message => "Invalid discovery result" })
245     unless ref $result->{supportedVersions} eq "ARRAY" && ref $result->{capabilities} eq "HASH";
246    
247     my %supported = map { $_ => 1 } @{$result->{supportedVersions}};
248     my ($version) = grep $supported{$_}, @{$self->{versions}};
249     return $self->_connected (0, { transport => 1, message => "No supported protocol version" })
250     unless defined $version;
251     return $self->_initialize if $version lt "2026";
252    
253     $self->{protocol_version} = $version;
254     $self->{server_info} = $result->{_meta}{"io.modelcontextprotocol/serverInfo"};
255     $self->{server_capabilities} = $result->{capabilities};
256     $self->{instructions} = $result->{instructions};
257     $self->_connected (1, undef);
258     });
259     });
260     }
261    
262     sub _initialize {
263     my ($self) = @_;
264    
265     my ($version) = grep $_ lt "2026", @{$self->{versions}};
266     return $self->_connected (0, { transport => 1, message => "Server requires unsupported legacy initialization" })
267     unless defined $version;
268    
269     $self->{protocol_version} = $version;
270     weaken $self;
271     $self->_request ("initialize", {
272     protocolVersion => $version,
273     clientInfo => $self->{client_info},
274     capabilities => $self->{client_capabilities},
275     }, sub {
276     my ($success, $result) = @_;
277 root 1.4
278 root 1.1 return unless $self;
279     return $self->_connected (0, $result) unless $success;
280    
281     my $version = $result->{protocolVersion};
282     return $self->_connected (0, { transport => 1, message => "Unsupported negotiated protocol version" })
283     unless grep $_ eq $version && $_ lt "2026", @{$self->{versions}};
284    
285     $self->{protocol_version} = $version;
286     $self->{server_info} = $result->{serverInfo};
287     $self->{server_capabilities} = $result->{capabilities};
288     $self->{instructions} = $result->{instructions};
289     $self->_send ({ jsonrpc => "2.0", method => "notifications/initialized" },
290     sub { $self->_connected (@_) if $self });
291     });
292     }
293    
294     sub _connected {
295     my ($self, $success, $error) = @_;
296    
297     my $waiters = delete $self->{connecting} or return;
298     if ($success) {
299     $self->{connected} = 1;
300 root 1.7 $self->event ("ready");
301 root 1.1 } else {
302     $self->_shutdown;
303     delete @$self{qw(connected protocol_version server_info server_capabilities instructions)};
304     }
305     $_->($success, $error) for @$waiters;
306     }
307    
308     sub _request {
309     my ($self, $method, $params, $done, $operation) = @_;
310 root 1.4
311 root 1.1 my $id = ++$self->{wire_id};
312     my %params = %$params;
313    
314     if ($self->{protocol_version} ge "2026") {
315     $params{_meta} = {
316     %{$params{_meta} || {}},
317     "io.modelcontextprotocol/protocolVersion" => $self->{protocol_version},
318     "io.modelcontextprotocol/clientInfo" => $self->{client_info},
319     "io.modelcontextprotocol/clientCapabilities" => exists $params{_meta}{"io.modelcontextprotocol/clientCapabilities"}
320     ? $params{_meta}{"io.modelcontextprotocol/clientCapabilities"} : $self->{client_capabilities},
321     };
322     }
323    
324     $self->{pending}{$id} = $done;
325     $operation->{wire_id} = $id if $operation;
326     my $generation = $self->{generation};
327     weaken $self;
328     $self->_send ({ jsonrpc => "2.0", id => $id, method => $method, params => \%params }, sub {
329     my ($success, $error) = @_;
330 root 1.4
331 root 1.1 return if !$self || $self->{generation} != $generation || $success;
332     my $done = delete $self->{pending}{$id} or return;
333     $done->(0, $error);
334     });
335     $id
336     }
337    
338     sub _operation {
339     my ($self, $method, $params, $done, $interactions) = @_;
340 root 1.4
341 root 1.1 my $id = ++$self->{operation_id};
342     my $op = $self->{operations}{$id} = {
343 root 1.4 method => $method,
344     params => $params,
345     done => $done,
346     interactions => $interactions,
347 root 1.1 };
348    
349     weaken $self;
350     if ($self->{request_timeout}) {
351     $op->{timer} = AnyEvent->timer (after => $self->{request_timeout}, cb => sub {
352     $self->cancel ($id, "Request timed out") if $self;
353     });
354     }
355    
356     $self->connect (sub {
357     my ($success, $error) = @_;
358 root 1.4
359 root 1.1 return unless $self && $self->{operations}{$id};
360     return $self->_finish ($id, 0, $error) unless $success;
361     $self->_continue ($id, $params);
362     });
363     $id
364     }
365    
366     sub rpc {
367     my ($self, $method, $params, $done) = @_;
368    
369     $self->_operation ($method, $params, $done, 0)
370     }
371    
372     sub call {
373     my ($self, $name, $args, $done, %params) = @_;
374    
375     $self->_operation ("tools/call", { %params, name => $name, arguments => $args }, $done, 1)
376     }
377    
378     sub _continue {
379     my ($self, $id, $params) = @_;
380 root 1.4
381 root 1.1 my $op = $self->{operations}{$id} or return;
382    
383     weaken $self;
384     $self->_request ($op->{method}, $params, sub {
385     my ($success, $result) = @_;
386 root 1.4
387 root 1.1 return unless $self && $self->{operations}{$id};
388     delete $op->{wire_id};
389    
390     if ($success && $op->{interactions} && $result->{resultType} eq "input_required") {
391     return $self->_input ($id, $result)
392     }
393     $self->_finish ($id, $success, $result);
394     }, $op);
395     }
396    
397     sub _input {
398     my ($self, $id, $result) = @_;
399 root 1.4
400 root 1.1 my $op = $self->{operations}{$id} or return;
401     my $input = $result->{inputRequests};
402    
403     return $self->_finish ($id, 0, { transport => 1, message => "Input required without a client handler" })
404     unless $self->{on_request} && ref $input eq "HASH" && keys %$input;
405     return $self->_finish ($id, 0, { transport => 1, message => "Too many input rounds" })
406     if ++$op->{rounds} > ($self->{max_rounds} || 32);
407    
408     weaken $self;
409     my %responses;
410     my $remaining = keys %$input;
411     $op->{input} = [];
412     for my $key (sort keys %$input) {
413     last unless $self->{operations}{$id};
414     my $request = $input->{$key};
415 root 1.10 unless (ref $request eq "HASH"
416     && defined $request->{method}
417     && ref ($request->{params} || {}) eq "HASH") {
418 root 1.1 return $self->_finish ($id, 0, { transport => 1, message => "Invalid input request" })
419     }
420    
421     my $req = AnyEvent::MCP::Client::Request->new (
422 root 1.4 client => $self,
423     id => $key,
424     method => $request->{method},
425     params => $request->{params} || {},
426 root 1.1 protocol_version => $self->{protocol_version},
427 root 1.4 operation => $id,
428     on_reply => sub {
429 root 1.1 my ($success, $response) = @_;
430 root 1.4
431 root 1.1 return unless $self->{operations}{$id};
432     return $self->_finish ($id, 0, $response) unless $success;
433     $responses{$key} = $response;
434     return if --$remaining;
435    
436     delete $op->{input};
437     my %params = (%{$op->{params}}, inputResponses => \%responses);
438     $params{requestState} = $result->{requestState} if exists $result->{requestState};
439     $self->_continue ($id, \%params);
440     },
441     );
442     push @{$op->{input}}, $req;
443     $self->event (request => $req);
444     }
445     }
446    
447     sub _finish {
448     my ($self, $id, $success, $result) = @_;
449    
450     my $op = delete $self->{operations}{$id} or return;
451     delete $self->{pending}{$op->{wire_id}} if exists $op->{wire_id};
452     $_->cancel for @{$op->{input} || []};
453     delete $op->{timer};
454     $op->{done}->($success, $result);
455     }
456    
457     sub cancel {
458     my ($self, $id, $reason) = @_;
459 root 1.4
460 root 1.1 my $op = $self->{operations}{$id} or return;
461     my $wire = $op->{wire_id};
462     $reason = "Request cancelled" unless defined $reason;
463    
464     $self->_finish ($id, 0, { transport => 1, cancelled => 1, message => $reason });
465     if (defined $wire && $self->{connected}) {
466     $self->_send ({
467     jsonrpc => "2.0", method => "notifications/cancelled",
468 root 1.4 params => { requestId => $wire, reason => $reason },
469 root 1.1 }, sub {});
470     $self->_abort ($wire);
471     }
472     }
473    
474     sub tools {
475     my ($self, $done) = @_;
476 root 1.4
477 root 1.1 my (@tools, %seen);
478     weaken $self;
479     my $page;
480     $page = sub {
481     my ($cursor) = @_;
482 root 1.4
483 root 1.1 $self->rpc ("tools/list", defined $cursor ? { cursor => $cursor } : {}, sub {
484     my ($success, $result) = @_;
485 root 1.4
486 root 1.1 unless ($success && ref $result->{tools} eq "ARRAY") {
487     undef $page;
488     return $done->(0, $success ? { transport => 1, message => "Invalid tools/list result" } : $result)
489     }
490    
491     push @tools, @{$result->{tools}};
492     if (defined $result->{nextCursor} && length $result->{nextCursor}) {
493     if ($seen{$result->{nextCursor}}++) {
494     undef $page;
495     return $done->(0, { transport => 1, message => "Repeated tools/list cursor" })
496     }
497     return $page->($result->{nextCursor})
498     }
499    
500     undef $page;
501     $done->(1, \@tools);
502     });
503     };
504     $page->();
505     }
506    
507     sub notify {
508     my ($self, $method, $params, $done) = @_;
509 root 1.4
510 root 1.1 $done ||= sub {};
511     weaken $self;
512    
513     $self->connect (sub {
514     my ($success, $error) = @_;
515 root 1.4
516 root 1.1 return unless $self;
517     return $done->(0, $error) unless $success;
518     $self->_send ({ jsonrpc => "2.0", method => $method, params => $params }, $done);
519     });
520     }
521    
522     sub _receive {
523     my ($self, $message) = @_;
524    
525     die "Invalid JSON-RPC message\n"
526     unless ref $message eq "HASH" && $message->{jsonrpc} eq "2.0";
527    
528     if (exists $message->{method}) {
529     die "Invalid server method\n" if ref $message->{method};
530     if (!exists $message->{id}) {
531     $self->event (notification => $message->{method}, $message->{params} || {});
532     return
533     }
534    
535     die "Server-initiated request on modern connection\n" if $self->{protocol_version} ge "2026";
536     weaken $self;
537     my $req = AnyEvent::MCP::Client::Request->new (
538 root 1.4 client => $self,
539     id => $message->{id},
540     method => $message->{method},
541     params => $message->{params} || {},
542 root 1.1 on_reply => sub {
543     my ($success, $result) = @_;
544 root 1.4
545 root 1.1 return unless $self;
546     delete $self->{incoming}{$message->{id}};
547     $self->_send ({
548     jsonrpc => "2.0", id => $message->{id},
549     ($success ? "result" : "error") => $result,
550     }, sub {});
551     },
552     );
553     $self->{incoming}{$req->{id}} = $req;
554     if ($req->{method} eq "ping") {
555     $req->reply ({});
556     } elsif ($self->{on_request}) {
557     $self->event (request => $req);
558     } else {
559     $req->error (-32601, "Method not supported");
560     }
561     return
562     }
563    
564 root 1.4 my $field = exists $message->{result} ? "result" : "error";
565    
566     defined $message->{id} && !ref $message->{id}
567     && (exists $message->{result}) != (exists $message->{error})
568     && ref $message->{$field} eq "HASH"
569     or die "Invalid JSON-RPC response\n";
570 root 1.1
571     my $done = delete $self->{pending}{$message->{id}} or return;
572 root 1.4 $done->(exists $message->{result}, $message->{$field});
573 root 1.1 }
574    
575     sub _open {
576     $_[1]->(1, undef)
577     }
578    
579     sub _abort {
580     }
581    
582     sub close {
583     my ($self, $message) = @_;
584 root 1.4
585 root 1.3 my $was_connected = $self->{connected};
586 root 1.6 # The close event runs after outstanding operations have been failed.
587     my $outstanding = scalar keys %{$self->{operations} || {}};
588 root 1.1 ++$self->{generation};
589     $self->_shutdown;
590     delete @$self{qw(connected protocol_version server_info server_capabilities instructions)};
591    
592 root 1.6 my $error = { transport => 1, pending => $outstanding,
593     message => defined $message ? $message : "Client closed" };
594 root 1.1 my $connecting = delete $self->{connecting} || [];
595     my $pending = delete $self->{pending} || {};
596     my $incoming = delete $self->{incoming} || {};
597     $_->cancel for values %$incoming;
598     $self->_finish ($_, 0, $error) for keys %{$self->{operations} || {}};
599     $_->(0, $error) for values %$pending;
600     $_->(0, $error) for @$connecting;
601 root 1.3 $self->event (close => $error) if $was_connected && defined $message;
602 root 1.1 }
603    
604     sub DESTROY {
605     $_[0]->close
606     }
607    
608     1