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 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