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 (6 days, 13 hours ago) by root
Branch: MAIN
CVS Tags: rel-0_7, rel-0_71, HEAD
Changes since 1.9: +3 -1 lines
Log Message:
*** empty log message ***

File Contents

# Content
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 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
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 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 =head1 METHODS
44
45 =over
46
47 =item $client = AnyEvent::MCP::Client->new (%config)
48
49 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
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 =item $id = $client->rpc ($method, $params, $done->($success, $result))
100
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 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 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 =item $id = $client->call ($name, $args, $done->($success, $result), %params)
115
116 Calls a tool and handles modern C<input_required> results via C<on_request>.
117 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 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 =item $client->tools ($done->($success, $tools))
140
141 Collects all pages of tool definitions into an array reference. Names and
142 definitions are preserved. Repeated pagination cursors are rejected.
143
144 =item $client->notify ($method, $params, $done->($success, $result))
145
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 =item $client->cancel ($id, $reason)
150
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 =item $client->close
156
157 Closes transport resources, cancels input handlers and fails pending callbacks.
158 New operations can reconnect. Previously sent calls are not repeated.
159
160 =back
161
162 =head1 EVENTS
163
164 =over
165
166 =item on_request => $callback->($client, $req)
167
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 =item on_notification => $callback->($client, $method, $params)
174
175 Receives server notifications, including progress and tool-list changes.
176
177 =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 =item on_close => $callback->($client, $error)
184
185 Called when an established connection closes unexpectedly, with a transport
186 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
190 =back
191
192 =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 client_info => { name => "AnyEvent::MCP", version => "$AnyEvent::MCP::VERSION" },
209 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
229 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
239 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
278 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 $self->event ("ready");
301 } 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
311 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
331 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
341 my $id = ++$self->{operation_id};
342 my $op = $self->{operations}{$id} = {
343 method => $method,
344 params => $params,
345 done => $done,
346 interactions => $interactions,
347 };
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
359 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
381 my $op = $self->{operations}{$id} or return;
382
383 weaken $self;
384 $self->_request ($op->{method}, $params, sub {
385 my ($success, $result) = @_;
386
387 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
400 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 unless (ref $request eq "HASH"
416 && defined $request->{method}
417 && ref ($request->{params} || {}) eq "HASH") {
418 return $self->_finish ($id, 0, { transport => 1, message => "Invalid input request" })
419 }
420
421 my $req = AnyEvent::MCP::Client::Request->new (
422 client => $self,
423 id => $key,
424 method => $request->{method},
425 params => $request->{params} || {},
426 protocol_version => $self->{protocol_version},
427 operation => $id,
428 on_reply => sub {
429 my ($success, $response) = @_;
430
431 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
460 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 params => { requestId => $wire, reason => $reason },
469 }, sub {});
470 $self->_abort ($wire);
471 }
472 }
473
474 sub tools {
475 my ($self, $done) = @_;
476
477 my (@tools, %seen);
478 weaken $self;
479 my $page;
480 $page = sub {
481 my ($cursor) = @_;
482
483 $self->rpc ("tools/list", defined $cursor ? { cursor => $cursor } : {}, sub {
484 my ($success, $result) = @_;
485
486 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
510 $done ||= sub {};
511 weaken $self;
512
513 $self->connect (sub {
514 my ($success, $error) = @_;
515
516 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 client => $self,
539 id => $message->{id},
540 method => $message->{method},
541 params => $message->{params} || {},
542 on_reply => sub {
543 my ($success, $result) = @_;
544
545 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 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
571 my $done = delete $self->{pending}{$message->{id}} or return;
572 $done->(exists $message->{result}, $message->{$field});
573 }
574
575 sub _open {
576 $_[1]->(1, undef)
577 }
578
579 sub _abort {
580 }
581
582 sub close {
583 my ($self, $message) = @_;
584
585 my $was_connected = $self->{connected};
586 # The close event runs after outstanding operations have been failed.
587 my $outstanding = scalar keys %{$self->{operations} || {}};
588 ++$self->{generation};
589 $self->_shutdown;
590 delete @$self{qw(connected protocol_version server_info server_capabilities instructions)};
591
592 my $error = { transport => 1, pending => $outstanding,
593 message => defined $message ? $message : "Client closed" };
594 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 $self->event (close => $error) if $was_connected && defined $message;
602 }
603
604 sub DESTROY {
605 $_[0]->close
606 }
607
608 1