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/Client.py +840 -0
- Pyjo/MCP/Prompt.py +260 -0
- Pyjo/MCP/Resource.py +205 -0
- Pyjo/MCP/ResourceTemplate.py +276 -0
- Pyjo/MCP/Server.py +1526 -0
- Pyjo/MCP/Test.py +761 -0
- Pyjo/MCP/Tool.py +399 -0
- Pyjo/MCP/__init__.py +80 -0
- pyjo_mcp-0.1.0.dist-info/METADATA +193 -0
- pyjo_mcp-0.1.0.dist-info/RECORD +12 -0
- pyjo_mcp-0.1.0.dist-info/WHEEL +4 -0
- pyjo_mcp-0.1.0.dist-info/licenses/LICENSE +201 -0
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
|