ViewVC Help
View File | Revision Log | Show Annotations | Download File
/cvs/AnyEvent-MCP/lib/AnyEvent/MCP/Client.pm
Revision: 1.4
Committed: Tue Oct 13 10:17:31 2026 UTC (3 weeks, 6 days ago) by root
Branch: MAIN
Changes since 1.3: +41 -11 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     =head1 METHODS
37    
38     =head2 $client = AnyEvent::MCP::Client->new (%config)
39    
40     Normally construct a transport subclass. C<client_info> is the advertised
41     client identity; C<client_capabilities> declares supported interactions.
42     C<versions> lists supported revisions in preference order. C<request_timeout>
43     is an optional overall deadline in seconds, including connection and user input.
44     C<max_rounds> bounds tool-call input continuations, defaulting to 32.
45    
46     C<name> is an optional local configuration name. HTTP loads defaults from
47     C<[client NAME]> only when it is supplied. It is independent of the advertised
48     C<client_info>. Unnamed clients do not access global configuration.
49    
50     =head2 $client->connect ($done->($success, $result))
51    
52     Discovers or initializes the server. On success C<$result> is undef.
53     Any JSON-RPC error from discovery falls back to legacy initialization.
54     Transport failures do not trigger fallback.
55     After connecting, C<protocol_version>, C<server_info>, C<server_capabilities>
56     and C<instructions> are available directly in the client hash.
57    
58     =head2 $id = $client->rpc ($method, $params, $done->($success, $result))
59    
60     Sends a request. C<$params> is a hash reference and is not modified.
61     C<$id> identifies the local operation for cancellation. Callbacks may run
62     before this method returns.
63    
64     On success C<$result> is the entire decoded result, including C<isError>,
65     C<_meta> and C<resultType>. An C<input_required> result is returned unchanged.
66     On failure C<$result> is an error hash: JSON-RPC errors retain their fields,
67     while local errors have C<message> and C<transport> true. Local cancellation
68     also has C<cancelled> true. Each completion callback runs once.
69    
70     =head2 $id = $client->call ($name, $args, $done->($success, $result), %params)
71    
72     Calls a tool and handles modern C<input_required> results via C<on_request>.
73     C<%params> supplies additional protocol parameters, such as C<_meta>. Explicit
74     per-request client capabilities override the constructor capabilities, allowing
75     brokers to advertise the upstream capabilities without changing shared state.
76     The original method and arguments are retained; only server-requested answers
77     and opaque C<requestState> are added for continuation. No connection failure
78     causes a retry. Without C<on_request>, an interaction fails locally.
79    
80     Permission challenges are input requests handled by C<on_request>. To ask
81     locally through the external permission helper:
82    
83     client_capabilities => { elicitation => { form => {} } },
84     on_request => sub {
85     AnyEvent::MCP::Permissions::Ask->elicitation ($_[1]);
86     },
87    
88     Load L<AnyEvent::MCP::Permissions::Ask> before using that callback. It handles
89     permission-tagged elicitation only; applications supporting other elicitation
90     requests dispatch those separately. The client does not silently approve
91     permissions. C<rpc> returns input challenges intact for broker forwarding.
92    
93     =head2 $client->tools ($done->($success, $tools))
94    
95     Collects all pages of tool definitions into an array reference. Names and
96     definitions are preserved. Repeated pagination cursors are rejected.
97    
98     =head2 $client->notify ($method, $params, $done->($success, $result))
99    
100     Sends a notification after connecting. C<$done> is optional. Success means
101     the transport accepted it, not that the server acted on it.
102    
103     =head2 $client->cancel ($id, $reason)
104    
105     Cancels a pending local operation, completes its callback with an error and
106     sends best-effort protocol cancellation for an outstanding wire request.
107     C<$reason> is optional. Late responses are ignored.
108    
109     =head2 $client->close
110    
111     Closes transport resources, cancels input handlers and fails pending callbacks.
112     New operations can reconnect. Previously sent calls are not repeated.
113    
114     =head1 EVENTS
115    
116     =head2 on_request => $callback->($client, $req)
117    
118     Handles an input request asynchronously. C<$req> is a
119     L<AnyEvent::MCP::Client::Request> with C<method> and C<params>.
120     Declare the corresponding capabilities explicitly in C<client_capabilities>.
121     For a broker, C<rpc> can instead return the complete input challenge upstream.
122    
123     =head2 on_notification => $callback->($client, $method, $params)
124    
125     Receives server notifications, including progress and tool-list changes.
126    
127 root 1.3 =head2 on_close => $callback->($client, $error)
128    
129     Called when an established connection closes unexpectedly, with a transport
130     error hash. An explicit C<close> without a reason does not trigger this event.
131    
132 root 1.1 =cut
133    
134     package AnyEvent::MCP::Client;
135    
136     use common::sense;
137     use AnyEvent::MCP ();
138     use AnyEvent ();
139     use AnyEvent::MCP::Client::Request ();
140     use Scalar::Util qw(weaken);
141    
142     our @ISA = qw(AnyEvent::MCP::Base);
143    
144     sub new {
145     my $class = shift;
146    
147     $class->SUPER::new (
148     client_info => { name => "AnyEvent::MCP", version => $AnyEvent::MCP::VERSION },
149     client_capabilities => {},
150     versions => [qw(2026-07-28 2025-11-25 2025-06-18 2025-03-26)],
151     @_,
152     )
153     }
154    
155     sub connect {
156     my ($self, $done) = @_;
157    
158     return $done->(1, undef) if $self->{connected};
159     if ($self->{connecting}) {
160     push @{$self->{connecting}}, $done;
161     return
162     }
163    
164     $self->{connecting} = [$done];
165     weaken $self;
166     $self->_open (sub {
167     my ($success, $error) = @_;
168 root 1.4
169 root 1.1 return unless $self;
170     return $self->_connected (0, $error) unless $success;
171    
172     my ($modern) = grep $_ ge "2026", @{$self->{versions}};
173     return $self->_initialize unless $modern;
174    
175     $self->{protocol_version} = $modern;
176     $self->_request ("server/discover", {}, sub {
177     my ($success, $result) = @_;
178 root 1.4
179 root 1.1 return unless $self;
180    
181     return $self->_initialize if !$success && !$result->{transport};
182     return $self->_connected (0, $result) unless $success;
183    
184     return $self->_connected (0, { transport => 1, message => "Invalid discovery result" })
185     unless ref $result->{supportedVersions} eq "ARRAY" && ref $result->{capabilities} eq "HASH";
186    
187     my %supported = map { $_ => 1 } @{$result->{supportedVersions}};
188     my ($version) = grep $supported{$_}, @{$self->{versions}};
189     return $self->_connected (0, { transport => 1, message => "No supported protocol version" })
190     unless defined $version;
191     return $self->_initialize if $version lt "2026";
192    
193     $self->{protocol_version} = $version;
194     $self->{server_info} = $result->{_meta}{"io.modelcontextprotocol/serverInfo"};
195     $self->{server_capabilities} = $result->{capabilities};
196     $self->{instructions} = $result->{instructions};
197     $self->_connected (1, undef);
198     });
199     });
200     }
201    
202     sub _initialize {
203     my ($self) = @_;
204    
205     my ($version) = grep $_ lt "2026", @{$self->{versions}};
206     return $self->_connected (0, { transport => 1, message => "Server requires unsupported legacy initialization" })
207     unless defined $version;
208    
209     $self->{protocol_version} = $version;
210     weaken $self;
211     $self->_request ("initialize", {
212     protocolVersion => $version,
213     clientInfo => $self->{client_info},
214     capabilities => $self->{client_capabilities},
215     }, sub {
216     my ($success, $result) = @_;
217 root 1.4
218 root 1.1 return unless $self;
219     return $self->_connected (0, $result) unless $success;
220    
221     my $version = $result->{protocolVersion};
222     return $self->_connected (0, { transport => 1, message => "Unsupported negotiated protocol version" })
223     unless grep $_ eq $version && $_ lt "2026", @{$self->{versions}};
224    
225     $self->{protocol_version} = $version;
226     $self->{server_info} = $result->{serverInfo};
227     $self->{server_capabilities} = $result->{capabilities};
228     $self->{instructions} = $result->{instructions};
229     $self->_send ({ jsonrpc => "2.0", method => "notifications/initialized" },
230     sub { $self->_connected (@_) if $self });
231     });
232     }
233    
234     sub _connected {
235     my ($self, $success, $error) = @_;
236    
237     my $waiters = delete $self->{connecting} or return;
238     if ($success) {
239     $self->{connected} = 1;
240     } else {
241     $self->_shutdown;
242     delete @$self{qw(connected protocol_version server_info server_capabilities instructions)};
243     }
244     $_->($success, $error) for @$waiters;
245     }
246    
247     sub _request {
248     my ($self, $method, $params, $done, $operation) = @_;
249 root 1.4
250 root 1.1 my $id = ++$self->{wire_id};
251     my %params = %$params;
252    
253     if ($self->{protocol_version} ge "2026") {
254     $params{_meta} = {
255     %{$params{_meta} || {}},
256     "io.modelcontextprotocol/protocolVersion" => $self->{protocol_version},
257     "io.modelcontextprotocol/clientInfo" => $self->{client_info},
258     "io.modelcontextprotocol/clientCapabilities" => exists $params{_meta}{"io.modelcontextprotocol/clientCapabilities"}
259     ? $params{_meta}{"io.modelcontextprotocol/clientCapabilities"} : $self->{client_capabilities},
260     };
261     }
262    
263     $self->{pending}{$id} = $done;
264     $operation->{wire_id} = $id if $operation;
265     my $generation = $self->{generation};
266     weaken $self;
267     $self->_send ({ jsonrpc => "2.0", id => $id, method => $method, params => \%params }, sub {
268     my ($success, $error) = @_;
269 root 1.4
270 root 1.1 return if !$self || $self->{generation} != $generation || $success;
271     my $done = delete $self->{pending}{$id} or return;
272     $done->(0, $error);
273     });
274     $id
275     }
276    
277     sub _operation {
278     my ($self, $method, $params, $done, $interactions) = @_;
279 root 1.4
280 root 1.1 my $id = ++$self->{operation_id};
281     my $op = $self->{operations}{$id} = {
282 root 1.4 method => $method,
283     params => $params,
284     done => $done,
285     interactions => $interactions,
286 root 1.1 };
287    
288     weaken $self;
289     if ($self->{request_timeout}) {
290     $op->{timer} = AnyEvent->timer (after => $self->{request_timeout}, cb => sub {
291     $self->cancel ($id, "Request timed out") if $self;
292     });
293     }
294    
295     $self->connect (sub {
296     my ($success, $error) = @_;
297 root 1.4
298 root 1.1 return unless $self && $self->{operations}{$id};
299     return $self->_finish ($id, 0, $error) unless $success;
300     $self->_continue ($id, $params);
301     });
302     $id
303     }
304    
305     sub rpc {
306     my ($self, $method, $params, $done) = @_;
307    
308     $self->_operation ($method, $params, $done, 0)
309     }
310    
311     sub call {
312     my ($self, $name, $args, $done, %params) = @_;
313    
314     $self->_operation ("tools/call", { %params, name => $name, arguments => $args }, $done, 1)
315     }
316    
317     sub _continue {
318     my ($self, $id, $params) = @_;
319 root 1.4
320 root 1.1 my $op = $self->{operations}{$id} or return;
321    
322     weaken $self;
323     $self->_request ($op->{method}, $params, sub {
324     my ($success, $result) = @_;
325 root 1.4
326 root 1.1 return unless $self && $self->{operations}{$id};
327     delete $op->{wire_id};
328    
329     if ($success && $op->{interactions} && $result->{resultType} eq "input_required") {
330     return $self->_input ($id, $result)
331     }
332     $self->_finish ($id, $success, $result);
333     }, $op);
334     }
335    
336     sub _input {
337     my ($self, $id, $result) = @_;
338 root 1.4
339 root 1.1 my $op = $self->{operations}{$id} or return;
340     my $input = $result->{inputRequests};
341    
342     return $self->_finish ($id, 0, { transport => 1, message => "Input required without a client handler" })
343     unless $self->{on_request} && ref $input eq "HASH" && keys %$input;
344     return $self->_finish ($id, 0, { transport => 1, message => "Too many input rounds" })
345     if ++$op->{rounds} > ($self->{max_rounds} || 32);
346    
347     weaken $self;
348     my %responses;
349     my $remaining = keys %$input;
350     $op->{input} = [];
351     for my $key (sort keys %$input) {
352     last unless $self->{operations}{$id};
353     my $request = $input->{$key};
354     unless (ref $request eq "HASH" && defined $request->{method} && ref ($request->{params} || {}) eq "HASH") {
355     return $self->_finish ($id, 0, { transport => 1, message => "Invalid input request" })
356     }
357    
358     my $req = AnyEvent::MCP::Client::Request->new (
359 root 1.4 client => $self,
360     id => $key,
361     method => $request->{method},
362     params => $request->{params} || {},
363 root 1.1 protocol_version => $self->{protocol_version},
364 root 1.4 operation => $id,
365     on_reply => sub {
366 root 1.1 my ($success, $response) = @_;
367 root 1.4
368 root 1.1 return unless $self->{operations}{$id};
369     return $self->_finish ($id, 0, $response) unless $success;
370     $responses{$key} = $response;
371     return if --$remaining;
372    
373     delete $op->{input};
374     my %params = (%{$op->{params}}, inputResponses => \%responses);
375     $params{requestState} = $result->{requestState} if exists $result->{requestState};
376     $self->_continue ($id, \%params);
377     },
378     );
379     push @{$op->{input}}, $req;
380     $self->event (request => $req);
381     }
382     }
383    
384     sub _finish {
385     my ($self, $id, $success, $result) = @_;
386    
387     my $op = delete $self->{operations}{$id} or return;
388     delete $self->{pending}{$op->{wire_id}} if exists $op->{wire_id};
389     $_->cancel for @{$op->{input} || []};
390     delete $op->{timer};
391     $op->{done}->($success, $result);
392     }
393    
394     sub cancel {
395     my ($self, $id, $reason) = @_;
396 root 1.4
397 root 1.1 my $op = $self->{operations}{$id} or return;
398     my $wire = $op->{wire_id};
399     $reason = "Request cancelled" unless defined $reason;
400    
401     $self->_finish ($id, 0, { transport => 1, cancelled => 1, message => $reason });
402     if (defined $wire && $self->{connected}) {
403     $self->_send ({
404     jsonrpc => "2.0", method => "notifications/cancelled",
405 root 1.4 params => { requestId => $wire, reason => $reason },
406 root 1.1 }, sub {});
407     $self->_abort ($wire);
408     }
409     }
410    
411     sub tools {
412     my ($self, $done) = @_;
413 root 1.4
414 root 1.1 my (@tools, %seen);
415     weaken $self;
416     my $page;
417     $page = sub {
418     my ($cursor) = @_;
419 root 1.4
420 root 1.1 $self->rpc ("tools/list", defined $cursor ? { cursor => $cursor } : {}, sub {
421     my ($success, $result) = @_;
422 root 1.4
423 root 1.1 unless ($success && ref $result->{tools} eq "ARRAY") {
424     undef $page;
425     return $done->(0, $success ? { transport => 1, message => "Invalid tools/list result" } : $result)
426     }
427    
428     push @tools, @{$result->{tools}};
429     if (defined $result->{nextCursor} && length $result->{nextCursor}) {
430     if ($seen{$result->{nextCursor}}++) {
431     undef $page;
432     return $done->(0, { transport => 1, message => "Repeated tools/list cursor" })
433     }
434     return $page->($result->{nextCursor})
435     }
436    
437     undef $page;
438     $done->(1, \@tools);
439     });
440     };
441     $page->();
442     }
443    
444     sub notify {
445     my ($self, $method, $params, $done) = @_;
446 root 1.4
447 root 1.1 $done ||= sub {};
448     weaken $self;
449    
450     $self->connect (sub {
451     my ($success, $error) = @_;
452 root 1.4
453 root 1.1 return unless $self;
454     return $done->(0, $error) unless $success;
455     $self->_send ({ jsonrpc => "2.0", method => $method, params => $params }, $done);
456     });
457     }
458    
459     sub _receive {
460     my ($self, $message) = @_;
461    
462     die "Invalid JSON-RPC message\n"
463     unless ref $message eq "HASH" && $message->{jsonrpc} eq "2.0";
464    
465     if (exists $message->{method}) {
466     die "Invalid server method\n" if ref $message->{method};
467     if (!exists $message->{id}) {
468     $self->event (notification => $message->{method}, $message->{params} || {});
469     return
470     }
471    
472     die "Server-initiated request on modern connection\n" if $self->{protocol_version} ge "2026";
473     weaken $self;
474     my $req = AnyEvent::MCP::Client::Request->new (
475 root 1.4 client => $self,
476     id => $message->{id},
477     method => $message->{method},
478     params => $message->{params} || {},
479 root 1.1 on_reply => sub {
480     my ($success, $result) = @_;
481 root 1.4
482 root 1.1 return unless $self;
483     delete $self->{incoming}{$message->{id}};
484     $self->_send ({
485     jsonrpc => "2.0", id => $message->{id},
486     ($success ? "result" : "error") => $result,
487     }, sub {});
488     },
489     );
490     $self->{incoming}{$req->{id}} = $req;
491     if ($req->{method} eq "ping") {
492     $req->reply ({});
493     } elsif ($self->{on_request}) {
494     $self->event (request => $req);
495     } else {
496     $req->error (-32601, "Method not supported");
497     }
498     return
499     }
500    
501 root 1.4 my $field = exists $message->{result} ? "result" : "error";
502    
503     defined $message->{id} && !ref $message->{id}
504     && (exists $message->{result}) != (exists $message->{error})
505     && ref $message->{$field} eq "HASH"
506     or die "Invalid JSON-RPC response\n";
507 root 1.1
508     my $done = delete $self->{pending}{$message->{id}} or return;
509 root 1.4 $done->(exists $message->{result}, $message->{$field});
510 root 1.1 }
511    
512     sub _open {
513     $_[1]->(1, undef)
514     }
515    
516     sub _abort {
517     }
518    
519     sub close {
520     my ($self, $message) = @_;
521 root 1.4
522 root 1.3 my $was_connected = $self->{connected};
523 root 1.1 ++$self->{generation};
524     $self->_shutdown;
525     delete @$self{qw(connected protocol_version server_info server_capabilities instructions)};
526    
527     my $error = { transport => 1, message => defined $message ? $message : "Client closed" };
528     my $connecting = delete $self->{connecting} || [];
529     my $pending = delete $self->{pending} || {};
530     my $incoming = delete $self->{incoming} || {};
531     $_->cancel for values %$incoming;
532     $self->_finish ($_, 0, $error) for keys %{$self->{operations} || {}};
533     $_->(0, $error) for values %$pending;
534     $_->(0, $error) for @$connecting;
535 root 1.3 $self->event (close => $error) if $was_connected && defined $message;
536 root 1.1 }
537    
538     sub DESTROY {
539     $_[0]->close
540     }
541    
542     1