ViewVC Help
View File | Revision Log | Show Annotations | Download File
/cvs/cvsroot/AnyEvent-MCP/lib/AnyEvent/MCP/Client.pm
Revision: 1.3
Committed: Mon Oct 12 05:03:59 2026 UTC (2 weeks, 1 day ago) by root
Branch: MAIN
Changes since 1.2: +7 -0 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     return unless $self;
169     return $self->_connected (0, $error) unless $success;
170    
171     my ($modern) = grep $_ ge "2026", @{$self->{versions}};
172     return $self->_initialize unless $modern;
173    
174     $self->{protocol_version} = $modern;
175     $self->_request ("server/discover", {}, sub {
176     my ($success, $result) = @_;
177     return unless $self;
178    
179     return $self->_initialize if !$success && !$result->{transport};
180     return $self->_connected (0, $result) unless $success;
181    
182     return $self->_connected (0, { transport => 1, message => "Invalid discovery result" })
183     unless ref $result->{supportedVersions} eq "ARRAY" && ref $result->{capabilities} eq "HASH";
184    
185     my %supported = map { $_ => 1 } @{$result->{supportedVersions}};
186     my ($version) = grep $supported{$_}, @{$self->{versions}};
187     return $self->_connected (0, { transport => 1, message => "No supported protocol version" })
188     unless defined $version;
189     return $self->_initialize if $version lt "2026";
190    
191     $self->{protocol_version} = $version;
192     $self->{server_info} = $result->{_meta}{"io.modelcontextprotocol/serverInfo"};
193     $self->{server_capabilities} = $result->{capabilities};
194     $self->{instructions} = $result->{instructions};
195     $self->_connected (1, undef);
196     });
197     });
198     }
199    
200     sub _initialize {
201     my ($self) = @_;
202    
203     my ($version) = grep $_ lt "2026", @{$self->{versions}};
204     return $self->_connected (0, { transport => 1, message => "Server requires unsupported legacy initialization" })
205     unless defined $version;
206    
207     $self->{protocol_version} = $version;
208     weaken $self;
209     $self->_request ("initialize", {
210     protocolVersion => $version,
211     clientInfo => $self->{client_info},
212     capabilities => $self->{client_capabilities},
213     }, sub {
214     my ($success, $result) = @_;
215     return unless $self;
216     return $self->_connected (0, $result) unless $success;
217    
218     my $version = $result->{protocolVersion};
219     return $self->_connected (0, { transport => 1, message => "Unsupported negotiated protocol version" })
220     unless grep $_ eq $version && $_ lt "2026", @{$self->{versions}};
221    
222     $self->{protocol_version} = $version;
223     $self->{server_info} = $result->{serverInfo};
224     $self->{server_capabilities} = $result->{capabilities};
225     $self->{instructions} = $result->{instructions};
226     $self->_send ({ jsonrpc => "2.0", method => "notifications/initialized" },
227     sub { $self->_connected (@_) if $self });
228     });
229     }
230    
231     sub _connected {
232     my ($self, $success, $error) = @_;
233    
234     my $waiters = delete $self->{connecting} or return;
235     if ($success) {
236     $self->{connected} = 1;
237     } else {
238     $self->_shutdown;
239     delete @$self{qw(connected protocol_version server_info server_capabilities instructions)};
240     }
241     $_->($success, $error) for @$waiters;
242     }
243    
244     sub _request {
245     my ($self, $method, $params, $done, $operation) = @_;
246     my $id = ++$self->{wire_id};
247     my %params = %$params;
248    
249     if ($self->{protocol_version} ge "2026") {
250     $params{_meta} = {
251     %{$params{_meta} || {}},
252     "io.modelcontextprotocol/protocolVersion" => $self->{protocol_version},
253     "io.modelcontextprotocol/clientInfo" => $self->{client_info},
254     "io.modelcontextprotocol/clientCapabilities" => exists $params{_meta}{"io.modelcontextprotocol/clientCapabilities"}
255     ? $params{_meta}{"io.modelcontextprotocol/clientCapabilities"} : $self->{client_capabilities},
256     };
257     }
258    
259     $self->{pending}{$id} = $done;
260     $operation->{wire_id} = $id if $operation;
261     my $generation = $self->{generation};
262     weaken $self;
263     $self->_send ({ jsonrpc => "2.0", id => $id, method => $method, params => \%params }, sub {
264     my ($success, $error) = @_;
265     return if !$self || $self->{generation} != $generation || $success;
266     my $done = delete $self->{pending}{$id} or return;
267     $done->(0, $error);
268     });
269     $id
270     }
271    
272     sub _operation {
273     my ($self, $method, $params, $done, $interactions) = @_;
274     my $id = ++$self->{operation_id};
275     my $op = $self->{operations}{$id} = {
276     method => $method, params => $params, done => $done, interactions => $interactions,
277     };
278    
279     weaken $self;
280     if ($self->{request_timeout}) {
281     $op->{timer} = AnyEvent->timer (after => $self->{request_timeout}, cb => sub {
282     $self->cancel ($id, "Request timed out") if $self;
283     });
284     }
285    
286     $self->connect (sub {
287     my ($success, $error) = @_;
288     return unless $self && $self->{operations}{$id};
289     return $self->_finish ($id, 0, $error) unless $success;
290     $self->_continue ($id, $params);
291     });
292     $id
293     }
294    
295     sub rpc {
296     my ($self, $method, $params, $done) = @_;
297    
298     $self->_operation ($method, $params, $done, 0)
299     }
300    
301     sub call {
302     my ($self, $name, $args, $done, %params) = @_;
303    
304     $self->_operation ("tools/call", { %params, name => $name, arguments => $args }, $done, 1)
305     }
306    
307     sub _continue {
308     my ($self, $id, $params) = @_;
309     my $op = $self->{operations}{$id} or return;
310    
311     weaken $self;
312     $self->_request ($op->{method}, $params, sub {
313     my ($success, $result) = @_;
314     return unless $self && $self->{operations}{$id};
315     delete $op->{wire_id};
316    
317     if ($success && $op->{interactions} && $result->{resultType} eq "input_required") {
318     return $self->_input ($id, $result)
319     }
320     $self->_finish ($id, $success, $result);
321     }, $op);
322     }
323    
324     sub _input {
325     my ($self, $id, $result) = @_;
326     my $op = $self->{operations}{$id} or return;
327     my $input = $result->{inputRequests};
328    
329     return $self->_finish ($id, 0, { transport => 1, message => "Input required without a client handler" })
330     unless $self->{on_request} && ref $input eq "HASH" && keys %$input;
331     return $self->_finish ($id, 0, { transport => 1, message => "Too many input rounds" })
332     if ++$op->{rounds} > ($self->{max_rounds} || 32);
333    
334     weaken $self;
335     my %responses;
336     my $remaining = keys %$input;
337     $op->{input} = [];
338     for my $key (sort keys %$input) {
339     last unless $self->{operations}{$id};
340     my $request = $input->{$key};
341     unless (ref $request eq "HASH" && defined $request->{method} && ref ($request->{params} || {}) eq "HASH") {
342     return $self->_finish ($id, 0, { transport => 1, message => "Invalid input request" })
343     }
344    
345     my $req = AnyEvent::MCP::Client::Request->new (
346     client => $self, id => $key, method => $request->{method}, params => $request->{params} || {},
347     protocol_version => $self->{protocol_version},
348     operation => $id,
349     on_reply => sub {
350     my ($success, $response) = @_;
351     return unless $self->{operations}{$id};
352     return $self->_finish ($id, 0, $response) unless $success;
353     $responses{$key} = $response;
354     return if --$remaining;
355    
356     delete $op->{input};
357     my %params = (%{$op->{params}}, inputResponses => \%responses);
358     $params{requestState} = $result->{requestState} if exists $result->{requestState};
359     $self->_continue ($id, \%params);
360     },
361     );
362     push @{$op->{input}}, $req;
363     $self->event (request => $req);
364     }
365     }
366    
367     sub _finish {
368     my ($self, $id, $success, $result) = @_;
369    
370     my $op = delete $self->{operations}{$id} or return;
371     delete $self->{pending}{$op->{wire_id}} if exists $op->{wire_id};
372     $_->cancel for @{$op->{input} || []};
373     delete $op->{timer};
374     $op->{done}->($success, $result);
375     }
376    
377     sub cancel {
378     my ($self, $id, $reason) = @_;
379     my $op = $self->{operations}{$id} or return;
380     my $wire = $op->{wire_id};
381     $reason = "Request cancelled" unless defined $reason;
382    
383     $self->_finish ($id, 0, { transport => 1, cancelled => 1, message => $reason });
384     if (defined $wire && $self->{connected}) {
385     $self->_send ({
386     jsonrpc => "2.0", method => "notifications/cancelled",
387     params => { requestId => $wire, reason => $reason },
388     }, sub {});
389     $self->_abort ($wire);
390     }
391     }
392    
393     sub tools {
394     my ($self, $done) = @_;
395     my (@tools, %seen);
396     weaken $self;
397     my $page;
398     $page = sub {
399     my ($cursor) = @_;
400     $self->rpc ("tools/list", defined $cursor ? { cursor => $cursor } : {}, sub {
401     my ($success, $result) = @_;
402     unless ($success && ref $result->{tools} eq "ARRAY") {
403     undef $page;
404     return $done->(0, $success ? { transport => 1, message => "Invalid tools/list result" } : $result)
405     }
406    
407     push @tools, @{$result->{tools}};
408     if (defined $result->{nextCursor} && length $result->{nextCursor}) {
409     if ($seen{$result->{nextCursor}}++) {
410     undef $page;
411     return $done->(0, { transport => 1, message => "Repeated tools/list cursor" })
412     }
413     return $page->($result->{nextCursor})
414     }
415    
416     undef $page;
417     $done->(1, \@tools);
418     });
419     };
420     $page->();
421     }
422    
423     sub notify {
424     my ($self, $method, $params, $done) = @_;
425     $done ||= sub {};
426     weaken $self;
427    
428     $self->connect (sub {
429     my ($success, $error) = @_;
430     return unless $self;
431     return $done->(0, $error) unless $success;
432     $self->_send ({ jsonrpc => "2.0", method => $method, params => $params }, $done);
433     });
434     }
435    
436     sub _receive {
437     my ($self, $message) = @_;
438    
439     die "Invalid JSON-RPC message\n"
440     unless ref $message eq "HASH" && $message->{jsonrpc} eq "2.0";
441    
442     if (exists $message->{method}) {
443     die "Invalid server method\n" if ref $message->{method};
444     if (!exists $message->{id}) {
445     $self->event (notification => $message->{method}, $message->{params} || {});
446     return
447     }
448    
449     die "Server-initiated request on modern connection\n" if $self->{protocol_version} ge "2026";
450     weaken $self;
451     my $req = AnyEvent::MCP::Client::Request->new (
452     client => $self, id => $message->{id}, method => $message->{method}, params => $message->{params} || {},
453     on_reply => sub {
454     my ($success, $result) = @_;
455     return unless $self;
456     delete $self->{incoming}{$message->{id}};
457     $self->_send ({
458     jsonrpc => "2.0", id => $message->{id},
459     ($success ? "result" : "error") => $result,
460     }, sub {});
461     },
462     );
463     $self->{incoming}{$req->{id}} = $req;
464     if ($req->{method} eq "ping") {
465     $req->reply ({});
466     } elsif ($self->{on_request}) {
467     $self->event (request => $req);
468     } else {
469     $req->error (-32601, "Method not supported");
470     }
471     return
472     }
473    
474     die "Invalid JSON-RPC response\n"
475     unless defined $message->{id} && !ref $message->{id}
476     && (exists $message->{result}) != (exists $message->{error})
477     && (exists $message->{result} ? ref $message->{result} eq "HASH" : ref $message->{error} eq "HASH");
478    
479     my $done = delete $self->{pending}{$message->{id}} or return;
480     $done->(exists $message->{result}, exists $message->{result} ? $message->{result} : $message->{error});
481     }
482    
483     sub _open {
484     $_[1]->(1, undef)
485     }
486    
487     sub _abort {
488     }
489    
490     sub close {
491     my ($self, $message) = @_;
492 root 1.3 my $was_connected = $self->{connected};
493 root 1.1 ++$self->{generation};
494     $self->_shutdown;
495     delete @$self{qw(connected protocol_version server_info server_capabilities instructions)};
496    
497     my $error = { transport => 1, message => defined $message ? $message : "Client closed" };
498     my $connecting = delete $self->{connecting} || [];
499     my $pending = delete $self->{pending} || {};
500     my $incoming = delete $self->{incoming} || {};
501     $_->cancel for values %$incoming;
502     $self->_finish ($_, 0, $error) for keys %{$self->{operations} || {}};
503     $_->(0, $error) for values %$pending;
504     $_->(0, $error) for @$connecting;
505 root 1.3 $self->event (close => $error) if $was_connected && defined $message;
506 root 1.1 }
507    
508     sub DESTROY {
509     $_[0]->close
510     }
511    
512     1