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/Client.py
ADDED
|
@@ -0,0 +1,840 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pyjo.MCP.Client - Client of MCP
|
|
3
|
+
===============================
|
|
4
|
+
::
|
|
5
|
+
|
|
6
|
+
from Pyjo.MCP.Client import Client
|
|
7
|
+
|
|
8
|
+
client = Client('http://127.0.0.1:3000/mcp')
|
|
9
|
+
tools = client.list_tools()['tools']
|
|
10
|
+
|
|
11
|
+
# Or import the module, as in Google Python Style Guide
|
|
12
|
+
|
|
13
|
+
import Pyjo.MCP.Client
|
|
14
|
+
|
|
15
|
+
client = Pyjo.MCP.Client.new('http://127.0.0.1:3000/mcp', name='calculator-client')
|
|
16
|
+
result = client.call_tool('add', {'a': 1, 'b': 2})
|
|
17
|
+
print(result['structuredContent'])
|
|
18
|
+
|
|
19
|
+
# Non-blocking
|
|
20
|
+
client.call_tool_p('add', {'a': 1, 'b': 2}).then(lambda result: print(result['structuredContent'])).wait()
|
|
21
|
+
|
|
22
|
+
:mod:`Pyjo.MCP.Client` is a client of the Model Context Protocol over
|
|
23
|
+
Streamable HTTP, of the revision :data:`Pyjo.MCP.PROTOCOL_VERSION` of the
|
|
24
|
+
specification, made with :mod:`Pyjo.UserAgent`. It sends what every request
|
|
25
|
+
carries, the ``_meta`` with the revision, the capabilities and the name of the
|
|
26
|
+
client, and the headers of MCP made from the body, ``MCP-Protocol-Version``,
|
|
27
|
+
``Mcp-Method``, ``Mcp-Name`` and the ``Mcp-Param-{name}`` of the
|
|
28
|
+
``x-mcp-header`` of a tool, and reads the response in JSON or in a stream of
|
|
29
|
+
events.
|
|
30
|
+
|
|
31
|
+
It is a client to test a server with and to start from, not an agent: it
|
|
32
|
+
caches nothing for the ``ttlMs`` of a result, and leaves the authorization to
|
|
33
|
+
the application, which sends its headers with :attr:`~Pyjo_MCP_Client.headers`.
|
|
34
|
+
|
|
35
|
+
See also
|
|
36
|
+
--------
|
|
37
|
+
|
|
38
|
+
:mod:`Pyjo.MCP.Server`, :mod:`Pyjo.MCP.Tool`, :mod:`Pyjo.UserAgent`,
|
|
39
|
+
:mod:`Pyjo.MCP`.
|
|
40
|
+
|
|
41
|
+
Classes
|
|
42
|
+
-------
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
import Pyjo.EventEmitter
|
|
46
|
+
import Pyjo.Exception
|
|
47
|
+
import Pyjo.JSON
|
|
48
|
+
import Pyjo.JSON.RPC
|
|
49
|
+
import Pyjo.Promise
|
|
50
|
+
import Pyjo.SSE
|
|
51
|
+
import Pyjo.Transaction.HTTP
|
|
52
|
+
import Pyjo.UserAgent
|
|
53
|
+
|
|
54
|
+
# The package of the distribution, imported relatively, as pyright takes the regular package Pyjo of Pyjoyment, which
|
|
55
|
+
# hides the namespace that extend_path keeps open
|
|
56
|
+
from .. import MCP
|
|
57
|
+
from . import Tool
|
|
58
|
+
|
|
59
|
+
import base64
|
|
60
|
+
import builtins
|
|
61
|
+
import re
|
|
62
|
+
import weakref
|
|
63
|
+
|
|
64
|
+
from collections.abc import Callable
|
|
65
|
+
from typing import Any, cast
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
type Anything = builtins.object
|
|
69
|
+
|
|
70
|
+
# The members of params that the header Mcp-Name has, for each method
|
|
71
|
+
_NAMES = {'tools/call': 'name', 'resources/read': 'uri', 'prompts/get': 'name'}
|
|
72
|
+
|
|
73
|
+
# A value of a header that is plain, without the spaces at its ends that HTTP drops, and one that looks encoded
|
|
74
|
+
_BASE64 = re.compile(r'=\?base64\?.*\?=', re.S)
|
|
75
|
+
_PLAIN = re.compile(r'[\x21-\x7e](?:[\x20-\x7e]*[\x21-\x7e])?')
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Pyjo_MCP_Client(Pyjo.EventEmitter.object):
|
|
79
|
+
"""
|
|
80
|
+
:mod:`Pyjo.MCP.Client` inherits all events and methods from
|
|
81
|
+
:mod:`Pyjo.EventEmitter` and implements the following new ones.
|
|
82
|
+
|
|
83
|
+
Events
|
|
84
|
+
------
|
|
85
|
+
|
|
86
|
+
``invalid_tool`` ::
|
|
87
|
+
|
|
88
|
+
@client.on('invalid_tool')
|
|
89
|
+
def invalid_tool(client, definition, e):
|
|
90
|
+
...
|
|
91
|
+
|
|
92
|
+
Emitted for a tool of ``tools/list`` with an ``x-mcp-header`` that the
|
|
93
|
+
specification does not allow, which :meth:`list_tools` leaves out, with the
|
|
94
|
+
definition of the tool and the :mod:`Pyjo.Exception` of
|
|
95
|
+
:mod:`Pyjo.MCP.Tool`. ::
|
|
96
|
+
|
|
97
|
+
client.on('invalid_tool', lambda client, definition, e: print(f'{definition["name"]}: {e.message}'))
|
|
98
|
+
|
|
99
|
+
``notification`` ::
|
|
100
|
+
|
|
101
|
+
@client.on('notification')
|
|
102
|
+
def notification(client, message):
|
|
103
|
+
...
|
|
104
|
+
|
|
105
|
+
Emitted for every message of the stream of events of a response that is
|
|
106
|
+
not the response itself, as a notification of the progress of a tool. ::
|
|
107
|
+
|
|
108
|
+
client.on('notification', lambda client, message: print(message['method']))
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
url: str = 'http://localhost:3000/mcp',
|
|
114
|
+
ua: Pyjo.UserAgent.object | None = None,
|
|
115
|
+
name: str = 'Pyjo.MCP',
|
|
116
|
+
version: str = '1.0.0',
|
|
117
|
+
) -> None:
|
|
118
|
+
"""::
|
|
119
|
+
|
|
120
|
+
client = Pyjo.MCP.Client.new()
|
|
121
|
+
client = Pyjo.MCP.Client.new('http://127.0.0.1:3000/mcp', name='calculator-client', version='2.0.0')
|
|
122
|
+
client = Pyjo.MCP.Client.new('/mcp', ua=t.ua)
|
|
123
|
+
|
|
124
|
+
Construct a new :mod:`Pyjo.MCP.Client` object.
|
|
125
|
+
"""
|
|
126
|
+
super().__init__()
|
|
127
|
+
|
|
128
|
+
self.capabilities: dict[str, Any] = {}
|
|
129
|
+
"""::
|
|
130
|
+
|
|
131
|
+
capabilities = client.capabilities
|
|
132
|
+
client.capabilities = {'elicitation': {}}
|
|
133
|
+
|
|
134
|
+
The capabilities that every request declares, an empty :class:`dict`
|
|
135
|
+
by default.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
self.headers: dict[str, Any] = {}
|
|
139
|
+
"""::
|
|
140
|
+
|
|
141
|
+
headers = client.headers
|
|
142
|
+
client.headers = {'Authorization': 'Bearer abc123'}
|
|
143
|
+
|
|
144
|
+
The headers of HTTP of every request, as the ``Authorization`` of a
|
|
145
|
+
server that requires a bearer token, an empty :class:`dict` by default.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
self.name = name
|
|
149
|
+
"""::
|
|
150
|
+
|
|
151
|
+
name = client.name
|
|
152
|
+
client.name = 'calculator-client'
|
|
153
|
+
|
|
154
|
+
The name of the client, ``Pyjo.MCP`` by default.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
self.protocol_version = MCP.PROTOCOL_VERSION
|
|
158
|
+
"""::
|
|
159
|
+
|
|
160
|
+
version = client.protocol_version
|
|
161
|
+
client.protocol_version = '2026-07-28'
|
|
162
|
+
|
|
163
|
+
The revision of the specification of the requests,
|
|
164
|
+
:data:`Pyjo.MCP.PROTOCOL_VERSION` by default. A server that rejects it
|
|
165
|
+
with the error ``-32022`` gets the request once more, of the newest
|
|
166
|
+
revision that both support, which the client keeps for the next
|
|
167
|
+
requests.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
self.tools: dict[str, Tool.object] = {}
|
|
171
|
+
"""::
|
|
172
|
+
|
|
173
|
+
tools = client.tools
|
|
174
|
+
|
|
175
|
+
The :mod:`Pyjo.MCP.Tool` objects of the tools of the last
|
|
176
|
+
:meth:`list_tools`, by their names, whose ``x-mcp-header`` give the
|
|
177
|
+
headers ``Mcp-Param-{name}`` of their calls.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
self.url = url
|
|
181
|
+
"""::
|
|
182
|
+
|
|
183
|
+
url = client.url
|
|
184
|
+
client.url = 'http://127.0.0.1:3000/mcp'
|
|
185
|
+
|
|
186
|
+
The URL of the endpoint of MCP, ``http://localhost:3000/mcp`` by
|
|
187
|
+
default.
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
self.version = version
|
|
191
|
+
"""::
|
|
192
|
+
|
|
193
|
+
version = client.version
|
|
194
|
+
client.version = '2.0.0'
|
|
195
|
+
|
|
196
|
+
The version of the client, ``1.0.0`` by default.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
self._id = 0
|
|
200
|
+
self._ua = ua
|
|
201
|
+
# The response of a stream of events, which the parser of the content takes from the body
|
|
202
|
+
self._responses: weakref.WeakKeyDictionary[Pyjo.Transaction.HTTP.object, dict[str, Any]] = (
|
|
203
|
+
weakref.WeakKeyDictionary()
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def ua(self) -> Pyjo.UserAgent.object:
|
|
208
|
+
"""::
|
|
209
|
+
|
|
210
|
+
ua = client.ua
|
|
211
|
+
client.ua = Pyjo.UserAgent.new()
|
|
212
|
+
|
|
213
|
+
The :mod:`Pyjo.UserAgent` of the requests, a new one by default.
|
|
214
|
+
"""
|
|
215
|
+
if self._ua is None:
|
|
216
|
+
self._ua = Pyjo.UserAgent.new()
|
|
217
|
+
return self._ua
|
|
218
|
+
|
|
219
|
+
@ua.setter
|
|
220
|
+
def ua(self, value: Pyjo.UserAgent.object) -> None:
|
|
221
|
+
self._ua = value
|
|
222
|
+
|
|
223
|
+
def build_notification(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
224
|
+
"""::
|
|
225
|
+
|
|
226
|
+
notification = client.build_notification('notifications/cancelled', {'requestId': 1})
|
|
227
|
+
|
|
228
|
+
Build a notification of JSON-RPC with the method and its parameters.
|
|
229
|
+
"""
|
|
230
|
+
return {'jsonrpc': '2.0', 'method': method, 'params': params or {}}
|
|
231
|
+
|
|
232
|
+
def build_request(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
233
|
+
"""::
|
|
234
|
+
|
|
235
|
+
request = client.build_request('tools/call', {'name': 'add', 'arguments': {'a': 1, 'b': 2}})
|
|
236
|
+
|
|
237
|
+
Build a request of JSON-RPC with the method and its parameters, the
|
|
238
|
+
next ``id`` of the client, and the ``_meta`` that every request
|
|
239
|
+
carries, over the ``_meta`` of the parameters.
|
|
240
|
+
"""
|
|
241
|
+
params = params or {}
|
|
242
|
+
meta = {
|
|
243
|
+
**cast(dict[str, Any], params.get('_meta') or {}),
|
|
244
|
+
MCP.META_CLIENT_CAPABILITIES: self.capabilities,
|
|
245
|
+
MCP.META_CLIENT_INFO: {'name': self.name, 'version': self.version},
|
|
246
|
+
MCP.META_PROTOCOL_VERSION: self.protocol_version,
|
|
247
|
+
}
|
|
248
|
+
self._id += 1
|
|
249
|
+
return {**self.build_notification(method, {**params, '_meta': meta}), 'id': self._id}
|
|
250
|
+
|
|
251
|
+
def build_tx(self, request: dict[str, Any]) -> Pyjo.Transaction.HTTP.object:
|
|
252
|
+
"""::
|
|
253
|
+
|
|
254
|
+
tx = client.build_tx(request)
|
|
255
|
+
|
|
256
|
+
Build the :mod:`Pyjo.Transaction.HTTP` of a request or a notification,
|
|
257
|
+
a POST to :attr:`url` with the headers of MCP made from the body, which
|
|
258
|
+
reads a response in a stream of events too, as :meth:`send_request`
|
|
259
|
+
does, so that a test sends what the client sends. ::
|
|
260
|
+
|
|
261
|
+
# The request without its header Mcp-Name
|
|
262
|
+
tx = client.build_tx(client.build_request('tools/call', {'name': 'add', 'arguments': {}}))
|
|
263
|
+
tx.req.headers.remove('Mcp-Name')
|
|
264
|
+
t.request_ok(tx).status_is(400)
|
|
265
|
+
"""
|
|
266
|
+
tx = self.ua.build_tx('POST', self.url, self._headers(request), json=request)
|
|
267
|
+
id = request.get('id')
|
|
268
|
+
|
|
269
|
+
def sse(content: Any, event: Any = None) -> None:
|
|
270
|
+
if event and (message := _message(event)) is not None:
|
|
271
|
+
if _is_response(message, id):
|
|
272
|
+
self._responses[tx] = message
|
|
273
|
+
else:
|
|
274
|
+
self.emit('notification', message)
|
|
275
|
+
|
|
276
|
+
tx.res.content.on('sse', sse)
|
|
277
|
+
return tx
|
|
278
|
+
|
|
279
|
+
def call_tool(
|
|
280
|
+
self,
|
|
281
|
+
name: str,
|
|
282
|
+
arguments: dict[str, Any] | None = None,
|
|
283
|
+
/,
|
|
284
|
+
*,
|
|
285
|
+
input_responses: dict[str, Any] | None = None,
|
|
286
|
+
request_state: str | None = None,
|
|
287
|
+
) -> dict[str, Any]:
|
|
288
|
+
"""::
|
|
289
|
+
|
|
290
|
+
result = client.call_tool('greet')
|
|
291
|
+
result = client.call_tool('greet', {'name': 'Pyjo'})
|
|
292
|
+
result = client.call_tool('delete', {'path': 'a.txt'}, input_responses=responses, request_state=state)
|
|
293
|
+
|
|
294
|
+
Call a tool with its arguments and return its result, or raise the
|
|
295
|
+
:class:`Pyjo.JSON.RPC.Error` of the response. A result with
|
|
296
|
+
``isError`` is a result, of an error of the execution of the tool. ::
|
|
297
|
+
|
|
298
|
+
result = client.call_tool('divide', {'a': 1, 'b': 0})
|
|
299
|
+
if result['isError']:
|
|
300
|
+
print(result['content'][0]['text'])
|
|
301
|
+
|
|
302
|
+
A result whose ``resultType`` is ``input_required`` asks for input
|
|
303
|
+
first, with the requests of its ``inputRequests``, which the client
|
|
304
|
+
answers, and the same call again with the responses, by the keys of
|
|
305
|
+
the requests, as ``input_responses``, and the ``requestState`` of the
|
|
306
|
+
result as ``request_state``, is the next round of the request. ::
|
|
307
|
+
|
|
308
|
+
result = client.call_tool('delete', {'path': 'a.txt'})
|
|
309
|
+
while result['resultType'] == 'input_required':
|
|
310
|
+
responses = {key: {'action': 'accept'} for key in result['inputRequests']}
|
|
311
|
+
state = result.get('requestState')
|
|
312
|
+
result = client.call_tool('delete', {'path': 'a.txt'}, input_responses=responses, request_state=state)
|
|
313
|
+
"""
|
|
314
|
+
return _result(self.send_request(self._call_tool(name, arguments, input_responses, request_state)))
|
|
315
|
+
|
|
316
|
+
def call_tool_p(
|
|
317
|
+
self,
|
|
318
|
+
name: str,
|
|
319
|
+
arguments: dict[str, Any] | None = None,
|
|
320
|
+
/,
|
|
321
|
+
*,
|
|
322
|
+
input_responses: dict[str, Any] | None = None,
|
|
323
|
+
request_state: str | None = None,
|
|
324
|
+
) -> Pyjo.Promise.object:
|
|
325
|
+
"""::
|
|
326
|
+
|
|
327
|
+
promise = client.call_tool_p('greet', {'name': 'Pyjo'})
|
|
328
|
+
|
|
329
|
+
Same as :meth:`call_tool`, but returns a :mod:`Pyjo.Promise` of the
|
|
330
|
+
result instead of blocking.
|
|
331
|
+
"""
|
|
332
|
+
return self.send_request_p(self._call_tool(name, arguments, input_responses, request_state)).then(_result)
|
|
333
|
+
|
|
334
|
+
def complete(
|
|
335
|
+
self, ref: dict[str, Any], argument: str, value: str, arguments: dict[str, str] | None = None
|
|
336
|
+
) -> dict[str, Any]:
|
|
337
|
+
"""::
|
|
338
|
+
|
|
339
|
+
result = client.complete({'type': 'ref/prompt', 'name': 'review'}, 'language', 'Py')
|
|
340
|
+
result = client.complete({'type': 'ref/resource', 'uri': 'repo://{owner}/{repo}'}, 'repo', 'Py', {'owner': 'dex4er'})
|
|
341
|
+
|
|
342
|
+
Complete the value of an argument of a prompt, or of a variable of a
|
|
343
|
+
resource template, with ``completion/complete``, with the values of the
|
|
344
|
+
other arguments, and return its result, or raise the
|
|
345
|
+
:class:`Pyjo.JSON.RPC.Error` of the response. ::
|
|
346
|
+
|
|
347
|
+
print(client.complete({'type': 'ref/prompt', 'name': 'review'}, 'language', 'P')['completion']['values'])
|
|
348
|
+
"""
|
|
349
|
+
return _result(self.send_request(self._complete(ref, argument, value, arguments)))
|
|
350
|
+
|
|
351
|
+
def complete_p(
|
|
352
|
+
self, ref: dict[str, Any], argument: str, value: str, arguments: dict[str, str] | None = None
|
|
353
|
+
) -> Pyjo.Promise.object:
|
|
354
|
+
"""::
|
|
355
|
+
|
|
356
|
+
promise = client.complete_p({'type': 'ref/prompt', 'name': 'review'}, 'language', 'Py')
|
|
357
|
+
|
|
358
|
+
Same as :meth:`complete`, but returns a :mod:`Pyjo.Promise` of the
|
|
359
|
+
result instead of blocking.
|
|
360
|
+
"""
|
|
361
|
+
return self.send_request_p(self._complete(ref, argument, value, arguments)).then(_result)
|
|
362
|
+
|
|
363
|
+
def discover(self) -> dict[str, Any]:
|
|
364
|
+
"""::
|
|
365
|
+
|
|
366
|
+
result = client.discover()
|
|
367
|
+
|
|
368
|
+
Discover the revisions, the capabilities and the instructions of the
|
|
369
|
+
server with ``server/discover``.
|
|
370
|
+
"""
|
|
371
|
+
return _result(self.send_request(self.build_request('server/discover')))
|
|
372
|
+
|
|
373
|
+
def discover_p(self) -> Pyjo.Promise.object:
|
|
374
|
+
"""::
|
|
375
|
+
|
|
376
|
+
promise = client.discover_p()
|
|
377
|
+
|
|
378
|
+
Same as :meth:`discover`, but returns a :mod:`Pyjo.Promise` of the
|
|
379
|
+
result instead of blocking.
|
|
380
|
+
"""
|
|
381
|
+
return self.send_request_p(self.build_request('server/discover')).then(_result)
|
|
382
|
+
|
|
383
|
+
def get_prompt(
|
|
384
|
+
self,
|
|
385
|
+
name: str,
|
|
386
|
+
arguments: dict[str, Any] | None = None,
|
|
387
|
+
/,
|
|
388
|
+
*,
|
|
389
|
+
input_responses: dict[str, Any] | None = None,
|
|
390
|
+
request_state: str | None = None,
|
|
391
|
+
) -> dict[str, Any]:
|
|
392
|
+
"""::
|
|
393
|
+
|
|
394
|
+
result = client.get_prompt('summary')
|
|
395
|
+
result = client.get_prompt('review', {'code': 'x = 1'})
|
|
396
|
+
result = client.get_prompt('review', {'code': 'x = 1'}, input_responses=responses, request_state=state)
|
|
397
|
+
|
|
398
|
+
Get the messages of a prompt with its arguments, whose values are
|
|
399
|
+
strings, and return its result, or raise the
|
|
400
|
+
:class:`Pyjo.JSON.RPC.Error` of the response, with the input of the
|
|
401
|
+
rounds of a request as :meth:`call_tool` has it. ::
|
|
402
|
+
|
|
403
|
+
for message in client.get_prompt('review', {'code': 'x = 1'})['messages']:
|
|
404
|
+
print(message['role'], message['content'].get('text'))
|
|
405
|
+
"""
|
|
406
|
+
return _result(self.send_request(self._get_prompt(name, arguments, input_responses, request_state)))
|
|
407
|
+
|
|
408
|
+
def get_prompt_p(
|
|
409
|
+
self,
|
|
410
|
+
name: str,
|
|
411
|
+
arguments: dict[str, Any] | None = None,
|
|
412
|
+
/,
|
|
413
|
+
*,
|
|
414
|
+
input_responses: dict[str, Any] | None = None,
|
|
415
|
+
request_state: str | None = None,
|
|
416
|
+
) -> Pyjo.Promise.object:
|
|
417
|
+
"""::
|
|
418
|
+
|
|
419
|
+
promise = client.get_prompt_p('review', {'code': 'x = 1'})
|
|
420
|
+
|
|
421
|
+
Same as :meth:`get_prompt`, but returns a :mod:`Pyjo.Promise` of the
|
|
422
|
+
result instead of blocking.
|
|
423
|
+
"""
|
|
424
|
+
return self.send_request_p(self._get_prompt(name, arguments, input_responses, request_state)).then(_result)
|
|
425
|
+
|
|
426
|
+
def list_prompts(self) -> dict[str, Any]:
|
|
427
|
+
"""::
|
|
428
|
+
|
|
429
|
+
result = client.list_prompts()
|
|
430
|
+
|
|
431
|
+
List the prompts of the server with ``prompts/list``. ::
|
|
432
|
+
|
|
433
|
+
for prompt in client.list_prompts()['prompts']:
|
|
434
|
+
print(prompt['name'], [argument['name'] for argument in prompt.get('arguments', [])])
|
|
435
|
+
"""
|
|
436
|
+
return _result(self.send_request(self.build_request('prompts/list')))
|
|
437
|
+
|
|
438
|
+
def list_prompts_p(self) -> Pyjo.Promise.object:
|
|
439
|
+
"""::
|
|
440
|
+
|
|
441
|
+
promise = client.list_prompts_p()
|
|
442
|
+
|
|
443
|
+
Same as :meth:`list_prompts`, but returns a :mod:`Pyjo.Promise` of the
|
|
444
|
+
result instead of blocking.
|
|
445
|
+
"""
|
|
446
|
+
return self.send_request_p(self.build_request('prompts/list')).then(_result)
|
|
447
|
+
|
|
448
|
+
def list_resources(self) -> dict[str, Any]:
|
|
449
|
+
"""::
|
|
450
|
+
|
|
451
|
+
result = client.list_resources()
|
|
452
|
+
|
|
453
|
+
List the resources of the server with ``resources/list``. ::
|
|
454
|
+
|
|
455
|
+
for resource in client.list_resources()['resources']:
|
|
456
|
+
print(resource['uri'], resource.get('mimeType'))
|
|
457
|
+
"""
|
|
458
|
+
return _result(self.send_request(self.build_request('resources/list')))
|
|
459
|
+
|
|
460
|
+
def list_resources_p(self) -> Pyjo.Promise.object:
|
|
461
|
+
"""::
|
|
462
|
+
|
|
463
|
+
promise = client.list_resources_p()
|
|
464
|
+
|
|
465
|
+
Same as :meth:`list_resources`, but returns a :mod:`Pyjo.Promise` of
|
|
466
|
+
the result instead of blocking.
|
|
467
|
+
"""
|
|
468
|
+
return self.send_request_p(self.build_request('resources/list')).then(_result)
|
|
469
|
+
|
|
470
|
+
def list_resource_templates(self) -> dict[str, Any]:
|
|
471
|
+
"""::
|
|
472
|
+
|
|
473
|
+
result = client.list_resource_templates()
|
|
474
|
+
|
|
475
|
+
List the resource templates of the server with
|
|
476
|
+
``resources/templates/list``, whose URIs :meth:`read_resource` reads. ::
|
|
477
|
+
|
|
478
|
+
for template in client.list_resource_templates()['resourceTemplates']:
|
|
479
|
+
print(template['uriTemplate'], template.get('mimeType'))
|
|
480
|
+
"""
|
|
481
|
+
return _result(self.send_request(self.build_request('resources/templates/list')))
|
|
482
|
+
|
|
483
|
+
def list_resource_templates_p(self) -> Pyjo.Promise.object:
|
|
484
|
+
"""::
|
|
485
|
+
|
|
486
|
+
promise = client.list_resource_templates_p()
|
|
487
|
+
|
|
488
|
+
Same as :meth:`list_resource_templates`, but returns a
|
|
489
|
+
:mod:`Pyjo.Promise` of the result instead of blocking.
|
|
490
|
+
"""
|
|
491
|
+
return self.send_request_p(self.build_request('resources/templates/list')).then(_result)
|
|
492
|
+
|
|
493
|
+
def list_tools(self) -> dict[str, Any]:
|
|
494
|
+
"""::
|
|
495
|
+
|
|
496
|
+
result = client.list_tools()
|
|
497
|
+
|
|
498
|
+
List the tools of the server with ``tools/list``, and keep them in
|
|
499
|
+
:attr:`tools`, which the calls take the headers of their arguments
|
|
500
|
+
from. A tool whose ``x-mcp-header`` the specification does not allow
|
|
501
|
+
is left out of the result and of :attr:`tools`, as the specification
|
|
502
|
+
requires, with the event ``invalid_tool``. ::
|
|
503
|
+
|
|
504
|
+
for tool in client.list_tools()['tools']:
|
|
505
|
+
print(tool['name'], tool.get('description'))
|
|
506
|
+
"""
|
|
507
|
+
return _result(self.send_request(self.build_request('tools/list')))
|
|
508
|
+
|
|
509
|
+
def list_tools_p(self) -> Pyjo.Promise.object:
|
|
510
|
+
"""::
|
|
511
|
+
|
|
512
|
+
promise = client.list_tools_p()
|
|
513
|
+
|
|
514
|
+
Same as :meth:`list_tools`, but returns a :mod:`Pyjo.Promise` of the
|
|
515
|
+
result instead of blocking.
|
|
516
|
+
"""
|
|
517
|
+
return self.send_request_p(self.build_request('tools/list')).then(_result)
|
|
518
|
+
|
|
519
|
+
def listen(self, notifications: dict[str, Any], cb: Callable[[dict[str, Any]], Any]) -> dict[str, Any] | None:
|
|
520
|
+
"""::
|
|
521
|
+
|
|
522
|
+
result = client.listen({'toolsListChanged': True}, lambda message: print(message['method']))
|
|
523
|
+
result = client.listen({'resourceSubscriptions': ['file:///project/README.md']}, cb)
|
|
524
|
+
|
|
525
|
+
Open a subscription with ``subscriptions/listen``, for the
|
|
526
|
+
notifications that the filter asks for, ``toolsListChanged``,
|
|
527
|
+
``promptsListChanged``, ``resourcesListChanged`` and
|
|
528
|
+
``resourceSubscriptions``, and block until it ends. The function gets
|
|
529
|
+
every message of the subscription, starting with
|
|
530
|
+
``notifications/subscriptions/acknowledged``, whose ``notifications``
|
|
531
|
+
are the ones that the server sends, and closes the subscription when it
|
|
532
|
+
returns ``False``. Return the result of the response with which the
|
|
533
|
+
server ends the subscription, or ``None`` for one that the function
|
|
534
|
+
closed, or raise the :class:`Pyjo.JSON.RPC.Error` of the response, or
|
|
535
|
+
a :mod:`Pyjo.Exception` for a connection that closes without one. ::
|
|
536
|
+
|
|
537
|
+
# Until the third change of the resource
|
|
538
|
+
def updated(message):
|
|
539
|
+
if message['method'] == 'notifications/resources/updated':
|
|
540
|
+
updated.count += 1
|
|
541
|
+
print(f'Changed: {message["params"]["uri"]}')
|
|
542
|
+
return updated.count < 3
|
|
543
|
+
|
|
544
|
+
updated.count = 0
|
|
545
|
+
client.listen({'resourceSubscriptions': ['file:///project/README.md']}, updated)
|
|
546
|
+
print(client.read_resource('file:///project/README.md')['contents'][0]['text'])
|
|
547
|
+
"""
|
|
548
|
+
tx, finished = self._listen(notifications, cb)
|
|
549
|
+
return finished(self.ua.start(tx))
|
|
550
|
+
|
|
551
|
+
def listen_p(self, notifications: dict[str, Any], cb: Callable[[dict[str, Any]], Any]) -> Pyjo.Promise.object:
|
|
552
|
+
"""::
|
|
553
|
+
|
|
554
|
+
promise = client.listen_p({'toolsListChanged': True}, lambda message: print(message['method']))
|
|
555
|
+
|
|
556
|
+
Same as :meth:`listen`, but returns a :mod:`Pyjo.Promise` of the
|
|
557
|
+
result instead of blocking.
|
|
558
|
+
"""
|
|
559
|
+
tx, finished = self._listen(notifications, cb)
|
|
560
|
+
|
|
561
|
+
def failed(e: Any) -> Any:
|
|
562
|
+
return finished(tx)
|
|
563
|
+
|
|
564
|
+
return self.ua.start_p(tx).then(finished, failed)
|
|
565
|
+
|
|
566
|
+
def read_resource(
|
|
567
|
+
self, uri: str, *, input_responses: dict[str, Any] | None = None, request_state: str | None = None
|
|
568
|
+
) -> dict[str, Any]:
|
|
569
|
+
"""::
|
|
570
|
+
|
|
571
|
+
result = client.read_resource('file:///project/README.md')
|
|
572
|
+
result = client.read_resource('file:///project/README.md', input_responses=responses, request_state=state)
|
|
573
|
+
|
|
574
|
+
Read the contents of a resource with ``resources/read`` and return its
|
|
575
|
+
result, or raise the :class:`Pyjo.JSON.RPC.Error` of the response, as
|
|
576
|
+
the one of a resource that does not exist, with the input of the
|
|
577
|
+
rounds of a request as :meth:`call_tool` has it. ::
|
|
578
|
+
|
|
579
|
+
for contents in client.read_resource('file:///project/README.md')['contents']:
|
|
580
|
+
print(contents.get('text'))
|
|
581
|
+
"""
|
|
582
|
+
return _result(self.send_request(self._read_resource(uri, input_responses, request_state)))
|
|
583
|
+
|
|
584
|
+
def read_resource_p(
|
|
585
|
+
self, uri: str, *, input_responses: dict[str, Any] | None = None, request_state: str | None = None
|
|
586
|
+
) -> Pyjo.Promise.object:
|
|
587
|
+
"""::
|
|
588
|
+
|
|
589
|
+
promise = client.read_resource_p('file:///project/README.md')
|
|
590
|
+
|
|
591
|
+
Same as :meth:`read_resource`, but returns a :mod:`Pyjo.Promise` of
|
|
592
|
+
the result instead of blocking.
|
|
593
|
+
"""
|
|
594
|
+
return self.send_request_p(self._read_resource(uri, input_responses, request_state)).then(_result)
|
|
595
|
+
|
|
596
|
+
def read_response(self, tx: Pyjo.Transaction.HTTP.object, request: dict[str, Any]) -> dict[str, Any] | None:
|
|
597
|
+
"""::
|
|
598
|
+
|
|
599
|
+
response = client.read_response(tx, request)
|
|
600
|
+
|
|
601
|
+
Read the response of a request from its transaction of
|
|
602
|
+
:meth:`build_tx`, once the transaction is done, as
|
|
603
|
+
:meth:`send_request` does: from JSON or from a stream of events, and
|
|
604
|
+
with the tools of a result of ``tools/list`` kept in :attr:`tools`,
|
|
605
|
+
without the ones of the event ``invalid_tool``. ::
|
|
606
|
+
|
|
607
|
+
request = client.build_request('tools/list')
|
|
608
|
+
tx = client.ua.start(client.build_tx(request))
|
|
609
|
+
print(client.read_response(tx, request)['result']['tools'])
|
|
610
|
+
"""
|
|
611
|
+
response = self._read_response(tx, request)
|
|
612
|
+
if request.get('method') == 'tools/list' and isinstance(result := _object(response).get('result'), dict):
|
|
613
|
+
response = {**_object(response), 'result': self._tools(cast(dict[str, Any], result))}
|
|
614
|
+
return response
|
|
615
|
+
|
|
616
|
+
def send_request(self, request: dict[str, Any]) -> dict[str, Any] | None:
|
|
617
|
+
"""::
|
|
618
|
+
|
|
619
|
+
response = client.send_request(request)
|
|
620
|
+
|
|
621
|
+
Send a request or a notification and return the response, or ``None``
|
|
622
|
+
for one that the server accepted without a response, ``202
|
|
623
|
+
Accepted``. A response of HTTP without one of JSON-RPC fails with a
|
|
624
|
+
:mod:`Pyjo.Exception`, with the text of an error in plain text, as a
|
|
625
|
+
server of an older revision tells the revisions that it supports. ::
|
|
626
|
+
|
|
627
|
+
response = client.send_request(client.build_request('tools/list'))
|
|
628
|
+
print(response['result']['tools'])
|
|
629
|
+
"""
|
|
630
|
+
response = self.read_response(self.ua.start(self.build_tx(request)), request)
|
|
631
|
+
if self._renegotiate(request, response):
|
|
632
|
+
response = self.read_response(self.ua.start(self.build_tx(request)), request)
|
|
633
|
+
return response
|
|
634
|
+
|
|
635
|
+
def send_request_p(self, request: dict[str, Any]) -> Pyjo.Promise.object:
|
|
636
|
+
"""::
|
|
637
|
+
|
|
638
|
+
promise = client.send_request_p(request)
|
|
639
|
+
|
|
640
|
+
Same as :meth:`send_request`, but returns a :mod:`Pyjo.Promise` of the
|
|
641
|
+
response instead of blocking.
|
|
642
|
+
"""
|
|
643
|
+
|
|
644
|
+
def response(tx: Pyjo.Transaction.HTTP.object) -> dict[str, Any] | None:
|
|
645
|
+
return self.read_response(tx, request)
|
|
646
|
+
|
|
647
|
+
def start() -> Pyjo.Promise.object:
|
|
648
|
+
return self.ua.start_p(self.build_tx(request)).then(response)
|
|
649
|
+
|
|
650
|
+
def renegotiate(response: Any = None) -> Any:
|
|
651
|
+
return start() if self._renegotiate(request, response) else response
|
|
652
|
+
|
|
653
|
+
return start().then(renegotiate)
|
|
654
|
+
|
|
655
|
+
def _call_tool(
|
|
656
|
+
self, name: str, arguments: dict[str, Any] | None, responses: dict[str, Any] | None, state: str | None
|
|
657
|
+
) -> dict[str, Any]:
|
|
658
|
+
return self.build_request('tools/call', _input({'name': name, 'arguments': arguments or {}}, responses, state))
|
|
659
|
+
|
|
660
|
+
def _complete(
|
|
661
|
+
self, ref: dict[str, Any], argument: str, value: str, arguments: dict[str, str] | None
|
|
662
|
+
) -> dict[str, Any]:
|
|
663
|
+
params: dict[str, Any] = {'ref': ref, 'argument': {'name': argument, 'value': value}}
|
|
664
|
+
if arguments is not None:
|
|
665
|
+
params['context'] = {'arguments': arguments}
|
|
666
|
+
return self.build_request('completion/complete', params)
|
|
667
|
+
|
|
668
|
+
def _get_prompt(
|
|
669
|
+
self, name: str, arguments: dict[str, Any] | None, responses: dict[str, Any] | None, state: str | None
|
|
670
|
+
) -> dict[str, Any]:
|
|
671
|
+
return self.build_request('prompts/get', _input({'name': name, 'arguments': arguments or {}}, responses, state))
|
|
672
|
+
|
|
673
|
+
def _headers(self, request: dict[str, Any]) -> dict[str, Any]:
|
|
674
|
+
# The headers of MCP, which mirror the body, as the server checks them
|
|
675
|
+
method = request.get('method')
|
|
676
|
+
params = _object(request.get('params'))
|
|
677
|
+
meta = _object(params.get('_meta'))
|
|
678
|
+
headers = {
|
|
679
|
+
**self.headers,
|
|
680
|
+
'Accept': 'application/json, text/event-stream',
|
|
681
|
+
'MCP-Protocol-Version': meta.get(MCP.META_PROTOCOL_VERSION, self.protocol_version),
|
|
682
|
+
'Mcp-Method': method,
|
|
683
|
+
}
|
|
684
|
+
if isinstance(method, str) and (key := _NAMES.get(method)) is not None:
|
|
685
|
+
headers['Mcp-Name'] = _encode(params.get(key, ''))
|
|
686
|
+
if method == 'tools/call' and (tool := self.tools.get(params.get('name', ''))) is not None:
|
|
687
|
+
for name, path, _ in tool.header_params:
|
|
688
|
+
if (value := _value(params.get('arguments'), path)) is not None:
|
|
689
|
+
headers[f'Mcp-Param-{name}'] = _encode(value)
|
|
690
|
+
return headers
|
|
691
|
+
|
|
692
|
+
def _listen(
|
|
693
|
+
self, notifications: dict[str, Any], cb: Callable[[dict[str, Any]], Any]
|
|
694
|
+
) -> tuple[Pyjo.Transaction.HTTP.object, Callable[..., dict[str, Any] | None]]:
|
|
695
|
+
# The transaction of a subscription, and the result of the transaction when it is done
|
|
696
|
+
request = self.build_request('subscriptions/listen', {'notifications': notifications})
|
|
697
|
+
tx = self.build_tx(request)
|
|
698
|
+
closed = False
|
|
699
|
+
|
|
700
|
+
# The messages of the subscription, which the function closes, but its response
|
|
701
|
+
def sse(content: Any, event: Any = None) -> None:
|
|
702
|
+
nonlocal closed
|
|
703
|
+
if closed or not event or (message := _message(event)) is None or _is_response(message, request['id']):
|
|
704
|
+
return
|
|
705
|
+
if cb(message) is False:
|
|
706
|
+
closed = True
|
|
707
|
+
tx.res.error = {'message': 'Interrupted'}
|
|
708
|
+
|
|
709
|
+
tx.res.content.on('sse', sse)
|
|
710
|
+
|
|
711
|
+
def finished(tx: Pyjo.Transaction.HTTP.object) -> dict[str, Any] | None:
|
|
712
|
+
return None if closed else _result(self.read_response(tx, request))
|
|
713
|
+
|
|
714
|
+
return tx, finished
|
|
715
|
+
|
|
716
|
+
def _read_resource(self, uri: str, responses: dict[str, Any] | None, state: str | None) -> dict[str, Any]:
|
|
717
|
+
return self.build_request('resources/read', _input({'uri': uri}, responses, state))
|
|
718
|
+
|
|
719
|
+
def _renegotiate(self, request: dict[str, Any], response: Any) -> bool:
|
|
720
|
+
# The newest revision that both support, for one more request, after the error of an unsupported one
|
|
721
|
+
error = _object(_object(response).get('error'))
|
|
722
|
+
if error.get('code') != MCP.UNSUPPORTED_PROTOCOL_VERSION:
|
|
723
|
+
return False
|
|
724
|
+
supported = _object(error.get('data')).get('supported')
|
|
725
|
+
supported = cast(list[Any], supported) if isinstance(supported, list) else []
|
|
726
|
+
version = next((version for version in MCP.SUPPORTED_VERSIONS if version in supported), None)
|
|
727
|
+
if version is None or version == self.protocol_version:
|
|
728
|
+
return False
|
|
729
|
+
self.protocol_version = version
|
|
730
|
+
_object(_object(request.get('params')).get('_meta'))[MCP.META_PROTOCOL_VERSION] = version
|
|
731
|
+
return True
|
|
732
|
+
|
|
733
|
+
def _read_response(self, tx: Pyjo.Transaction.HTTP.object, request: dict[str, Any]) -> dict[str, Any] | None:
|
|
734
|
+
# The response of a stream of events, which the parser of the content takes from the body
|
|
735
|
+
res = tx.res
|
|
736
|
+
if (response := self._responses.pop(tx, None)) is not None:
|
|
737
|
+
return response
|
|
738
|
+
if res.code == 202:
|
|
739
|
+
return None
|
|
740
|
+
|
|
741
|
+
# The events that stay in the body of a chunked stream, which the parser of the content does not parse
|
|
742
|
+
if (res.headers.content_type or '').startswith('text/event-stream'):
|
|
743
|
+
buffer = bytearray(res.body)
|
|
744
|
+
while (event := Pyjo.SSE.parse_event(buffer)) is not None:
|
|
745
|
+
if (message := _message(event)) is None:
|
|
746
|
+
continue
|
|
747
|
+
if _is_response(message, request.get('id')):
|
|
748
|
+
return message
|
|
749
|
+
self.emit('notification', message)
|
|
750
|
+
|
|
751
|
+
if isinstance(response := res.json(), dict):
|
|
752
|
+
return cast(dict[str, Any], response)
|
|
753
|
+
|
|
754
|
+
# The text of the error, as a server of an older revision of MCP tells the revisions it supports in plain text
|
|
755
|
+
error = tx.error or {}
|
|
756
|
+
message = f'{self.url}: {error.get("code") or ""} {error.get("message", "No response")}'.strip()
|
|
757
|
+
if (res.headers.content_type or '').startswith('text/plain') and (body := res.text.strip()):
|
|
758
|
+
message += f': {body}'
|
|
759
|
+
raise Pyjo.Exception.new(message)
|
|
760
|
+
|
|
761
|
+
def _tools(self, result: dict[str, Any]) -> dict[str, Any]:
|
|
762
|
+
# The tools whose x-mcp-header the specification allows, as a client excludes the others
|
|
763
|
+
def nothing(**arguments: Any) -> None:
|
|
764
|
+
return None
|
|
765
|
+
|
|
766
|
+
tools: dict[str, Tool.object] = {}
|
|
767
|
+
definitions: list[Any] = []
|
|
768
|
+
for definition in cast(list[Any], result.get('tools') or []):
|
|
769
|
+
definition_ = _object(definition)
|
|
770
|
+
try:
|
|
771
|
+
tool = Tool.new(
|
|
772
|
+
nothing,
|
|
773
|
+
name=definition_.get('name'),
|
|
774
|
+
input_schema=definition_.get('inputSchema') or {'type': 'object'},
|
|
775
|
+
)
|
|
776
|
+
except Pyjo.Exception.object as e:
|
|
777
|
+
self.emit('invalid_tool', definition, e)
|
|
778
|
+
continue
|
|
779
|
+
tools[tool.name] = tool
|
|
780
|
+
definitions.append(definition)
|
|
781
|
+
self.tools = tools
|
|
782
|
+
return {**result, 'tools': definitions}
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _encode(value: Any) -> str:
|
|
786
|
+
# A value of a header, plain ASCII, or Base64 of UTF-8 with the sentinel of MCP
|
|
787
|
+
text = value if isinstance(value, str) else Pyjo.JSON.to_json(value)
|
|
788
|
+
if _PLAIN.fullmatch(text) and not _BASE64.fullmatch(text):
|
|
789
|
+
return text
|
|
790
|
+
return f'=?base64?{base64.b64encode(text.encode()).decode()}?='
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def _input(params: dict[str, Any], responses: dict[str, Any] | None, state: str | None) -> dict[str, Any]:
|
|
794
|
+
# The parameters of a request with the input of the rounds before
|
|
795
|
+
if responses is not None:
|
|
796
|
+
params['inputResponses'] = responses
|
|
797
|
+
if state is not None:
|
|
798
|
+
params['requestState'] = state
|
|
799
|
+
return params
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _is_response(message: dict[str, Any], id: Any) -> bool:
|
|
803
|
+
return id is not None and message.get('id') == id and 'method' not in message
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _message(event: Any) -> dict[str, Any] | None:
|
|
807
|
+
# The message of JSON-RPC of an event, or None
|
|
808
|
+
try:
|
|
809
|
+
message = Pyjo.JSON.decode_json(_object(event).get('text') or '')
|
|
810
|
+
except ValueError:
|
|
811
|
+
return None
|
|
812
|
+
return cast(dict[str, Any], message) if isinstance(message, dict) else None
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _object(value: Anything) -> dict[str, Any]:
|
|
816
|
+
# An object of JSON, or an empty one for another value
|
|
817
|
+
return cast(dict[str, Any], value) if isinstance(value, dict) else {}
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def _result(response: Any) -> dict[str, Any]:
|
|
821
|
+
# The result of a response, or the error of JSON-RPC that it has
|
|
822
|
+
if response is None:
|
|
823
|
+
raise Pyjo.Exception.new('No response')
|
|
824
|
+
if 'error' in (response_ := _object(response)):
|
|
825
|
+
raise Pyjo.JSON.RPC.Error.from_json(response_['error'])
|
|
826
|
+
return _object(response_.get('result'))
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def _value(arguments: Any, path: list[str]) -> Any:
|
|
830
|
+
# The value of an argument at the path of its properties
|
|
831
|
+
for key in path:
|
|
832
|
+
if not isinstance(arguments, dict):
|
|
833
|
+
return None
|
|
834
|
+
arguments = cast(dict[str, Any], arguments).get(key)
|
|
835
|
+
return arguments
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
new = Pyjo_MCP_Client.new
|
|
839
|
+
object = Pyjo_MCP_Client
|
|
840
|
+
Client = Pyjo_MCP_Client
|