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