Pyjo-MCP 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Pyjo/MCP/Server.py ADDED
@@ -0,0 +1,1526 @@
1
+ """
2
+ Pyjo.MCP.Server - Server of MCP
3
+ ===============================
4
+ ::
5
+
6
+ from Pyjo.MCP.Server import Server
7
+
8
+ server = Server(name='calculator', version='1.0.0')
9
+
10
+ @server.tool
11
+ def add(a: int, b: int) -> int:
12
+ \"\"\"Add two numbers.\"\"\"
13
+ return a + b
14
+
15
+ @server.prompt
16
+ def explain(expression: str) -> str:
17
+ \"\"\"Explain an arithmetic expression step by step.\"\"\"
18
+ return f'Explain step by step how to compute {expression}.'
19
+
20
+ @server.resource('calculator://precision', mime_type='application/json')
21
+ def precision() -> dict:
22
+ \"\"\"The precision of the numbers of the calculator.\"\"\"
23
+ return {'digits': 15}
24
+
25
+ # Or import the module, as in Google Python Style Guide
26
+
27
+ import Pyjo.MCP.Server
28
+
29
+ server = Pyjo.MCP.Server.new(name='calculator', version='1.0.0', instructions='Arithmetic for the model.')
30
+ server.tool('add', lambda a, b: a + b, input_schema={'type': 'object', 'properties': {'a': {}, 'b': {}}})
31
+
32
+ # Pyjoyment.Lite, with the validation of Host and Origin
33
+ plugin('AllowedHosts')
34
+ any('/mcp', server.to_action())
35
+
36
+ # Pyjoyment
37
+ app.plugin('AllowedHosts')
38
+ app.routes.any('/mcp').to(cb=server.to_action())
39
+
40
+ # The transport stdio too, of the command stdio
41
+ app.hook('before_server_start', server.to_stdio())
42
+
43
+ :mod:`Pyjo.MCP.Server` is a server of the Model Context Protocol, of the
44
+ revision :data:`Pyjo.MCP.PROTOCOL_VERSION` of the specification, with the
45
+ methods ``server/discover``, ``tools/list`` and ``tools/call``, whose tools are
46
+ :mod:`Pyjo.MCP.Tool` objects, ``prompts/list`` and ``prompts/get``, whose
47
+ prompts are :mod:`Pyjo.MCP.Prompt` objects, ``resources/list``,
48
+ ``resources/templates/list`` and ``resources/read``, whose resources are
49
+ :mod:`Pyjo.MCP.Resource` and :mod:`Pyjo.MCP.ResourceTemplate` objects, and
50
+ ``completion/complete``, which completes the values of their arguments and
51
+ variables. The protocol is stateless: each request carries its revision and
52
+ the capabilities of its client in its ``_meta``, which the server checks, and
53
+ a result carries the name and the version of the server in its own ``_meta``.
54
+
55
+ A client that wants to know of the changes of the server opens a subscription
56
+ with ``subscriptions/listen``, a request that stays in progress, whose stream
57
+ gets the notifications of :meth:`~Pyjo_MCP_Server.notify_list_changed` and
58
+ :meth:`~Pyjo_MCP_Server.notify_resource_updated` that the client asks for,
59
+ until the client cancels it or the server stops.
60
+
61
+ A tool, a prompt or a resource that needs input of the client, of the user,
62
+ of its language model or of its roots, asks for it with
63
+ :meth:`Context.elicit`, :meth:`Context.sample`, :meth:`Context.list_roots` or
64
+ :meth:`Context.input`, and the server answers with the result
65
+ ``input_required`` of the requests, whose responses come with the same request
66
+ again, in the rounds of a multi round-trip request, with the responses of the
67
+ rounds before in its ``requestState``, which
68
+ :attr:`~Pyjo_MCP_Server.secrets` sign.
69
+
70
+ The requests come over Streamable HTTP, the transport of
71
+ :meth:`~Pyjo_MCP_Server.to_action`, an action of a route of :mod:`Pyjoyment`,
72
+ which answers each request with a JSON object, or with a stream of events when
73
+ the request sends notifications before its result, as its progress, and the
74
+ client accepts one. The specification requires the
75
+ validation of ``Origin`` of a server on ``localhost``, against DNS rebinding,
76
+ which :mod:`Pyjoyment.Plugin.AllowedHosts` does, for the whole application or
77
+ the routes under its helper.
78
+
79
+ The requests come over the transport stdio too, a message of JSON-RPC on each
80
+ line of the standard input and output, which
81
+ :meth:`~Pyjo_MCP_Server.to_stdio` serves when the command ``stdio`` of
82
+ :mod:`Pyjoyment` starts the application, and the log goes to the standard
83
+ error. :meth:`~Pyjo_MCP_Server.serve` serves any other stream of messages, and
84
+ :meth:`~Pyjo_MCP_Server.dispatch_p` handles a message of any other transport.
85
+
86
+ See also
87
+ --------
88
+
89
+ :mod:`Pyjo.MCP.Tool`, :mod:`Pyjo.MCP.Prompt`, :mod:`Pyjo.MCP.Resource`,
90
+ :mod:`Pyjo.MCP.ResourceTemplate`, :mod:`Pyjo.JSON.RPC`, :mod:`Pyjoyment.Plugin.AllowedHosts`,
91
+ :mod:`Pyjo.MCP`.
92
+
93
+ Classes
94
+ -------
95
+ """
96
+
97
+ import Pyjo.EventEmitter
98
+ import Pyjo.Exception
99
+ import Pyjo.IOLoop
100
+ import Pyjo.IOLoop.Messages
101
+ import Pyjo.JSON
102
+ import Pyjo.JSON.RPC
103
+ import Pyjo.Log
104
+ import Pyjo.Promise
105
+ import Pyjo.Server.Stdio
106
+ import Pyjo.URL.Template
107
+
108
+ # The package of the distribution, imported relatively, as pyright takes the regular package Pyjo of Pyjoyment, which
109
+ # hides the namespace that extend_path keeps open
110
+ from .. import MCP
111
+ from . import Prompt, Resource, ResourceTemplate, Tool
112
+
113
+ import base64
114
+ import binascii
115
+ import builtins
116
+ import hashlib
117
+ import hmac
118
+ import inspect
119
+ import re
120
+ import threading
121
+ import time
122
+
123
+ from collections.abc import Callable
124
+ from typing import TYPE_CHECKING, Any, Self, cast, overload
125
+
126
+
127
+ if TYPE_CHECKING:
128
+ import Pyjoyment.Controller
129
+
130
+ type Anything = builtins.object
131
+
132
+ # The members of params that the header Mcp-Name has, for each method
133
+ _NAMES = {'tools/call': 'name', 'resources/read': 'uri', 'prompts/get': 'name'}
134
+
135
+ # A value of a header that is encoded with Base64, and one that is plain
136
+ _BASE64 = re.compile(r'=\?base64\?(.*)\?=', re.S)
137
+ _PLAIN = re.compile(r'[\t\x20-\x7e]*')
138
+
139
+
140
+ class InputRequired(Exception):
141
+ """
142
+ The exception of :meth:`Context.input` of a request whose input the client
143
+ has not given yet, which the server answers with the result
144
+ ``input_required`` of its requests. A function lets it go, as an
145
+ ``except Exception`` that catches it would keep the server from asking. ::
146
+
147
+ try:
148
+ answer = context.elicit('confirm', 'Delete the file?')
149
+ except InputRequired:
150
+ raise
151
+ except Exception:
152
+ ...
153
+ """
154
+
155
+ def __init__(self, requests: dict[str, dict[str, Any]]) -> None:
156
+ super().__init__(f'Input required: {", ".join(requests)}')
157
+
158
+ self.requests = requests
159
+ """::
160
+
161
+ requests = e.requests
162
+
163
+ The requests of the input, by their keys, as ``inputRequests`` gives
164
+ them.
165
+ """
166
+
167
+
168
+ class Context(Pyjo.EventEmitter.object):
169
+ """
170
+ The context of a request of MCP, with what its ``_meta`` tells, which a
171
+ tool that asks for it gets before its arguments, and the notifications
172
+ that the tool sends to the client before its result. ::
173
+
174
+ @server.tool(context=True)
175
+ def whoami(context) -> str:
176
+ return context.client_info.get('name', 'unknown')
177
+
178
+ Events
179
+ ------
180
+
181
+ ``cancelled`` ::
182
+
183
+ @context.on('cancelled')
184
+ def cancelled(context):
185
+ ...
186
+
187
+ Emitted once when the client abandons the request, as when it closes the
188
+ connection of a response of Streamable HTTP before the result, or sends
189
+ ``notifications/cancelled`` of the request over the transport stdio, so
190
+ that a tool that runs long stops. ::
191
+
192
+ id = Pyjo.IOLoop.recurring(1, lambda: ...)
193
+ context.on('cancelled', lambda context: Pyjo.IOLoop.remove(id))
194
+ """
195
+
196
+ def __init__(
197
+ self,
198
+ controller: 'Pyjoyment.Controller.object | None' = None,
199
+ client: Pyjo.JSON.RPC.Client | None = None,
200
+ log: Pyjo.Log.object | None = None,
201
+ app: Any = None,
202
+ ) -> None:
203
+ """::
204
+
205
+ context = Pyjo.MCP.Server.Context.new()
206
+ context = Pyjo.MCP.Server.Context.new(c)
207
+ context = Pyjo.MCP.Server.Context.new(client=client, log=app.log, app=app)
208
+
209
+ Construct a new :class:`Context` object.
210
+ """
211
+ super().__init__()
212
+
213
+ self.app = controller.app if app is None and controller is not None else app
214
+ """::
215
+
216
+ app = context.app
217
+
218
+ The application of :mod:`Pyjoyment` of the request, the one of the
219
+ controller or of the transport stdio, whose ``secrets`` sign the state
220
+ of the request by default, or ``None``.
221
+ """
222
+
223
+ self.client = client
224
+ """::
225
+
226
+ client = context.client
227
+
228
+ The :class:`Pyjo.JSON.RPC.Client` of the peer of a stream of messages,
229
+ as the transport stdio, whose requests go to the client of MCP on the
230
+ same stream, or ``None`` for Streamable HTTP.
231
+ """
232
+
233
+ self.client_capabilities: dict[str, Any] = {}
234
+ """::
235
+
236
+ capabilities = context.client_capabilities
237
+
238
+ The capabilities of the client, as the request declares them.
239
+ """
240
+
241
+ self.client_info: dict[str, Any] = {}
242
+ """::
243
+
244
+ info = context.client_info
245
+
246
+ The name and the version of the client, as the request tells them, or
247
+ an empty :class:`dict`.
248
+ """
249
+
250
+ self.controller = controller
251
+ """::
252
+
253
+ c = context.controller
254
+
255
+ The :mod:`Pyjoyment.Controller` of the request of HTTP, or ``None`` for
256
+ another transport.
257
+ """
258
+
259
+ self.id: Any = None
260
+ """::
261
+
262
+ id = context.id
263
+
264
+ The ``id`` of the request of JSON-RPC, which the messages of its
265
+ subscription name, or ``None`` for a notification.
266
+ """
267
+
268
+ self.input_responses: dict[str, Any] = {}
269
+ """::
270
+
271
+ responses = context.input_responses
272
+
273
+ The responses of the client to the requests of :meth:`input`, by their
274
+ keys, of all the rounds of the request so far: the ``inputResponses``
275
+ of the request, and those of the rounds before it, which its
276
+ ``requestState`` keeps.
277
+ """
278
+
279
+ self.log = log
280
+ """::
281
+
282
+ log = context.log
283
+
284
+ The :mod:`Pyjo.Log` of the errors of the server of a request without
285
+ a controller, as the one of the application for the transport stdio,
286
+ or ``None``.
287
+ """
288
+
289
+ self.log_level: str | None = None
290
+ """::
291
+
292
+ level = context.log_level
293
+
294
+ The lowest level of the log messages that the client wants of the
295
+ request, as its ``_meta`` tells it, or ``None`` for none.
296
+ """
297
+
298
+ self.meta: dict[str, Any] = {}
299
+ """::
300
+
301
+ meta = context.meta
302
+
303
+ The ``_meta`` of the request.
304
+ """
305
+
306
+ self.progress_token: Any = None
307
+ """::
308
+
309
+ token = context.progress_token
310
+
311
+ The ``progressToken`` of the ``_meta`` of the request, which the
312
+ notifications of its progress name, or ``None`` for none.
313
+ """
314
+
315
+ self.protocol_version: str | None = None
316
+ """::
317
+
318
+ version = context.protocol_version
319
+
320
+ The revision of the specification of the request.
321
+ """
322
+
323
+ self.state: dict[str, Any] = {}
324
+ """::
325
+
326
+ state = context.state
327
+ context.state['total'] = 42
328
+
329
+ Values of JSON that a function keeps for the next round of the request,
330
+ as the result of a long computation before it asks for input, which
331
+ the signed ``requestState`` of the result ``input_required`` gives the
332
+ client, and the client gives back. The client can read them, but not
333
+ change them.
334
+ """
335
+
336
+ self.status: int | None = None
337
+ """::
338
+
339
+ status = context.status
340
+
341
+ The status of the response of HTTP of an error, which the request sets,
342
+ or ``None`` for the one of its code.
343
+ """
344
+
345
+ self.stream: Callable[[dict[str, Any]], Any] | None = None
346
+ """::
347
+
348
+ cb = context.stream
349
+ context.stream = lambda notification: ...
350
+
351
+ The function of the transport that sends a notification of the request
352
+ to the client, as an event of the stream of the response of Streamable
353
+ HTTP, or a message of the transport stdio, or ``None``, where the
354
+ notifications go nowhere, as for a client of Streamable HTTP that does
355
+ not accept a stream of events.
356
+ """
357
+
358
+ self._cancelled = False
359
+
360
+ @property
361
+ def is_cancelled(self) -> bool:
362
+ """::
363
+
364
+ boolean = context.is_cancelled
365
+
366
+ Check if the client abandoned the request, for a tool that cannot
367
+ subscribe to the event ``cancelled`` and checks it instead.
368
+ """
369
+ return self._cancelled
370
+
371
+ def cancel(self) -> Self:
372
+ """::
373
+
374
+ context = context.cancel()
375
+
376
+ Mark the request as abandoned, and emit the event ``cancelled``, once.
377
+ """
378
+ if not self._cancelled:
379
+ self._cancelled = True
380
+ self.emit('cancelled')
381
+ return self
382
+
383
+ def elicit(self, key: str, message: str, schema: dict[str, Any] | None = None) -> dict[str, Any]:
384
+ """::
385
+
386
+ result = context.elicit('confirm', 'Delete the file?')
387
+ result = context.elicit('name', 'Who are you?', {'type': 'object', 'properties': {'name': {'type': 'string'}}})
388
+
389
+ Ask the user for input with a form, ``elicitation/create`` of the key,
390
+ as :meth:`input` does, and return the result of the client, whose
391
+ ``action`` is ``accept``, ``decline`` or ``cancel``, with the
392
+ ``content`` of the form when the user accepts. The schema of the form
393
+ is an object without properties by default, a confirmation. ::
394
+
395
+ if context.elicit('confirm', f'Delete {path}?')['action'] != 'accept':
396
+ return 'Cancelled'
397
+ """
398
+ params = {'mode': 'form', 'message': message, 'requestedSchema': schema or {'type': 'object', 'properties': {}}}
399
+ return self.input({key: {'method': 'elicitation/create', 'params': params}})[key]
400
+
401
+ def input(self, requests: dict[str, dict[str, Any]]) -> dict[str, Any]:
402
+ """::
403
+
404
+ responses = context.input({'roots': {'method': 'roots/list'}})
405
+
406
+ Return the responses of the client to the requests, by their keys, or
407
+ raise :class:`InputRequired` of the requests that it has not answered
408
+ yet, which the server answers with the result ``input_required``, so
409
+ that the client asks the user or its language model, and sends the
410
+ request again with their responses. The function then runs again from
411
+ its beginning, and gets them, so what it does before it asks for
412
+ input has to be safe to repeat. A key names the same request in every
413
+ round. A request of a capability that the client does not declare,
414
+ ``elicitation``, ``sampling`` or ``roots``, raises the error
415
+ ``-32021`` of JSON-RPC. ::
416
+
417
+ # Two questions at once
418
+ responses = context.input(
419
+ {
420
+ 'name': {'method': 'elicitation/create', 'params': {'message': 'Name?', 'requestedSchema': schema}},
421
+ 'roots': {'method': 'roots/list'},
422
+ }
423
+ )
424
+ """
425
+ missing = {key: request for key, request in requests.items() if key not in self.input_responses}
426
+ if not missing:
427
+ return {key: self.input_responses[key] for key in requests}
428
+ for request in missing.values():
429
+ capability = _CAPABILITIES.get(str(request.get('method')), '')
430
+ if capability and capability not in self.client_capabilities:
431
+ data: dict[str, Any] = {'requiredCapabilities': {capability: {}}}
432
+ message = f'Missing required client capability: {capability}'
433
+ raise Pyjo.JSON.RPC.Error.new(MCP.MISSING_REQUIRED_CLIENT_CAPABILITY, message, data)
434
+ raise InputRequired(missing)
435
+
436
+ def list_roots(self, key: str = 'roots') -> dict[str, Any]:
437
+ """::
438
+
439
+ result = context.list_roots()
440
+ result = context.list_roots('workspace')
441
+
442
+ Ask the client for its roots, ``roots/list`` of the key, ``roots`` by
443
+ default, as :meth:`input` does, and return the result of the client,
444
+ whose ``roots`` are the directories and the files that the server may
445
+ work on. ::
446
+
447
+ paths = [root['uri'] for root in context.list_roots()['roots']]
448
+ """
449
+ return self.input({key: {'method': 'roots/list'}})[key]
450
+
451
+ def notify(self, method: str, params: dict[str, Any] | None = None) -> bool:
452
+ """::
453
+
454
+ boolean = context.notify('notifications/message', {'level': 'info', 'data': 'Hello'})
455
+
456
+ Send a notification of JSON-RPC to the client, before the result of
457
+ the request, and return whether it was sent: not for a request that
458
+ the client abandoned, nor without a :attr:`stream`.
459
+ """
460
+ if self._cancelled or (stream := self.stream) is None:
461
+ return False
462
+ stream({'jsonrpc': '2.0', 'method': method, 'params': params or {}})
463
+ return True
464
+
465
+ def notify_log(self, level: str, data: Any) -> bool:
466
+ """::
467
+
468
+ boolean = context.notify_log('info', 'Fetching the forecast')
469
+ boolean = context.notify_log('error', {'code': 23})
470
+
471
+ Send a log message of the level, ``debug``, ``info``, ``notice``,
472
+ ``warning``, ``error``, ``critical``, ``alert`` or ``emergency``, as
473
+ ``notifications/message``, if the client wants the messages of the
474
+ level, as :attr:`log_level` tells, and return whether it was sent.
475
+ """
476
+ if (minimum := self.log_level) is None or _LEVELS.get(level, 0) < _LEVELS.get(minimum, 0):
477
+ return False
478
+ return self.notify('notifications/message', {'level': level, 'data': data})
479
+
480
+ def notify_progress(self, progress: float, total: float | None = None, message: str | None = None) -> bool:
481
+ """::
482
+
483
+ boolean = context.notify_progress(1)
484
+ boolean = context.notify_progress(1, 3, 'Fetched the first page')
485
+
486
+ Send the progress of the request, as ``notifications/progress``, if
487
+ the client asked for it with a :attr:`progress_token`, and return
488
+ whether it was sent.
489
+ """
490
+ if (token := self.progress_token) is None:
491
+ return False
492
+ params: dict[str, Any] = {'progressToken': token, 'progress': progress}
493
+ if total is not None:
494
+ params['total'] = total
495
+ if message is not None:
496
+ params['message'] = message
497
+ return self.notify('notifications/progress', params)
498
+
499
+ def sample(self, key: str, messages: str | list[dict[str, Any]], max_tokens: int, **params: Any) -> dict[str, Any]:
500
+ """::
501
+
502
+ result = context.sample('summary', 'Summarize the file.', 100)
503
+ result = context.sample('summary', messages, 100, systemPrompt='You are terse.')
504
+
505
+ Ask the language model of the client for a message,
506
+ ``sampling/createMessage`` of the key, as :meth:`input` does, with the
507
+ messages of the conversation, a string for one message of the user,
508
+ the maximum number of tokens, and the other parameters of the request
509
+ by their names in the specification, and return the result of the
510
+ client, with the ``role``, the ``content`` and the ``model`` of the
511
+ message. ::
512
+
513
+ text = context.sample('haiku', f'Write a haiku about {topic}.', 50)['content']['text']
514
+ """
515
+ if isinstance(messages, str):
516
+ messages = [{'role': 'user', 'content': {'type': 'text', 'text': messages}}]
517
+ request = {
518
+ 'method': 'sampling/createMessage',
519
+ 'params': {'messages': messages, 'maxTokens': max_tokens, **params},
520
+ }
521
+ return self.input({key: request})[key]
522
+
523
+
524
+ class Pyjo_MCP_Server(Pyjo.EventEmitter.object):
525
+ """
526
+ :mod:`Pyjo.MCP.Server` inherits all events and methods from
527
+ :mod:`Pyjo.EventEmitter` and implements the following new ones.
528
+
529
+ Events
530
+ ------
531
+
532
+ ``exception`` ::
533
+
534
+ @server.on('exception')
535
+ def exception(server, e, context):
536
+ ...
537
+
538
+ Emitted when a tool raises an exception that is not a
539
+ :class:`Pyjo.MCP.Tool.Error`, an error of the server, which the response
540
+ does not tell, with the :class:`Context` of the request. The action of
541
+ :meth:`to_action` logs it with the log of the application too. ::
542
+
543
+ server.on('exception', lambda server, e, context: print(f'Failed: {e}'))
544
+ """
545
+
546
+ def __init__(self, name: str = 'Pyjo.MCP', version: str = '1.0.0', instructions: str | None = None) -> None:
547
+ """::
548
+
549
+ server = Pyjo.MCP.Server.new()
550
+ server = Pyjo.MCP.Server.new(name='calculator', version='1.0.0', instructions='Arithmetic.')
551
+
552
+ Construct a new :mod:`Pyjo.MCP.Server` object.
553
+ """
554
+ super().__init__()
555
+
556
+ self.cache_scope = 'public'
557
+ """::
558
+
559
+ scope = server.cache_scope
560
+ server.cache_scope = 'private'
561
+
562
+ Who may cache the results of ``server/discover`` and ``tools/list``,
563
+ ``public`` by default, or ``private`` for the tools of the user of the
564
+ request.
565
+ """
566
+
567
+ self.cache_ttl = 0
568
+ """::
569
+
570
+ ttl = server.cache_ttl
571
+ server.cache_ttl = 300000
572
+
573
+ How long a client may cache the results of ``server/discover`` and
574
+ ``tools/list``, in milliseconds, ``0`` by default.
575
+ """
576
+
577
+ self.heartbeat: float = 30
578
+ """::
579
+
580
+ interval = server.heartbeat
581
+ server.heartbeat = 15
582
+
583
+ How often a subscription of Streamable HTTP sends a comment of the
584
+ stream of events, which keeps a connection without notifications from
585
+ being closed by a proxy, in seconds, ``30`` by default, or ``0`` for
586
+ never.
587
+ """
588
+
589
+ self.instructions = instructions
590
+ """::
591
+
592
+ instructions = server.instructions
593
+ server.instructions = 'Arithmetic for the model.'
594
+
595
+ How a language model uses the server, which ``server/discover`` gives,
596
+ or ``None``.
597
+ """
598
+
599
+ self.list_changed = False
600
+ """::
601
+
602
+ boolean = server.list_changed
603
+ server.list_changed = True
604
+
605
+ Whether ``server/discover`` declares ``listChanged`` of the tools, the
606
+ prompts and the resources, a promise that the application calls
607
+ :meth:`notify_list_changed` when it changes a list, false by default.
608
+ """
609
+
610
+ self.name = name
611
+ """::
612
+
613
+ name = server.name
614
+ server.name = 'calculator'
615
+
616
+ The name of the server, ``Pyjo.MCP`` by default.
617
+ """
618
+
619
+ self.prompts: list[Prompt.object] = []
620
+ """::
621
+
622
+ prompts = server.prompts
623
+
624
+ The :mod:`Pyjo.MCP.Prompt` objects of the server, in the order of
625
+ ``prompts/list``.
626
+ """
627
+
628
+ self.resources: list[Resource.object] = []
629
+ """::
630
+
631
+ resources = server.resources
632
+
633
+ The :mod:`Pyjo.MCP.Resource` objects of the server, in the order of
634
+ ``resources/list``.
635
+ """
636
+
637
+ self.resource_templates: list[ResourceTemplate.object] = []
638
+ """::
639
+
640
+ templates = server.resource_templates
641
+
642
+ The :mod:`Pyjo.MCP.ResourceTemplate` objects of the server, in the order
643
+ of ``resources/templates/list``, in which ``resources/read`` tries
644
+ them for a URI that no resource has.
645
+ """
646
+
647
+ self.secrets: list[str] | None = None
648
+ """::
649
+
650
+ secrets = server.secrets
651
+ server.secrets = ['passw0rd']
652
+
653
+ The secrets that sign the ``requestState`` of a request that needs
654
+ input, the first one the new states and all of them the ones that
655
+ come back, so that a secret can be rotated, or ``None`` for the
656
+ ``secrets`` of the application of the request, as its signed cookies
657
+ have them.
658
+ """
659
+
660
+ self.state_timeout = 600
661
+ """::
662
+
663
+ timeout = server.state_timeout
664
+ server.state_timeout = 3600
665
+
666
+ How many seconds the ``requestState`` of a request that needs input is
667
+ valid, ``600`` by default, after which the client has to send the
668
+ request again from its first round.
669
+ """
670
+
671
+ self.subscribe = False
672
+ """::
673
+
674
+ boolean = server.subscribe
675
+ server.subscribe = True
676
+
677
+ Whether ``server/discover`` declares ``subscribe`` of the resources, a
678
+ promise that the application calls :meth:`notify_resource_updated`
679
+ when a resource changes, false by default.
680
+ """
681
+
682
+ self.tools: list[Tool.object] = []
683
+ """::
684
+
685
+ tools = server.tools
686
+
687
+ The :mod:`Pyjo.MCP.Tool` objects of the server, in the order of
688
+ ``tools/list``.
689
+ """
690
+
691
+ self.version = version
692
+ """::
693
+
694
+ version = server.version
695
+ server.version = '2.0.0'
696
+
697
+ The version of the server, ``1.0.0`` by default.
698
+ """
699
+
700
+ rpc = self._rpc = Pyjo.JSON.RPC.new()
701
+ rpc.method('server/discover', self._discover, context=True)
702
+ rpc.method('tools/list', self._tools_list, context=True)
703
+ rpc.method('tools/call', self._tools_call, context=True)
704
+ rpc.method('prompts/list', self._prompts_list, context=True)
705
+ rpc.method('prompts/get', self._prompts_get, context=True)
706
+ rpc.method('resources/list', self._resources_list, context=True)
707
+ rpc.method('resources/templates/list', self._resources_templates_list, context=True)
708
+ rpc.method('resources/read', self._resources_read, context=True)
709
+ rpc.method('subscriptions/listen', self._listen, context=True)
710
+ rpc.method('completion/complete', self._complete, context=True)
711
+
712
+ # The subscriptions of each thread, as a notification is written by the event loop of the thread of its stream
713
+ self._local = threading.local()
714
+
715
+ def exception(rpc: Pyjo.JSON.RPC.object, e: Exception, context: Any) -> None:
716
+ self._exception(e, context)
717
+
718
+ rpc.on('exception', exception)
719
+
720
+ def dispatch_p(self, message: Any, context: Context | None = None) -> Pyjo.Promise.object:
721
+ """::
722
+
723
+ promise = server.dispatch_p(request)
724
+ promise = server.dispatch_p(b'{"jsonrpc": "2.0", "method": "tools/list", ...}', context)
725
+
726
+ Dispatch a request or a notification of JSON-RPC, as :mod:`Pyjo.JSON`
727
+ decodes it, or the JSON of one, and return a :mod:`Pyjo.Promise` of
728
+ the response, or of ``None`` for a notification, as
729
+ :meth:`~Pyjo.JSON.RPC.Pyjo_JSON_RPC.dispatch_p` does. A batch, which
730
+ MCP does not have, and a request with the ``id`` of ``null``, which
731
+ MCP does not allow, are invalid requests.
732
+ """
733
+ context = context or Context()
734
+ if isinstance(message, bytes | bytearray | str):
735
+ try:
736
+ message = Pyjo.JSON.decode_json(message)
737
+ except ValueError:
738
+ pass
739
+ if isinstance(message, list) or ('id' in _object(message) and _object(message)['id'] is None):
740
+ return Pyjo.Promise.resolve(_error(None, Pyjo.JSON.RPC.INVALID_REQUEST, 'Invalid Request'))
741
+ context.id = _object(message).get('id')
742
+ return self._rpc.dispatch_p(message, context)
743
+
744
+ def notify_list_changed(self, kind: str) -> int:
745
+ """::
746
+
747
+ count = server.notify_list_changed('tools')
748
+ count = server.notify_list_changed('prompts')
749
+ count = server.notify_list_changed('resources')
750
+
751
+ Send ``notifications/tools/list_changed``, or the one of the prompts
752
+ or of the resources, to the subscriptions that want it, after a change
753
+ of :attr:`tools`, :attr:`prompts`, :attr:`resources` or
754
+ :attr:`resource_templates`, and return how many got it. The
755
+ subscriptions are those of the process and of the thread, as each
756
+ worker of a server that has several has its own. ::
757
+
758
+ server.tools = [tool for tool in server.tools if tool.name != 'add']
759
+ server.notify_list_changed('tools')
760
+ """
761
+ if (key := _LIST_CHANGED.get(kind)) is None:
762
+ raise Pyjo.Exception.new(f'Unknown list: {kind}')
763
+ return self._notify(lambda notifications: notifications.get(key) is True, f'notifications/{kind}/list_changed')
764
+
765
+ def notify_resource_updated(self, uri: str) -> int:
766
+ """::
767
+
768
+ count = server.notify_resource_updated('file:///project/README.md')
769
+
770
+ Send ``notifications/resources/updated`` of the URI to the
771
+ subscriptions that want it, after a change of the contents of the
772
+ resource, which the client reads again, and return how many got it, as
773
+ :meth:`notify_list_changed` does.
774
+ """
775
+
776
+ def wants(notifications: dict[str, Any]) -> bool:
777
+ return uri in notifications.get('resourceSubscriptions', [])
778
+
779
+ return self._notify(wants, 'notifications/resources/updated', {'uri': uri})
780
+
781
+ @overload
782
+ def prompt[F: Callable[..., Any]](self, name: F, cb: None = None) -> F: ...
783
+
784
+ @overload
785
+ def prompt[F: Callable[..., Any]](
786
+ self, name: str | None = None, cb: None = None, **options: Any
787
+ ) -> Callable[[F], F]: ...
788
+
789
+ @overload
790
+ def prompt[F: Callable[..., Any]](self, name: str, cb: F, **options: Any) -> F: ...
791
+
792
+ def prompt(
793
+ self, name: str | Callable[..., Any] | None = None, cb: Callable[..., Any] | None = None, **options: Any
794
+ ) -> Any:
795
+ """::
796
+
797
+ cb = server.prompt('review', lambda code: f'Review this code:\\n{code}')
798
+ cb = server.prompt('review', lambda context, code: ..., context=True)
799
+
800
+ Add a prompt, a :mod:`Pyjo.MCP.Prompt` of the function with the options
801
+ of its constructor, and return the function. Without a function, it is
802
+ a decorator, with the name of the prompt or of the function. ::
803
+
804
+ @server.prompt
805
+ def review(code: str, language: str = 'Python') -> str:
806
+ \"\"\"Review a piece of code.\"\"\"
807
+ return f'Review this {language} code:\\n{code}'
808
+
809
+ @server.prompt('code_review', title='Code review')
810
+ def code_review(code: str) -> str:
811
+ \"\"\"Review a piece of code.\"\"\"
812
+ return f'Review this code:\\n{code}'
813
+ """
814
+ if callable(name):
815
+ return self._prompt(None, name, options)
816
+ if cb is None:
817
+ title = name
818
+
819
+ def decorator(cb: Callable[..., Any]) -> Callable[..., Any]:
820
+ return self._prompt(title, cb, options)
821
+
822
+ return decorator
823
+ return self._prompt(name, cb, options)
824
+
825
+ @overload
826
+ def resource[F: Callable[..., Any]](self, uri: str, cb: None = None, **options: Any) -> Callable[[F], F]: ...
827
+
828
+ @overload
829
+ def resource[F: Callable[..., Any]](self, uri: str, cb: F, **options: Any) -> F: ...
830
+
831
+ def resource(self, uri: str, cb: Callable[..., Any] | None = None, **options: Any) -> Any:
832
+ """::
833
+
834
+ cb = server.resource('file:///project/README.md', lambda: '# Project', mime_type='text/markdown')
835
+ cb = server.resource('config://app', lambda context: ..., context=True)
836
+ cb = server.resource('notes://{number}', lambda number: ...)
837
+
838
+ Add a resource of the URI, a :mod:`Pyjo.MCP.Resource` of the function
839
+ with the options of its constructor, and return the function. A URI
840
+ Template of :rfc:`6570` with variables adds a resource template, a
841
+ :mod:`Pyjo.MCP.ResourceTemplate`, whose function gets their values as
842
+ keyword arguments. Without a function, it is a decorator. ::
843
+
844
+ @server.resource('file:///project/README.md', mime_type='text/markdown')
845
+ def readme() -> str:
846
+ \"\"\"The documentation of the project.\"\"\"
847
+ return '# Project'
848
+
849
+ @server.resource('notes://{number}')
850
+ def note(number: str) -> str:
851
+ \"\"\"A note, by its number.\"\"\"
852
+ return notes[int(number) - 1]
853
+ """
854
+ if cb is None:
855
+
856
+ def decorator(cb: Callable[..., Any]) -> Callable[..., Any]:
857
+ return self._resource(uri, cb, options)
858
+
859
+ return decorator
860
+ return self._resource(uri, cb, options)
861
+
862
+ def serve(self, peer: Any, log: Pyjo.Log.object | None = None, app: Any = None) -> Pyjo.JSON.RPC.Client:
863
+ """::
864
+
865
+ client = server.serve(messages)
866
+ client = server.serve(messages, log=app.log, app=app)
867
+
868
+ Serve the methods to a peer on a stream of messages in both directions,
869
+ as a :mod:`Pyjo.IOLoop.Messages` object, and return the
870
+ :class:`Pyjo.JSON.RPC.Client` of the peer, as
871
+ :meth:`~Pyjo.JSON.RPC.Pyjo_JSON_RPC.serve` does, with the rules of
872
+ :meth:`dispatch_p` and a :class:`Context` of its own for each request,
873
+ whose ``client`` is the one of the peer, whose ``log`` gets the errors
874
+ of the server, and whose ``app`` is the application.
875
+ ``notifications/cancelled`` of the peer cancels the :class:`Context` of
876
+ its request, which gets no response then, and the end of the input of
877
+ the peer ends its subscriptions gracefully, with their responses. ::
878
+
879
+ # The transport stdio, a message on each line of the standard input and output
880
+ messages = Pyjo.IOLoop.Messages.new(Pyjo.IOLoop.Stdio.new())
881
+ server.serve(messages)
882
+ messages.start()
883
+ """
884
+ contexts: dict[Any, Context] = {}
885
+
886
+ def dispatch_p(message: Any, client: Pyjo.JSON.RPC.Client) -> Any:
887
+ # The cancellation of a request in progress, as a connection that closes is over Streamable HTTP
888
+ if (message_ := _object(message)).get('method') == 'notifications/cancelled':
889
+ if (context := contexts.get(_id(_object(message_.get('params')).get('requestId')))) is not None:
890
+ context.cancel()
891
+ return None
892
+
893
+ # The notifications of a request go to the peer, on the same stream
894
+ context = Context(client=client, log=log, app=app)
895
+ context.stream = lambda notification: client.notify(notification['method'], **notification['params'])
896
+ if (id := _id(message_.get('id'))) is not None:
897
+ contexts[id] = context
898
+
899
+ def respond(response: Any = None) -> Any:
900
+ if contexts.get(id) is context:
901
+ del contexts[id]
902
+ return None if context.is_cancelled else response
903
+
904
+ return self.dispatch_p(message, context).then(respond)
905
+
906
+ client = self._rpc.serve(peer, dispatch_p=dispatch_p)
907
+
908
+ # The end of the input, after which the requests in progress get their responses, ends the subscriptions
909
+ def end(peer: Any) -> None:
910
+ for subscription in list(self._subscriptions):
911
+ if subscription.context.client is client:
912
+ subscription.end()
913
+
914
+ # A stream that closes abandons its requests
915
+ def close(peer: Any, *args: Any) -> None:
916
+ for context in list(contexts.values()):
917
+ context.cancel()
918
+
919
+ peer.on('end', end)
920
+ peer.on('close', close)
921
+ return client
922
+
923
+ @overload
924
+ def tool[F: Callable[..., Any]](self, name: F, cb: None = None) -> F: ...
925
+
926
+ @overload
927
+ def tool[F: Callable[..., Any]](
928
+ self, name: str | None = None, cb: None = None, **options: Any
929
+ ) -> Callable[[F], F]: ...
930
+
931
+ @overload
932
+ def tool[F: Callable[..., Any]](self, name: str, cb: F, **options: Any) -> F: ...
933
+
934
+ def tool(
935
+ self, name: str | Callable[..., Any] | None = None, cb: Callable[..., Any] | None = None, **options: Any
936
+ ) -> Any:
937
+ """::
938
+
939
+ cb = server.tool('add', lambda a, b: a + b, input_schema={'type': 'object'})
940
+ cb = server.tool('whoami', lambda context: context.client_info.get('name'), context=True)
941
+
942
+ Add a tool, a :mod:`Pyjo.MCP.Tool` of the function with the options of
943
+ its constructor, and return the function. Without a function, it is a
944
+ decorator, with the name of the tool or of the function. ::
945
+
946
+ @server.tool
947
+ def add(a: int, b: int) -> int:
948
+ \"\"\"Add two numbers.\"\"\"
949
+ return a + b
950
+
951
+ @server.tool('math.multiply', title='Multiplication', annotations={'readOnlyHint': True})
952
+ def multiply(a: int, b: int) -> int:
953
+ \"\"\"Multiply two numbers.\"\"\"
954
+ return a * b
955
+ """
956
+ if callable(name):
957
+ return self._tool(None, name, options)
958
+ if cb is None:
959
+ title = name
960
+
961
+ def decorator(cb: Callable[..., Any]) -> Callable[..., Any]:
962
+ return self._tool(title, cb, options)
963
+
964
+ return decorator
965
+ return self._tool(name, cb, options)
966
+
967
+ def to_action(self) -> Callable[['Pyjoyment.Controller.object'], Any]:
968
+ """::
969
+
970
+ action = server.to_action()
971
+
972
+ The action of a route of :mod:`Pyjoyment`, the endpoint of MCP of
973
+ Streamable HTTP, which takes a request of JSON-RPC in the body of a
974
+ POST and renders its response in JSON, ``202 Accepted`` without a body
975
+ for a notification, ``405 Method Not Allowed`` for another method of
976
+ HTTP, and ``400 Bad Request`` for a header of MCP that does not match
977
+ the body, as ``MCP-Protocol-Version``, ``Mcp-Method``, ``Mcp-Name`` and
978
+ ``Mcp-Param-{name}``, or a request that is not valid. The first
979
+ notification of the request, of :meth:`Context.notify`, turns the
980
+ response into a stream of events, ``text/event-stream``, where the
981
+ ``Accept`` of the request has it, with the notifications and then the
982
+ response, and a connection that closes before the result cancels the
983
+ :class:`Context`. A subscription of ``subscriptions/listen`` is such a
984
+ stream, which needs the ``Accept`` and stays open, with a comment every
985
+ :attr:`heartbeat` seconds, until the client closes the connection, or
986
+ until the event loop stops gracefully, which ends the subscription with
987
+ its response. ::
988
+
989
+ # Pyjoyment.Lite
990
+ any('/mcp', server.to_action())
991
+
992
+ # Pyjoyment
993
+ app.routes.any('/mcp').to(cb=server.to_action())
994
+ """
995
+
996
+ def action(c: 'Pyjoyment.Controller.object') -> Any:
997
+ if c.req.method != 'POST':
998
+ c.res.headers.header('Allow', 'POST')
999
+ return c.render(json=_error(None, Pyjo.JSON.RPC.INVALID_REQUEST, 'Method not allowed'), status=405)
1000
+
1001
+ try:
1002
+ message: Any = Pyjo.JSON.decode_json(c.req.body)
1003
+ except ValueError:
1004
+ return c.render(json=_error(None, Pyjo.JSON.RPC.PARSE_ERROR, 'Parse error'), status=400)
1005
+ if (request := _object(message)).get('id') is not None:
1006
+ if (mismatch := self._headers(c, request)) is not None:
1007
+ error = _error(request['id'], MCP.HEADER_MISMATCH, f'Header mismatch: {mismatch}')
1008
+ return c.render(json=error, status=400)
1009
+
1010
+ context = Context.new(c)
1011
+ streaming = done = False
1012
+
1013
+ # The notifications of the request open a stream of events, where the client accepts one, before the result
1014
+ def stream(notification: dict[str, Any]) -> None:
1015
+ nonlocal streaming
1016
+ if not streaming:
1017
+ streaming = True
1018
+ c.helpers.inactivity_timeout(0)
1019
+ c.res.headers.header('X-Accel-Buffering', 'no')
1020
+ c.write_sse()
1021
+ c.write_sse({'text': Pyjo.JSON.to_json(notification)})
1022
+
1023
+ if 'text/event-stream' in (c.req.headers.accept or ''):
1024
+ context.stream = stream
1025
+
1026
+ # A connection that closes before the result abandons the request
1027
+ def finish(c: 'Pyjoyment.Controller.object') -> None:
1028
+ if not done:
1029
+ context.cancel()
1030
+
1031
+ c.on('finish', finish)
1032
+
1033
+ def respond(response: Any = None) -> None:
1034
+ nonlocal done
1035
+ done = True
1036
+ if context.is_cancelled:
1037
+ return
1038
+ if streaming:
1039
+ if response is not None:
1040
+ c.write_sse({'text': Pyjo.JSON.to_json(response)})
1041
+ c.finish()
1042
+ return
1043
+ if response is None:
1044
+ c.rendered(202)
1045
+ return
1046
+ code = _object(_object(response).get('error')).get('code')
1047
+ c.render(
1048
+ json=response, status=context.status or (_STATUS.get(code, 200) if isinstance(code, int) else 200)
1049
+ )
1050
+
1051
+ c.render_later()
1052
+ return self.dispatch_p(message, context).then(respond)
1053
+
1054
+ return action
1055
+
1056
+ def to_stdio(self) -> Callable[[Any, Any], None]:
1057
+ """::
1058
+
1059
+ hook = server.to_stdio()
1060
+
1061
+ The hook ``before_server_start`` of an application of :mod:`Pyjoyment`,
1062
+ the transport stdio, which serves the methods with :meth:`serve` on
1063
+ the standard input and output of :mod:`Pyjo.Server.Stdio`, a message
1064
+ of JSON-RPC on each line, when the command ``stdio`` starts the
1065
+ application, and does nothing for another server, so that the same
1066
+ application serves Streamable HTTP with ``daemon``. ::
1067
+
1068
+ # Pyjoyment.Lite
1069
+ hook('before_server_start', server.to_stdio())
1070
+
1071
+ # Pyjoyment
1072
+ app.hook('before_server_start', server.to_stdio())
1073
+ """
1074
+
1075
+ def hook(server: Any, app: Any) -> None:
1076
+ def stream(server: Pyjo.Server.Stdio.object, stream: Any) -> None:
1077
+ self.serve(Pyjo.IOLoop.Messages.new(stream), log=app.log, app=app)
1078
+
1079
+ if isinstance(server, Pyjo.Server.Stdio.object):
1080
+ server.on('stream', stream)
1081
+
1082
+ return hook
1083
+
1084
+ def _complete(self, context: Context, /, **params: Any) -> Pyjo.Promise.object:
1085
+ self._meta(context, params)
1086
+ ref, argument = _object(params.get('ref')), _object(params.get('argument'))
1087
+ name, value = argument.get('name'), argument.get('value')
1088
+ if not isinstance(name, str) or not isinstance(value, str):
1089
+ raise Pyjo.JSON.RPC.Error.new(Pyjo.JSON.RPC.INVALID_PARAMS, 'Invalid argument')
1090
+ arguments = _object(_object(params.get('context')).get('arguments'))
1091
+
1092
+ # The prompt of its name, or the resource template of its URI Template
1093
+ source: Prompt.object | ResourceTemplate.object | None
1094
+ if ref.get('type') == 'ref/prompt':
1095
+ if (source := next((prompt for prompt in self.prompts if prompt.name == ref.get('name')), None)) is None:
1096
+ raise Pyjo.JSON.RPC.Error.new(Pyjo.JSON.RPC.INVALID_PARAMS, f'Unknown prompt: {ref.get("name")}')
1097
+ elif ref.get('type') == 'ref/resource':
1098
+ source = next((template for template in self.resource_templates if template.uri == ref.get('uri')), None)
1099
+ if source is None:
1100
+ message = f'Unknown resource template: {ref.get("uri")}'
1101
+ raise Pyjo.JSON.RPC.Error.new(Pyjo.JSON.RPC.INVALID_PARAMS, message)
1102
+ else:
1103
+ raise Pyjo.JSON.RPC.Error.new(Pyjo.JSON.RPC.INVALID_PARAMS, 'Invalid reference')
1104
+
1105
+ # The values of a list that start with what the user has typed, in any case, or the values of a function
1106
+ completer = source.complete.get(name, [])
1107
+ if isinstance(completer, list | tuple):
1108
+ prefix = value.casefold()
1109
+ promise = Pyjo.Promise.resolve(
1110
+ [str(v) for v in cast(list[Any], completer) if str(v).casefold().startswith(prefix)]
1111
+ )
1112
+ else:
1113
+ try:
1114
+ values = completer(*([context] if source.context else []), value, arguments)
1115
+ except Exception as e:
1116
+ return Pyjo.Promise.reject(e)
1117
+ if inspect.iscoroutine(values):
1118
+ values = Pyjo.Promise.new(values)
1119
+ promise = values if isinstance(values, Pyjo.Promise.object) else Pyjo.Promise.resolve(values)
1120
+
1121
+ def result(values: Any = None) -> dict[str, Any]:
1122
+ values = [str(v) for v in cast(list[Any], values or [])]
1123
+ completion = {'values': values[:100], 'total': len(values), 'hasMore': len(values) > 100}
1124
+ return self._result({'completion': completion})
1125
+
1126
+ return promise.then(result)
1127
+
1128
+ def _cursor(self, params: dict[str, Any]) -> None:
1129
+ # A list of one page, whose cursor no request has
1130
+ if params.get('cursor') is not None:
1131
+ raise Pyjo.JSON.RPC.Error.new(Pyjo.JSON.RPC.INVALID_PARAMS, 'Invalid cursor')
1132
+
1133
+ def _discover(self, context: Context, **params: Any) -> dict[str, Any]:
1134
+ self._meta(context, params)
1135
+ capabilities: dict[str, Any] = {}
1136
+ listed: dict[str, Any] = {'listChanged': True} if self.list_changed else {}
1137
+ if self.tools:
1138
+ capabilities['tools'] = {**listed}
1139
+ if self.prompts:
1140
+ capabilities['prompts'] = {**listed}
1141
+ if self.resources or self.resource_templates:
1142
+ capabilities['resources'] = {**listed, **({'subscribe': True} if self.subscribe else {})}
1143
+ if any(source.complete for source in [*self.prompts, *self.resource_templates]):
1144
+ capabilities['completions'] = {}
1145
+ result: dict[str, Any] = {'supportedVersions': list(MCP.SUPPORTED_VERSIONS), 'capabilities': capabilities}
1146
+ if self.instructions is not None:
1147
+ result['instructions'] = self.instructions
1148
+ return self._result(result, cacheable=True)
1149
+
1150
+ def _exception(self, e: Exception, context: Any) -> None:
1151
+ # An error of the server, which the log of the application of the request gets too
1152
+ if isinstance(context, Context) and (c := context.controller) is not None:
1153
+ c.helpers.log().error(Pyjo.Exception.new(e).inspect())
1154
+ elif isinstance(context, Context) and (log := context.log) is not None:
1155
+ log.error(Pyjo.Exception.new(e).inspect())
1156
+ self.emit('exception', e, context)
1157
+
1158
+ def _headers(self, c: 'Pyjoyment.Controller.object', request: dict[str, Any]) -> str | None:
1159
+ # What does not match between the headers of MCP and the body of the request, or None
1160
+ headers = c.req.headers
1161
+ params = _object(request.get('params'))
1162
+ meta = _object(params.get('_meta'))
1163
+
1164
+ version = headers.header('MCP-Protocol-Version')
1165
+ if version is None:
1166
+ return 'Missing MCP-Protocol-Version header'
1167
+ if (expected := meta.get(MCP.META_PROTOCOL_VERSION)) is not None and expected != version:
1168
+ return f"MCP-Protocol-Version header value '{version}' does not match body value '{expected}'"
1169
+
1170
+ method = request.get('method')
1171
+ if (sent := headers.header('Mcp-Method')) is None:
1172
+ return 'Missing Mcp-Method header'
1173
+ if sent != method:
1174
+ return f"Mcp-Method header value '{sent}' does not match body value '{method}'"
1175
+
1176
+ if isinstance(method, str) and (key := _NAMES.get(method)) is not None:
1177
+ if (name := headers.header('Mcp-Name')) is None:
1178
+ return 'Missing Mcp-Name header'
1179
+ if (decoded := _decode(name)) is None:
1180
+ return 'Invalid Mcp-Name header'
1181
+ if decoded != params.get(key):
1182
+ return f"Mcp-Name header value '{decoded}' does not match body value '{params.get(key)}'"
1183
+
1184
+ if method == 'tools/call':
1185
+ tool = next((tool for tool in self.tools if tool.name == params.get('name')), None)
1186
+ arguments = params.get('arguments')
1187
+ for name, path, type in tool.header_params if tool is not None else []:
1188
+ value = _value(arguments, path)
1189
+ header = headers.header(f'Mcp-Param-{name}')
1190
+ if value is None:
1191
+ if header is not None:
1192
+ return f'Unexpected Mcp-Param-{name} header'
1193
+ continue
1194
+ if header is None:
1195
+ return f'Missing Mcp-Param-{name} header'
1196
+ if (decoded := _decode(header)) is None:
1197
+ return f'Invalid Mcp-Param-{name} header'
1198
+ if not _match(type, decoded, value):
1199
+ body = value if isinstance(value, str) else Pyjo.JSON.to_json(value)
1200
+ return f"Mcp-Param-{name} header value '{decoded}' does not match body value '{body}'"
1201
+ return None
1202
+
1203
+ def _input(
1204
+ self, context: Context, method: str, target: Any, arguments: Any, params: dict[str, Any]
1205
+ ) -> Callable[[Any], Any]:
1206
+ # The input of the rounds before, from the requestState of the request, with its inputResponses, and the
1207
+ # handler of a rejection that answers InputRequired with the result input_required and the state of the next
1208
+ # round, which is bound to the method, the name or the URI, and the arguments, of the request
1209
+ binding = [method, target, hashlib.sha256(Pyjo.JSON.encode_json(arguments, sort_keys=True)).hexdigest()]
1210
+ responses = params.get('inputResponses')
1211
+ if responses is not None and not isinstance(responses, dict):
1212
+ raise Pyjo.JSON.RPC.Error.new(Pyjo.JSON.RPC.INVALID_PARAMS, 'Invalid input responses')
1213
+ if (token := params.get('requestState')) is not None:
1214
+ if (state := self._verify(context, token, binding)) is None:
1215
+ raise Pyjo.JSON.RPC.Error.new(Pyjo.JSON.RPC.INVALID_PARAMS, 'Invalid or expired requestState')
1216
+ context.input_responses = _object(state.get('responses'))
1217
+ context.state = _object(state.get('state'))
1218
+ context.input_responses.update(cast(dict[str, Any], responses or {}))
1219
+
1220
+ def input_required(e: Any = None) -> Any:
1221
+ if not isinstance(e, InputRequired):
1222
+ raise e
1223
+ result: dict[str, Any] = {'resultType': 'input_required', 'inputRequests': e.requests}
1224
+ if context.input_responses or context.state:
1225
+ state = {
1226
+ 'binding': binding,
1227
+ 'expires': int(time.time()) + self.state_timeout,
1228
+ 'responses': context.input_responses,
1229
+ 'state': context.state,
1230
+ }
1231
+ result['requestState'] = self._sign(context, state)
1232
+ return self._result(result)
1233
+
1234
+ return input_required
1235
+
1236
+ def _listen(self, context: Context, **params: Any) -> Pyjo.Promise.object:
1237
+ self._meta(context, params)
1238
+ if context.stream is None:
1239
+ raise Pyjo.JSON.RPC.Error.new(Pyjo.JSON.RPC.INVALID_REQUEST, 'Subscriptions need a stream of events')
1240
+
1241
+ # The notifications that the server sends, every one that the client asks for
1242
+ notifications = _object(params.get('notifications'))
1243
+ honored: dict[str, Any] = {key: True for key in _LIST_CHANGED.values() if notifications.get(key) is True}
1244
+ uris = notifications.get('resourceSubscriptions')
1245
+ if uris := [uri for uri in cast(list[Any], uris) if isinstance(uri, str)] if isinstance(uris, list) else []:
1246
+ honored['resourceSubscriptions'] = uris
1247
+
1248
+ promise = Pyjo.Promise.new()
1249
+ subscriptions = self._subscriptions
1250
+ loop = Pyjo.IOLoop.singleton
1251
+ timer: str | None = None
1252
+ finish: Any = None
1253
+
1254
+ def remove() -> None:
1255
+ if subscription in subscriptions:
1256
+ subscriptions.remove(subscription)
1257
+ if timer is not None:
1258
+ loop.remove(timer)
1259
+ if finish is not None:
1260
+ loop.unsubscribe('finish', finish)
1261
+
1262
+ # The server ends the subscription gracefully with its response
1263
+ def end() -> None:
1264
+ remove()
1265
+ promise.resolve(self._result({'_meta': {MCP.META_SUBSCRIPTION_ID: context.id}}))
1266
+
1267
+ # The client ends it without one
1268
+ def cancelled(context: Context) -> None:
1269
+ remove()
1270
+ promise.resolve(None)
1271
+
1272
+ context.on('cancelled', cancelled)
1273
+ subscription = _Subscription(context, honored, end)
1274
+ subscriptions.append(subscription)
1275
+ subscription.notify('notifications/subscriptions/acknowledged', {'notifications': honored})
1276
+
1277
+ # A connection of HTTP gets the comments that keep it open, and ends with a graceful stop of the event loop
1278
+ if (c := context.controller) is not None:
1279
+ if self.heartbeat:
1280
+ timer = loop.recurring(self.heartbeat, lambda loop: c.write_sse({'comment': 'keepalive'}))
1281
+
1282
+ def stopping(loop: Any) -> None:
1283
+ end()
1284
+
1285
+ finish = loop.once('finish', stopping)
1286
+ return promise
1287
+
1288
+ def _meta(self, context: Context, params: dict[str, Any]) -> None:
1289
+ # The fields of _meta of the request, which every request has
1290
+ meta_ = _object(params.get('_meta'))
1291
+ version = meta_.get(MCP.META_PROTOCOL_VERSION)
1292
+ if not isinstance(version, str):
1293
+ context.status = 400
1294
+ raise Pyjo.JSON.RPC.Error.new(Pyjo.JSON.RPC.INVALID_PARAMS, 'Missing protocol version')
1295
+ if version not in MCP.SUPPORTED_VERSIONS:
1296
+ context.status = 400
1297
+ data = {'supported': list(MCP.SUPPORTED_VERSIONS), 'requested': version}
1298
+ raise Pyjo.JSON.RPC.Error.new(MCP.UNSUPPORTED_PROTOCOL_VERSION, 'Unsupported protocol version', data)
1299
+ capabilities = meta_.get(MCP.META_CLIENT_CAPABILITIES)
1300
+ if not isinstance(capabilities, dict):
1301
+ context.status = 400
1302
+ raise Pyjo.JSON.RPC.Error.new(Pyjo.JSON.RPC.INVALID_PARAMS, 'Missing client capabilities')
1303
+ info = meta_.get(MCP.META_CLIENT_INFO)
1304
+ context.meta = meta_
1305
+ context.protocol_version = version
1306
+ context.client_capabilities = cast(dict[str, Any], capabilities)
1307
+ context.client_info = _object(info)
1308
+ context.log_level = level if isinstance(level := meta_.get(MCP.META_LOG_LEVEL), str) else None
1309
+ context.progress_token = meta_.get('progressToken')
1310
+
1311
+ def _notify(
1312
+ self, wants: Callable[[dict[str, Any]], bool], method: str, params: dict[str, Any] | None = None
1313
+ ) -> int:
1314
+ # A notification to the subscriptions that want it
1315
+ return sum(
1316
+ subscription.notify(method, params)
1317
+ for subscription in list(self._subscriptions)
1318
+ if wants(subscription.notifications)
1319
+ )
1320
+
1321
+ def _result(self, result: dict[str, Any], cacheable: bool = False) -> dict[str, Any]:
1322
+ # A result with its type, the name and the version of the server, and the hints of the cache
1323
+ result = {'resultType': 'complete', **result}
1324
+ if cacheable:
1325
+ result.update(ttlMs=self.cache_ttl, cacheScope=self.cache_scope)
1326
+ meta = _object(result.get('_meta'))
1327
+ result['_meta'] = {**meta, MCP.META_SERVER_INFO: {'name': self.name, 'version': self.version}}
1328
+ return result
1329
+
1330
+ def _prompt(self, name: str | None, cb: Callable[..., Any], options: dict[str, Any]) -> Callable[..., Any]:
1331
+ self.prompts.append(Prompt.new(cb, name=name, **options))
1332
+ return cb
1333
+
1334
+ def _prompts_get(self, context: Context, **params: Any) -> Pyjo.Promise.object:
1335
+ self._meta(context, params)
1336
+ name = params.get('name')
1337
+ if (prompt := next((prompt for prompt in self.prompts if prompt.name == name), None)) is None:
1338
+ raise Pyjo.JSON.RPC.Error.new(Pyjo.JSON.RPC.INVALID_PARAMS, f'Unknown prompt: {name}')
1339
+ arguments = params.get('arguments', {})
1340
+ if not isinstance(arguments, dict):
1341
+ raise Pyjo.JSON.RPC.Error.new(Pyjo.JSON.RPC.INVALID_PARAMS, 'Invalid arguments')
1342
+ input_required = self._input(context, 'prompts/get', name, arguments, params)
1343
+ return prompt.get_p(cast(dict[str, Any], arguments), context).then(self._result, input_required)
1344
+
1345
+ def _prompts_list(self, context: Context, **params: Any) -> dict[str, Any]:
1346
+ self._meta(context, params)
1347
+ self._cursor(params)
1348
+ return self._result({'prompts': [prompt.__json__() for prompt in self.prompts]}, cacheable=True)
1349
+
1350
+ def _resource(self, uri: str, cb: Callable[..., Any], options: dict[str, Any]) -> Callable[..., Any]:
1351
+ if Pyjo.URL.Template.new(uri).variables:
1352
+ self.resource_templates.append(ResourceTemplate.new(uri, cb, **options))
1353
+ else:
1354
+ self.resources.append(Resource.new(uri, cb, **options))
1355
+ return cb
1356
+
1357
+ def _resources_list(self, context: Context, **params: Any) -> dict[str, Any]:
1358
+ self._meta(context, params)
1359
+ self._cursor(params)
1360
+ return self._result({'resources': [resource.__json__() for resource in self.resources]}, cacheable=True)
1361
+
1362
+ def _resources_read(self, context: Context, **params: Any) -> Pyjo.Promise.object:
1363
+ self._meta(context, params)
1364
+ uri = params.get('uri')
1365
+ resource = next((resource for resource in self.resources if resource.uri == uri), None)
1366
+ if resource is None and isinstance(uri, str):
1367
+ resource = next(filter(None, (template.resource(uri) for template in self.resource_templates)), None)
1368
+ if resource is None:
1369
+ raise Pyjo.JSON.RPC.Error.new(Pyjo.JSON.RPC.INVALID_PARAMS, 'Resource not found', {'uri': uri})
1370
+
1371
+ def result(value: dict[str, Any]) -> dict[str, Any]:
1372
+ return self._result(value, cacheable=True)
1373
+
1374
+ input_required = self._input(context, 'resources/read', uri, {}, params)
1375
+ return resource.read_p(context).then(result, input_required)
1376
+
1377
+ def _resources_templates_list(self, context: Context, **params: Any) -> dict[str, Any]:
1378
+ self._meta(context, params)
1379
+ self._cursor(params)
1380
+ templates = [template.__json__() for template in self.resource_templates]
1381
+ return self._result({'resourceTemplates': templates}, cacheable=True)
1382
+
1383
+ def _secrets(self, context: Context) -> list[str]:
1384
+ # The secrets of the server, or of the application of the request
1385
+ if self.secrets:
1386
+ return self.secrets
1387
+ return list(getattr(context.app, 'secrets', None) or [])
1388
+
1389
+ def _sign(self, context: Context, state: dict[str, Any]) -> str:
1390
+ # The state, signed with the first secret
1391
+ if not (secrets := self._secrets(context)):
1392
+ raise Pyjo.Exception.new('No secrets to sign the state of a request')
1393
+ data = base64.urlsafe_b64encode(Pyjo.JSON.encode_json(state)).decode().rstrip('=')
1394
+ return f'{data}.{_signature(secrets[0], data)}'
1395
+
1396
+ @property
1397
+ def _subscriptions(self) -> list['_Subscription']:
1398
+ if (subscriptions := getattr(self._local, 'subscriptions', None)) is None:
1399
+ subscriptions = self._local.subscriptions = []
1400
+ return cast(list[_Subscription], subscriptions)
1401
+
1402
+ def _tool(self, name: str | None, cb: Callable[..., Any], options: dict[str, Any]) -> Callable[..., Any]:
1403
+ self.tools.append(Tool.new(cb, name=name, **options))
1404
+ return cb
1405
+
1406
+ def _tools_call(self, context: Context, **params: Any) -> Pyjo.Promise.object:
1407
+ self._meta(context, params)
1408
+ name = params.get('name')
1409
+ if (tool := next((tool for tool in self.tools if tool.name == name), None)) is None:
1410
+ raise Pyjo.JSON.RPC.Error.new(Pyjo.JSON.RPC.INVALID_PARAMS, f'Unknown tool: {name}')
1411
+ arguments = params.get('arguments', {})
1412
+ if not isinstance(arguments, dict):
1413
+ raise Pyjo.JSON.RPC.Error.new(Pyjo.JSON.RPC.INVALID_PARAMS, 'Invalid arguments')
1414
+ input_required = self._input(context, 'tools/call', name, arguments, params)
1415
+ return tool.call_p(cast(dict[str, Any], arguments), context).then(self._result, input_required)
1416
+
1417
+ def _tools_list(self, context: Context, **params: Any) -> dict[str, Any]:
1418
+ self._meta(context, params)
1419
+ self._cursor(params)
1420
+ return self._result({'tools': [tool.__json__() for tool in self.tools]}, cacheable=True)
1421
+
1422
+ def _verify(self, context: Context, token: Any, binding: list[Any]) -> dict[str, Any] | None:
1423
+ # The state of a token signed with any of the secrets, which has not expired and is bound to the request, or None
1424
+ if not isinstance(token, str) or '.' not in token:
1425
+ return None
1426
+ data, signature = token.rsplit('.', 1)
1427
+ if not any(hmac.compare_digest(_signature(secret, data), signature) for secret in self._secrets(context)):
1428
+ return None
1429
+ try:
1430
+ state = _object(Pyjo.JSON.decode_json(base64.urlsafe_b64decode(data + '=' * (-len(data) % 4))))
1431
+ except (ValueError, binascii.Error):
1432
+ return None
1433
+ expires = state.get('expires')
1434
+ if state.get('binding') != binding or not isinstance(expires, int) or expires < time.time():
1435
+ return None
1436
+ return state
1437
+
1438
+
1439
+ class _Subscription:
1440
+ # A subscription of subscriptions/listen, whose messages name its request, which the server ends with end
1441
+ __slots__ = ('context', 'end', 'notifications')
1442
+
1443
+ def __init__(self, context: Context, notifications: dict[str, Any], end: Callable[[], None]) -> None:
1444
+ self.context = context
1445
+ self.end = end
1446
+ self.notifications = notifications
1447
+
1448
+ def notify(self, method: str, params: dict[str, Any] | None = None) -> bool:
1449
+ return self.context.notify(method, {'_meta': {MCP.META_SUBSCRIPTION_ID: self.context.id}, **(params or {})})
1450
+
1451
+
1452
+ # The filters of the notifications of the changes of the lists of a subscription
1453
+ _LIST_CHANGED = {'tools': 'toolsListChanged', 'prompts': 'promptsListChanged', 'resources': 'resourcesListChanged'}
1454
+
1455
+ # The capabilities of the client that the requests of the input need
1456
+ _CAPABILITIES = {'elicitation/create': 'elicitation', 'sampling/createMessage': 'sampling', 'roots/list': 'roots'}
1457
+
1458
+ # The levels of the log messages of a client, as the ones of syslog
1459
+ _LEVELS = {
1460
+ level: number
1461
+ for number, level in enumerate(('debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency'))
1462
+ }
1463
+
1464
+ # The status of the response of HTTP of an error of JSON-RPC
1465
+ _STATUS = {
1466
+ Pyjo.JSON.RPC.PARSE_ERROR: 400,
1467
+ Pyjo.JSON.RPC.INVALID_REQUEST: 400,
1468
+ Pyjo.JSON.RPC.METHOD_NOT_FOUND: 404,
1469
+ Pyjo.JSON.RPC.INTERNAL_ERROR: 500,
1470
+ }
1471
+
1472
+
1473
+ def _decode(value: str) -> str | None:
1474
+ # A value of a header, decoded from Base64 of UTF-8, or None for one that is not valid
1475
+ if (match := _BASE64.fullmatch(value)) is not None:
1476
+ try:
1477
+ return base64.b64decode(match[1], validate=True).decode()
1478
+ except (binascii.Error, UnicodeDecodeError):
1479
+ return None
1480
+ return value if _PLAIN.fullmatch(value) else None
1481
+
1482
+
1483
+ def _error(id: Any, code: int, message: str) -> dict[str, Any]:
1484
+ return {'jsonrpc': '2.0', 'id': id, 'error': {'code': code, 'message': message}}
1485
+
1486
+
1487
+ def _id(id: Any) -> str | int | None:
1488
+ # An id of JSON-RPC, a string or a number, or None for another value
1489
+ return id if isinstance(id, str | int) and not isinstance(id, bool) else None
1490
+
1491
+
1492
+ def _signature(secret: str, data: str) -> str:
1493
+ # The HMAC-SHA256 of the data with the secret, in Base64 for URLs without padding
1494
+ digest = hmac.new(secret.encode(), data.encode(), hashlib.sha256).digest()
1495
+ return base64.urlsafe_b64encode(digest).decode().rstrip('=')
1496
+
1497
+
1498
+ def _object(value: Anything) -> dict[str, Any]:
1499
+ # An object of JSON, or an empty one for another value
1500
+ return cast(dict[str, Any], value) if isinstance(value, dict) else {}
1501
+
1502
+
1503
+ def _match(type: str, header: str, value: Any) -> bool:
1504
+ # A header of an argument and the value of the argument, the integers compared as numbers
1505
+ if type == 'integer':
1506
+ try:
1507
+ return float(header) == value and not isinstance(value, bool)
1508
+ except ValueError:
1509
+ return False
1510
+ if type == 'boolean':
1511
+ return isinstance(value, bool) and header == ('true' if value else 'false')
1512
+ return header == value
1513
+
1514
+
1515
+ def _value(arguments: Any, path: list[str]) -> Any:
1516
+ # The value of an argument at the path of its properties
1517
+ for key in path:
1518
+ if not isinstance(arguments, dict):
1519
+ return None
1520
+ arguments = cast(dict[str, Any], arguments).get(key)
1521
+ return arguments
1522
+
1523
+
1524
+ new = Pyjo_MCP_Server.new
1525
+ object = Pyjo_MCP_Server
1526
+ Server = Pyjo_MCP_Server