| 1 |
root |
1.1 |
=head1 NAME |
| 2 |
|
|
|
| 3 |
|
|
AnyEvent::MCP::Server::Toolset - MCP tool declarations and callbacks |
| 4 |
|
|
|
| 5 |
|
|
=head1 DESCRIPTION |
| 6 |
|
|
|
| 7 |
root |
1.2 |
A Toolset answers two questions: which tools exist, and what code should run |
| 8 |
|
|
when one is called? It holds tool declarations and their callbacks, but no |
| 9 |
|
|
transport or client state. You can therefore give the same Toolset to |
| 10 |
|
|
several servers without copying or re-registering the tools. |
| 11 |
|
|
|
| 12 |
|
|
If your program only needs one standalone server, the function interface in |
| 13 |
|
|
L<AnyEvent::MCP::Server::Simple> manages a default Toolset for you. |
| 14 |
root |
1.1 |
|
| 15 |
root |
1.5 |
=head1 EXAMPLE |
| 16 |
|
|
|
| 17 |
|
|
use AnyEvent::MCP::Server::Toolset; |
| 18 |
|
|
use AnyEvent::MCP::Server::HTTP; |
| 19 |
|
|
|
| 20 |
|
|
my $tools = AnyEvent::MCP::Server::Toolset->new; |
| 21 |
|
|
$tools->tool (echo => "Echo the supplied text", |
| 22 |
|
|
{ |
| 23 |
|
|
type => "object", required => ["text"], |
| 24 |
|
|
properties => { text => { type => "string" } }, |
| 25 |
|
|
}, |
| 26 |
|
|
sub { |
| 27 |
|
|
my ($req, $args) = @_; |
| 28 |
|
|
$req->result ({ text => $args->{text} }); |
| 29 |
|
|
}, |
| 30 |
|
|
); |
| 31 |
|
|
|
| 32 |
|
|
my $server = AnyEvent::MCP::Server::HTTP->new ( |
| 33 |
|
|
toolsets => [$tools], tokens => [$secret], |
| 34 |
|
|
); |
| 35 |
|
|
|
| 36 |
|
|
Here C<$secret> is an application-provided HTTP token. The server still |
| 37 |
|
|
needs C<< $server->listen >> and an event loop; see |
| 38 |
|
|
L<AnyEvent::MCP::Server::HTTP>. The Toolset does not create connections. |
| 39 |
|
|
For a complete standalone script, use |
| 40 |
|
|
L<AnyEvent::MCP::Server::Simple/TUTORIAL> instead. |
| 41 |
|
|
|
| 42 |
root |
1.1 |
=head1 REQUEST AND TOOL WORKFLOW |
| 43 |
|
|
|
| 44 |
|
|
Register tools with C<tool>, then pass the Toolset to a server through its |
| 45 |
|
|
C<toolsets> option. The server handles the protocol and transport; the Toolset |
| 46 |
|
|
supplies declarations for C<tools/list> and callbacks for C<tools/call>. |
| 47 |
|
|
|
| 48 |
|
|
For a tool call, the server creates an L<AnyEvent::MCP::Server::Request>, |
| 49 |
|
|
sets C<< $req->{tool} >> and C<< $req->{args} >>, and calls its C<on_request> |
| 50 |
|
|
hook. It then finds the first Toolset containing that tool and invokes its |
| 51 |
|
|
callback as C<$callback->($req, $args)>. C<$args> is the same hash reference |
| 52 |
|
|
as C<< $req->{args} >>; C<< $req->{toolset} >> identifies the invoking Toolset. |
| 53 |
|
|
|
| 54 |
|
|
The input schema describes the arguments to the client; the server does not |
| 55 |
|
|
validate arguments against it. The handler must validate the arguments it |
| 56 |
|
|
uses and complete the request with one reply. |
| 57 |
|
|
|
| 58 |
|
|
=head2 Permission checks and retries |
| 59 |
|
|
|
| 60 |
|
|
Configure the server's C<permissions> option with an |
| 61 |
|
|
L<AnyEvent::MCP::Server::Permissions> object. The handler can check several |
| 62 |
|
|
paths or operations in sequence: |
| 63 |
|
|
|
| 64 |
|
|
sub { |
| 65 |
|
|
my ($req, $args) = @_; |
| 66 |
|
|
|
| 67 |
|
|
my $source_grant = $req->require_permission ({ |
| 68 |
root |
1.6 |
tool => $req->{tool}, |
| 69 |
|
|
dimensions => { subject => { |
| 70 |
|
|
label => "Source file", value => $args->{source}, |
| 71 |
|
|
choices => [{ label => "source file", match => $args->{source} }], |
| 72 |
|
|
} }, |
| 73 |
root |
1.4 |
action => "read", sandbox_grants => ["workdir-read"], |
| 74 |
|
|
reason => "read the source file", |
| 75 |
root |
1.1 |
}); |
| 76 |
|
|
|
| 77 |
|
|
my $destination_grant = $req->require_permission ({ |
| 78 |
root |
1.6 |
tool => $req->{tool}, |
| 79 |
|
|
dimensions => { subject => { |
| 80 |
|
|
label => "Destination file", value => $args->{destination}, |
| 81 |
|
|
choices => [{ label => "destination file", match => $args->{destination} }], |
| 82 |
|
|
} }, |
| 83 |
root |
1.4 |
action => "write", sandbox_grants => ["workdir-write"], |
| 84 |
|
|
reason => "write the destination file", |
| 85 |
root |
1.1 |
}); |
| 86 |
|
|
|
| 87 |
|
|
... # read using $source_grant, write using $destination_grant |
| 88 |
|
|
$req->result ({ ... }); |
| 89 |
|
|
} |
| 90 |
|
|
|
| 91 |
root |
1.4 |
This example assumes both paths are inside the selected workdir and that |
| 92 |
|
|
the application enforces the returned grants. C<action> chooses the kind |
| 93 |
|
|
of operation to authorize; C<sandbox_grants> chooses the worker's authority. |
| 94 |
|
|
Grant names and their implications belong to the application. |
| 95 |
|
|
C<require_permission> returns the granted sandbox-grant array reference, |
| 96 |
|
|
which may be smaller than the requested set. Execute using those grants |
| 97 |
|
|
rather than assuming that everything requested was allowed. |
| 98 |
root |
1.1 |
|
| 99 |
|
|
Grants are separate; Server does not combine them. When reading the source |
| 100 |
|
|
and writing the destination separately, use each grant for its corresponding |
| 101 |
|
|
operation. |
| 102 |
|
|
|
| 103 |
|
|
Combining authority is needed when one operation spans several subjects, |
| 104 |
root |
1.4 |
such as renaming a file. Request the required action and sandbox grants for |
| 105 |
|
|
every affected path: a rename needs write authority for both source and |
| 106 |
|
|
destination, unlike the read/write example above. Check that every grant |
| 107 |
|
|
still permits the operation after any reductions. For an application using |
| 108 |
|
|
C<workdir-write> for a rename within one workdir, after requesting that grant |
| 109 |
|
|
for both paths: |
| 110 |
root |
1.1 |
|
| 111 |
|
|
my $permissions = $req->{server}{permissions}; |
| 112 |
|
|
my $source = $permissions->closure ($source_grant); |
| 113 |
|
|
my $destination = $permissions->closure ($destination_grant); |
| 114 |
|
|
|
| 115 |
root |
1.4 |
$source->{"workdir-write"} && $destination->{"workdir-write"} |
| 116 |
|
|
or die "Rename needs write permission for both paths\n"; |
| 117 |
root |
1.1 |
|
| 118 |
root |
1.4 |
C<closure> includes implied sandbox grants. If the operation needs one |
| 119 |
|
|
combined grant set, use the intersection of these closures, rather than their |
| 120 |
root |
1.1 |
union: authority granted for one path must not authorize the other. |
| 121 |
|
|
Do this before starting the operation. |
| 122 |
|
|
|
| 123 |
|
|
A denial dies and becomes a tool error. The first missing permission throws |
| 124 |
|
|
a special exception: Server catches it and asks. Modern MCP asking replies |
| 125 |
|
|
with C<input_required>; the client supplies the answer in a repeated call. |
| 126 |
|
|
Local asking, including the legacy fallback, retries the handler internally. |
| 127 |
|
|
An explicit local asking configuration also applies to modern callers. |
| 128 |
|
|
|
| 129 |
|
|
The handler starts again from the beginning, including the server's |
| 130 |
|
|
C<on_request> hook. Earlier one-time grants survive these retries but do not |
| 131 |
|
|
authorize another tool invocation. Current rules are rechecked, so a new |
| 132 |
|
|
denial can supersede an earlier approval. |
| 133 |
|
|
|
| 134 |
|
|
Complete all checks before side effects or asynchronous work. Do not catch |
| 135 |
|
|
the permission-needed exception as an ordinary tool failure; it must reach |
| 136 |
|
|
the server's synchronous dispatch boundary. A deferred callback cannot use |
| 137 |
|
|
that boundary to restart the handler. |
| 138 |
|
|
|
| 139 |
|
|
=head2 Replies and failures |
| 140 |
|
|
|
| 141 |
|
|
=over |
| 142 |
|
|
|
| 143 |
|
|
=item $req->reply ($result) |
| 144 |
|
|
|
| 145 |
|
|
Sends the MCP result hash reference as supplied. Use this for arbitrary |
| 146 |
|
|
content, structured errors or large results. The server adds the JSON-RPC |
| 147 |
|
|
envelope. |
| 148 |
|
|
|
| 149 |
|
|
=item $req->result ($data) |
| 150 |
|
|
|
| 151 |
|
|
Sends the hash reference C<$data> as C<structuredContent> and also as JSON in |
| 152 |
|
|
one text content item. This duplicates the data for clients that consume |
| 153 |
|
|
only text; use C<reply> when that representation is unsuitable. |
| 154 |
|
|
|
| 155 |
|
|
=item $req->error ($message) |
| 156 |
|
|
|
| 157 |
|
|
Sends a tool result with C<isError> set and C<$message> as one text item. |
| 158 |
|
|
|
| 159 |
|
|
=back |
| 160 |
|
|
|
| 161 |
|
|
A synchronous handler exception becomes a tool error. Tool failures do not |
| 162 |
|
|
become JSON-RPC errors; malformed protocol requests and unknown tools are |
| 163 |
|
|
handled by Server. |
| 164 |
|
|
|
| 165 |
|
|
Reply exactly once. Replying after completion is a bug and dies. A reply |
| 166 |
|
|
finishes the request, but returning from the callback does not. |
| 167 |
|
|
|
| 168 |
|
|
=head2 Asynchronous work and cancellation |
| 169 |
|
|
|
| 170 |
|
|
The callback may keep C<$req> and return without replying. Reply when the |
| 171 |
|
|
operation completes. The callback runs in the event loop; use asynchronous |
| 172 |
|
|
operations rather than blocking it. |
| 173 |
|
|
|
| 174 |
|
|
Set C<< $req->{on_cancel} >> to stop an outstanding operation. Cancellation |
| 175 |
|
|
sets C<< $req->{cancelled} >> and C<< $req->{finished} >> and sends no reply. |
| 176 |
|
|
A late completion callback must check C<< $req->{finished} >> before replying. |
| 177 |
|
|
|
| 178 |
|
|
Server catches exceptions during synchronous dispatch, not exceptions in |
| 179 |
|
|
later application callbacks. Such callbacks must handle their own failures. |
| 180 |
|
|
|
| 181 |
|
|
C<< $req->progress ($progress, $total, $message) >> reports progress when |
| 182 |
|
|
the client supplied a progress token. Delivery is best effort and may be |
| 183 |
|
|
dropped by the transport; it does not complete the request. |
| 184 |
|
|
|
| 185 |
|
|
=head1 METHODS |
| 186 |
|
|
|
| 187 |
|
|
=over |
| 188 |
|
|
|
| 189 |
|
|
=item $toolset = AnyEvent::MCP::Server::Toolset->new |
| 190 |
|
|
|
| 191 |
|
|
Creates an empty tool registry. |
| 192 |
|
|
|
| 193 |
|
|
=item $toolset->tool ($name, $description, $input_schema, $callback->($req, $args)) |
| 194 |
|
|
|
| 195 |
|
|
Registers a tool named C<$name>. Underscores are converted to hyphens and |
| 196 |
|
|
duplicate names are rejected. |
| 197 |
|
|
|
| 198 |
|
|
C<$description> is stripped of surrounding whitespace and common indentation. |
| 199 |
|
|
C<$input_schema> is a JSON Schema hash reference describing the arguments. |
| 200 |
|
|
|
| 201 |
|
|
C<$callback> receives an L<AnyEvent::MCP::Server::Request> object C<$req> |
| 202 |
|
|
and its C<< $req->{args} >> hash reference as C<$args>, for convenience: |
| 203 |
|
|
|
| 204 |
|
|
sub { |
| 205 |
|
|
my ($req, $args) = @_; |
| 206 |
|
|
|
| 207 |
|
|
$req->reply ({ |
| 208 |
|
|
content => [{ type => "text", text => $args->{text} }], |
| 209 |
|
|
}); |
| 210 |
|
|
} |
| 211 |
|
|
|
| 212 |
|
|
Registered tools are callbacks, not methods of the Toolset. |
| 213 |
|
|
|
| 214 |
|
|
=item $definitions = $toolset->list |
| 215 |
|
|
|
| 216 |
|
|
Returns the array reference of MCP tool definitions in registration order. |
| 217 |
|
|
C<$definitions> contains no callbacks. |
| 218 |
|
|
|
| 219 |
|
|
=item $toolset->call ($req) |
| 220 |
|
|
|
| 221 |
|
|
Invokes the tool named by C<< $req->{tool} >> with C<$req> and |
| 222 |
|
|
C<< $req->{args} >>. Sets C<< $req->{toolset} >> to this Toolset. |
| 223 |
|
|
|
| 224 |
|
|
Unknown names and exceptions from the callback are left for the server to |
| 225 |
|
|
handle. |
| 226 |
|
|
|
| 227 |
|
|
=back |
| 228 |
|
|
|
| 229 |
|
|
=cut |
| 230 |
|
|
|
| 231 |
|
|
package AnyEvent::MCP::Server::Toolset; |
| 232 |
|
|
|
| 233 |
|
|
use common::sense; |
| 234 |
|
|
use AnyEvent::MCP (); |
| 235 |
|
|
|
| 236 |
|
|
our @ISA = qw(AnyEvent::MCP::Base); |
| 237 |
|
|
|
| 238 |
|
|
sub new { |
| 239 |
|
|
my $class = shift; |
| 240 |
|
|
|
| 241 |
|
|
$class->SUPER::new (tool => {}, list => [], @_) |
| 242 |
|
|
} |
| 243 |
|
|
|
| 244 |
|
|
sub tool { |
| 245 |
|
|
my ($self, $name, $description, $input_schema, $callback) = @_; |
| 246 |
|
|
|
| 247 |
|
|
$name =~ y/_/-/; |
| 248 |
|
|
|
| 249 |
root |
1.3 |
!exists $self->{tool}{$name} |
| 250 |
|
|
or die "duplicate MCP tool '$name'\n"; |
| 251 |
root |
1.1 |
|
| 252 |
|
|
my $definition = { |
| 253 |
|
|
name => $name, |
| 254 |
|
|
description => AnyEvent::MCP::_clean_text $description, |
| 255 |
|
|
inputSchema => $input_schema, |
| 256 |
|
|
}; |
| 257 |
|
|
|
| 258 |
|
|
$self->{tool}{$name} = { |
| 259 |
|
|
definition => $definition, |
| 260 |
|
|
callback => $callback, |
| 261 |
|
|
}; |
| 262 |
|
|
|
| 263 |
|
|
push @{$self->{list}}, $definition; |
| 264 |
|
|
} |
| 265 |
|
|
|
| 266 |
|
|
sub list { |
| 267 |
|
|
$_[0]{list} |
| 268 |
|
|
} |
| 269 |
|
|
|
| 270 |
|
|
sub call { |
| 271 |
|
|
my ($self, $req) = @_; |
| 272 |
root |
1.3 |
|
| 273 |
root |
1.1 |
my $name = $req->{tool}; |
| 274 |
|
|
|
| 275 |
|
|
my $tool = $self->{tool}{$name} |
| 276 |
|
|
or die "unknown MCP tool '$name'\n"; |
| 277 |
|
|
|
| 278 |
|
|
$req->{toolset} = $self; |
| 279 |
|
|
$tool->{callback}->($req, $req->{args}) |
| 280 |
|
|
} |
| 281 |
|
|
|
| 282 |
|
|
1 |