File Coverage

blib/lib/MCP/Server.pm
Criterion Covered Total %
statement 253 258 98.0
branch 60 64 93.7
condition 19 34 55.8
subroutine 37 38 97.3
pod 8 8 100.0
total 377 402 93.7


line stmt bran cond sub pod time code
1             package MCP::Server;
2 4     4   2043 use Mojo::Base 'Mojo::EventEmitter', -signatures;
  4         8  
  4         23  
3              
4 4     4   955 use List::Util qw(first);
  4         6  
  4         272  
5 4     4   17 use Mojo::JSON qw(false true);
  4         4  
  4         205  
6             use MCP::Constants
7 4     4   21 qw(INSUFFICIENT_SCOPE INVALID_PARAMS INVALID_REQUEST METHOD_NOT_FOUND PARSE_ERROR PROTOCOL_VERSION RESOURCE_NOT_FOUND);
  4         4  
  4         224  
8 4     4   1569 use MCP::Prompt;
  4         10  
  4         19  
9 4     4   1691 use MCP::Resource;
  4         8  
  4         38  
10 4     4   1898 use MCP::Server::Transport::HTTP;
  4         11  
  4         26  
11 4     4   1959 use MCP::Server::Transport::Stdio;
  4         10  
  4         38  
12 4     4   1790 use MCP::Tool;
  4         13  
  4         37  
13 4     4   199 use Scalar::Util qw(blessed);
  4         7  
  4         12543  
14              
15             has name => 'PerlServer';
16             has prompts => sub { [] };
17             has resources => sub { [] };
18             has tools => sub { [] };
19             has 'transport';
20             has version => '1.0.0';
21              
22 105     105 1 538 sub handle ($self, $request, $context) {
  105         123  
  105         137  
  105         113  
  105         112  
23 105 50       261 return _jsonrpc_error(PARSE_ERROR, 'Invalid JSON-RPC request') unless ref $request eq 'HASH';
24 105 50       299 return _jsonrpc_error(INVALID_REQUEST, 'Missing JSON-RPC method') unless my $method = $request->{method};
25              
26             # Requests
27 105 100       270 if (defined(my $id = $request->{id})) {
28              
29 88   50     330 my $token = ($request->{params} // {})->{_meta}{progressToken};
30 88 100       192 $context->progress_token($token) if defined $token;
31              
32 88 100       541 if ($method eq 'initialize') {
    100          
    100          
    100          
    100          
    100          
    100          
    100          
33 18   50     90 my $result = $self->_handle_initialize($request->{params} // {});
34 18         248 return _jsonrpc_response($result, $id);
35             }
36             elsif ($method eq 'tools/list') {
37 6         36 my $result = $self->_handle_tools_list($context);
38 6         18 return _jsonrpc_response($result, $id);
39             }
40             elsif ($method eq 'tools/call') {
41 28   50     121 return $self->_handle_tools_call($request->{params} // {}, $id, $context);
42             }
43             elsif ($method eq 'ping') {
44 1         4 return _jsonrpc_response({}, $id);
45             }
46             elsif ($method eq 'prompts/list') {
47 5         23 my $result = $self->_handle_prompts_list($context);
48 5         14 return _jsonrpc_response($result, $id);
49             }
50             elsif ($method eq 'prompts/get') {
51 13   50     59 return $self->_handle_prompts_get($request->{params} // {}, $id, $context);
52             }
53             elsif ($method eq 'resources/list') {
54 5         18 my $result = $self->_handle_resources_list($context);
55 5         14 return _jsonrpc_response($result, $id);
56             }
57             elsif ($method eq 'resources/read') {
58 11   50     57 return $self->_handle_resources_read($request->{params} // {}, $id, $context);
59             }
60              
61             # Method not found
62 1         5 return _jsonrpc_error(METHOD_NOT_FOUND, "Method '$method' not found", $id);
63             }
64              
65             # Notifications (ignored for now)
66 17         43 return undef;
67             }
68              
69 3     3 1 4036 sub notify_list_changed ($self, $kind) {
  3         6  
  3         6  
  3         3  
70 3 50       10 return undef unless my $transport = $self->transport;
71 3         32 return $transport->notify_all("notifications/$kind/list_changed");
72             }
73              
74 1     1 1 7218 sub oauth_metadata ($self, %args) {
  1         2  
  1         4  
  1         1  
75 1         2 my %scopes;
76 1         1 for my $primitive (@{$self->tools}, @{$self->prompts}, @{$self->resources}) {
  1         4  
  1         7  
  1         6  
77 7         34 $scopes{$_} = 1 for @{$primitive->scopes};
  7         40  
78             }
79 1         5 my $metadata = {%args};
80 1 50 50     12 $metadata->{scopes_supported} //= [sort keys %scopes] if keys %scopes;
81 1         6 return $metadata;
82             }
83              
84 7     7 1 63 sub prompt ($self, %args) {
  7         8  
  7         15  
  7         5  
85 7         37 my $prompt = MCP::Prompt->new(%args);
86 7         29 push @{$self->prompts}, $prompt;
  7         13  
87 7         18 return $prompt;
88             }
89              
90 7     7 1 38 sub resource ($self, %args) {
  7         7  
  7         17  
  7         7  
91 7         27 my $resource = MCP::Resource->new(%args);
92 7         55 push @{$self->resources}, $resource;
  7         16  
93 7         17 return $resource;
94             }
95              
96 5     5 1 24929 sub to_action ($self, $options = {}) {
  5         21  
  5         8  
  5         8  
97 5         76 $self->transport(my $http = MCP::Server::Transport::HTTP->new(server => $self, %$options));
98 5     136   99 return sub ($c) { $http->handle_request($c) };
  136         534  
  136         568283  
  136         250  
  136         203  
99             }
100              
101 0     0 1 0 sub to_stdio ($self) {
  0         0  
  0         0  
102 0         0 $self->transport(my $stdio = MCP::Server::Transport::Stdio->new(server => $self));
103 0         0 $self->transport->handle_requests;
104             }
105              
106 17     17 1 951457 sub tool ($self, %args) {
  17         17  
  17         42  
  17         18  
107 17         99 my $tool = MCP::Tool->new(%args);
108 17         80 push @{$self->tools}, $tool;
  17         32  
109 17         54 return $tool;
110             }
111              
112 18     18   21 sub _handle_initialize ($self, $params) {
  18         19  
  18         24  
  18         19  
113 18         71 my $transport = $self->transport;
114 18 100 66     115 my $caps = $transport && $transport->notifications ? {listChanged => true} : {};
115             return {
116 18         373 protocolVersion => PROTOCOL_VERSION,
117             capabilities => {prompts => $caps, resources => $caps, tools => $caps},
118             serverInfo => {name => $self->name, version => $self->version}
119             };
120             }
121              
122 5     5   7 sub _handle_prompts_list ($self, $context) {
  5         9  
  5         13  
  5         7  
123 5         8 my @prompts;
124 5         7 for my $prompt (@{$self->_prompts($context)}) {
  5         17  
125 10 100       14 next unless $context->has_scope(@{$prompt->scopes});
  10         33  
126 9         62 my $info = {name => $prompt->name, description => $prompt->description, arguments => $prompt->arguments};
127 9         57 push @prompts, $info;
128             }
129              
130 5         18 return {prompts => \@prompts};
131             }
132              
133 13     13   19 sub _handle_prompts_get ($self, $params, $id, $context) {
  13         18  
  13         17  
  13         17  
  13         17  
  13         14  
134 13   50     29 my $name = $params->{name} // '';
135 13   50     27 my $args = $params->{arguments} // {};
136             return _jsonrpc_error(METHOD_NOT_FOUND, "Prompt '$name' not found", $id)
137 13 100   23   73 unless my $prompt = first { $_->name eq $name } @{$self->_prompts($context)};
  23         83  
  13         44  
138 11 100       113 if (my $err = $self->_check_scope($prompt, $context, $id)) { return $err }
  1         3  
139 10 100       91 return _jsonrpc_error(INVALID_PARAMS, 'Invalid arguments', $id) if $prompt->validate_input($args);
140              
141 9         27 my $result = $prompt->call($args, $context);
142 9 100 66 2   144 return $result->then(sub { _jsonrpc_response($_[0], $id) }) if blessed($result) && $result->isa('Mojo::Promise');
  2         253  
143 7         19 return _jsonrpc_response($result, $id);
144             }
145              
146 5     5   7 sub _handle_resources_list ($self, $context) {
  5         8  
  5         7  
  5         9  
147 5         7 my @resources;
148 5         7 for my $resource (@{$self->_resources($context)}) {
  5         114  
149 10 100       16 next unless $context->has_scope(@{$resource->scopes});
  10         36  
150 9         75 my $info = {
151             uri => $resource->uri,
152             name => $resource->name,
153             description => $resource->description,
154             mimeType => $resource->mime_type
155             };
156 9         173 push @resources, $info;
157             }
158              
159 5         27 return {resources => \@resources};
160             }
161              
162 11     11   42 sub _handle_resources_read ($self, $params, $id, $context) {
  11         15  
  11         17  
  11         12  
  11         26  
  11         11  
163 11   50     40 my $uri = $params->{uri} // '';
164             return _jsonrpc_error(RESOURCE_NOT_FOUND, 'Resource not found', $id)
165 11 100   19   46 unless my $resource = first { $_->uri eq $uri } @{$self->_resources($context)};
  19         65  
  11         34  
166 9 100       82 if (my $err = $self->_check_scope($resource, $context, $id)) { return $err }
  1         3  
167              
168 8         65 my $result = $resource->call($context);
169 8 100 66 1   154 return $result->then(sub { _jsonrpc_response($_[0], $id) }) if blessed($result) && $result->isa('Mojo::Promise');
  1         174  
170 7         18 return _jsonrpc_response($result, $id);
171             }
172              
173 28     28   50 sub _handle_tools_call ($self, $params, $id, $context) {
  28         36  
  28         48  
  28         64  
  28         32  
  28         34  
174 28   50     68 my $name = $params->{name} // '';
175 28   50     72 my $args = $params->{arguments} // {};
176             return _jsonrpc_error(METHOD_NOT_FOUND, "Tool '$name' not found", $id)
177 28 100   86   141 unless my $tool = first { $_->name eq $name } @{$self->_tools($context)};
  86         276  
  28         93  
178 25 100       235 if (my $err = $self->_check_scope($tool, $context, $id)) { return $err }
  2         6  
179 23 100       192 return _jsonrpc_error(INVALID_PARAMS, 'Invalid arguments', $id) if $tool->validate_input($args);
180              
181 22         18039 my $result = $tool->call($args, $context);
182 22 100 66 3   437 return $result->then(sub { _jsonrpc_response($_[0], $id) }) if blessed($result) && $result->isa('Mojo::Promise');
  3         401  
183 19         50 return _jsonrpc_response($result, $id);
184             }
185              
186 6     6   10 sub _handle_tools_list ($self, $context) {
  6         12  
  6         9  
  6         7  
187 6         8 my @tools;
188 6         9 for my $tool (@{$self->_tools($context)}) {
  6         20  
189 18 100       22 next unless $context->has_scope(@{$tool->scopes});
  18         50  
190 17         72 my $info = {name => $tool->name, description => $tool->description, inputSchema => $tool->input_schema};
191 17 100       134 if (my $output_schema = $tool->output_schema) { $info->{outputSchema} = $output_schema }
  1         4  
192              
193 17         64 my $annotations = $tool->annotations;
194 17 100       54 $info->{annotations} = $annotations if keys %$annotations;
195 17         26 push @tools, $info;
196             }
197              
198 6         19 return {tools => \@tools};
199             }
200              
201 45     45   71 sub _check_scope ($self, $primitive, $context, $id) {
  45         57  
  45         63  
  45         74  
  45         58  
  45         47  
202 45         156 my $scopes = $primitive->scopes;
203 45 100       257 return undef if $context->has_scope(@$scopes);
204 4         11 $context->insufficient_scope($scopes);
205 4         22 return _jsonrpc_error(INSUFFICIENT_SCOPE, 'Insufficient scope', $id);
206             }
207              
208 14     14   1021 sub _jsonrpc_error ($code, $message, $id = undef) {
  14         18  
  14         19  
  14         18  
  14         17  
209 14         111 return {jsonrpc => '2.0', id => $id, error => {code => $code, message => $message}};
210             }
211              
212 74     74   100 sub _jsonrpc_response ($result, $id = undef) {
  74         94  
  74         108  
  74         87  
213 74         453 return {jsonrpc => '2.0', id => $id, result => $result};
214             }
215              
216 18     18   24 sub _prompts ($self, $context) {
  18         22  
  18         28  
  18         20  
217 18         20 my $prompts = [@{$self->prompts}];
  18         61  
218 18         183 $self->emit('prompts', $prompts, $context);
219 18         292 return $prompts;
220             }
221              
222 16     16   24 sub _resources ($self, $context) {
  16         19  
  16         17  
  16         18  
223 16         23 my $resources = [@{$self->resources}];
  16         57  
224 16         135 $self->emit('resources', $resources, $context);
225 16         278 return $resources;
226             }
227              
228 34     34   37 sub _tools ($self, $context) {
  34         41  
  34         38  
  34         37  
229 34         43 my $tools = [@{$self->tools}];
  34         89  
230 34         330 $self->emit('tools', $tools, $context);
231 34         777 return $tools;
232             }
233              
234             1;
235              
236             =encoding utf8
237              
238             =head1 NAME
239              
240             MCP::Server - MCP server implementation
241              
242             =head1 SYNOPSIS
243              
244             use MCP::Server;
245              
246             my $server = MCP::Server->new(name => 'MyServer');
247              
248             $server->tool(
249             name => 'echo',
250             description => 'Echo the input text',
251             input_schema => {type => 'object', properties => {msg => {type => 'string'}}, required => ['msg']},
252             code => sub ($tool, $args) {
253             return "Echo: $args->{msg}";
254             }
255             );
256              
257             $server->prompt(
258             name => 'echo',
259             description => 'A prompt to demonstrate the echo tool',
260             code => sub ($prompt, $args) {
261             return 'Use the echo tool with the message "Hello, World!"';
262             }
263             );
264              
265             $server->resource(
266             uri => 'file:///example.txt',
267             name => 'example',
268             description => 'A simple text resource',
269             mime_type => 'text/plain',
270             code => sub ($resource) {
271             return 'This is an example resource content.';
272             }
273             );
274              
275             $server->to_stdio;
276              
277             =head1 DESCRIPTION
278              
279             L is an MCP (Model Context Protocol) server.
280              
281             =head1 EVENTS
282              
283             L inherits all events from L and emits the following new ones.
284              
285             =head2 prompts
286              
287             $server->on(prompts => sub ($server, $prompts, $context) { ... });
288              
289             Emitted whenever the list of prompts is accessed.
290              
291             =head2 resources
292              
293             $server->on(resources => sub ($server, $resources, $context) { ... });
294              
295             Emitted whenever the list of resources is accessed.
296              
297             =head2 tools
298              
299             $server->on(tools => sub ($server, $tools, $context) { ... });
300              
301             Emitted whenever the list of tools is accessed.
302              
303             =head1 ATTRIBUTES
304              
305             L implements the following attributes.
306              
307             =head2 name
308              
309             my $name = $server->name;
310             $server = $server->name('MyServer');
311              
312             The name of the server, used for identification.
313              
314             =head2 prompts
315              
316             my $prompts = $server->prompts;
317             $server = $server->prompts([MCP::Prompt->new]);
318              
319             An array reference containing registered prompts.
320              
321             =head2 resources
322              
323             my $resources = $server->resources;
324             $server = $server->resources([MCP::Resource->new]);
325              
326             An array reference containing registered resources.
327              
328             =head2 tools
329              
330             my $tools = $server->tools;
331             $server = $server->tools([MCP::Tool->new]);
332              
333             An array reference containing registered tools.
334              
335             =head2 transport
336              
337             my $transport = $server->transport;
338             $server = $server->transport(MCP::Server::Transport::HTTP->new);
339              
340             The transport layer used by the server, such as L or L.
341              
342             =head2 version
343              
344             my $version = $server->version;
345             $server = $server->version('1.0.0');
346              
347             The version of the server.
348              
349             =head1 METHODS
350              
351             L inherits all methods from L and implements the following new ones.
352              
353             =head2 handle
354              
355             my $response = $server->handle($request, $context);
356              
357             Handle a JSON-RPC request and return a response.
358              
359             =head2 notify_list_changed
360              
361             my $bool = $server->notify_list_changed('tools');
362              
363             Broadcast a C JSON-RPC notification to all connected clients. Returns true on
364             success, or C if no notification could be delivered.
365              
366             =head2 oauth_metadata
367              
368             my $metadata = $server->oauth_metadata(
369             resource => 'https://example.com/mcp',
370             authorization_servers => ['https://auth.example.com']
371             );
372              
373             Build an OAuth 2.0 Protected Resource Metadata document from the given fields, to be served from
374             C. Unless C is provided, it is filled in with the sorted
375             union of all scopes declared by registered tools, prompts, and resources.
376              
377             =head2 prompt
378              
379             my $prompt = $server->prompt(
380             name => 'my_prompt',
381             description => 'A sample prompt',
382             arguments => [{name => 'foo', description => 'Whatever', required => 1}],
383             code => sub ($prompt, $args) { ... }
384             );
385              
386             Register a new prompt with the server.
387              
388             =head2 resource
389              
390             my $resource = $server->resource(
391             uri => 'file://my_resource',
392             name => 'sample_resource',
393             description => 'A sample resource',
394             mime_type => 'text/plain',
395             code => sub ($resource) { ... }
396             );
397              
398             Register a new resource with the server.
399              
400             =head2 to_action
401              
402             my $action = $server->to_action;
403             my $action = $server->to_action({streaming => 1});
404              
405             Convert the server to a L action. Any options are passed through to the constructor of
406             L; in particular, C<< streaming => 1 >> opts in to the server-to-client SSE stream
407             and explicit session termination.
408              
409             =head2 to_stdio
410              
411             $server->to_stdio;
412              
413             Handles JSON-RPC requests over standard input/output.
414              
415             =head2 tool
416              
417             my $tool = $server->tool(
418             name => 'my_tool',
419             description => 'A sample tool',
420             input_schema => {type => 'object', properties => {foo => {type => 'string'}}},
421             code => sub ($tool, $args) { ... }
422             );
423              
424             Register a new tool with the server.
425              
426             =head1 SEE ALSO
427              
428             L, L, L.
429              
430             =cut