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/Test.py ADDED
@@ -0,0 +1,761 @@
1
+ """
2
+ Pyjo.MCP.Test - Testing servers of MCP
3
+ ======================================
4
+ ::
5
+
6
+ from Pyjo.File import curfile
7
+ from Pyjo.MCP.Test import Test
8
+ from Pyjo.Test import *
9
+
10
+ t = Test(curfile().dirname().sibling('calculator.py'))
11
+
12
+ t.discover_ok().status_is(200).result_is('/capabilities', {'tools': {}})
13
+ t.list_tools_ok().status_is(200).tool_exists('divide')
14
+ t.call_ok('divide', a=7, b=2).status_is(200).structured_content_is(3.5).is_error_is(False)
15
+ t.call_ok('divide', {'a': 1, 'b': 0}).is_error_is(True).text_content_is('Division by zero')
16
+ t.call_ok('power').error_code_is(-32602).error_message_is('Unknown tool: power')
17
+
18
+ # Or import the module, as in Google Python Style Guide
19
+
20
+ import Pyjo.MCP.Test
21
+
22
+ t = Pyjo.MCP.Test.new(curfile().dirname().sibling('calculator.py'))
23
+
24
+ # A request without its header Mcp-Name
25
+ params = {'name': 'add', 'arguments': {'a': 1, 'b': 2}}
26
+ t.mcp_ok('tools/call', params, headers={'Mcp-Name': None}).status_is(400)
27
+ t.error_message_is('Header mismatch: Missing Mcp-Name header')
28
+
29
+ done_testing()
30
+
31
+ :mod:`Pyjo.MCP.Test` is :mod:`Pyjo.Test.Pyjo` for the servers of the Model
32
+ Context Protocol, with the requests of :mod:`Pyjo.MCP.Client`: the envelope of
33
+ JSON-RPC, the ``_meta`` of the client and the headers of MCP made from the
34
+ body, as a client sends them, and assertions of the response of JSON-RPC,
35
+ which the client reads from JSON or from a stream of events.
36
+
37
+ The arguments of a tool go as a :class:`dict` or as keyword arguments of
38
+ :meth:`~Pyjo_MCP_Test.call_ok`, after the name of the tool, which is only
39
+ positional, so that no argument of a tool clashes with it.
40
+
41
+ See also
42
+ --------
43
+
44
+ :mod:`Pyjo.MCP.Client`, :mod:`Pyjo.MCP.Server`, :mod:`Pyjo.Test.Pyjo`,
45
+ :mod:`Pyjo.MCP`.
46
+
47
+ Classes
48
+ -------
49
+ """
50
+
51
+ import Pyjo.Exception
52
+ import Pyjo.JSON
53
+ import Pyjo.JSON.Pointer
54
+ import Pyjo.Test
55
+ import Pyjo.Test.Pyjo
56
+
57
+ # The package of the distribution, imported relatively, as pyright takes the regular package Pyjo of Pyjoyment, which
58
+ # hides the namespace that extend_path keeps open
59
+ from . import Client
60
+
61
+ from typing import Any, Self, cast
62
+
63
+
64
+ # pytest leaves the frames of this module out of the traceback of a failed test, as those of Pyjo.Test.Pyjo
65
+ __tracebackhide__ = Pyjo.Test.__tracebackhide__
66
+
67
+
68
+ class Pyjo_MCP_Test(Pyjo.Test.Pyjo.object):
69
+ """
70
+ :mod:`Pyjo.MCP.Test` inherits all attributes and methods from
71
+ :mod:`Pyjo.Test.Pyjo` and implements the following new ones.
72
+ """
73
+
74
+ def __init__(self, app: Any = None, config: dict[str, Any] | None = None, /) -> None:
75
+ """::
76
+
77
+ t = Pyjo.MCP.Test.new()
78
+ t = Pyjo.MCP.Test.new(Pyjo.File.new('/path/to/server.py'))
79
+ t = Pyjo.MCP.Test.new(app, {'foo': 'bar'})
80
+
81
+ Construct a new :mod:`Pyjo.MCP.Test` object, with the application of
82
+ :meth:`Pyjo.Test.Pyjo.Pyjo_Test_Pyjo.__init__`.
83
+ """
84
+ self._client: Client.object | None = None
85
+ super().__init__(app, config)
86
+
87
+ self.notifications: list[dict[str, Any]] = []
88
+ """::
89
+
90
+ notifications = t.notifications
91
+
92
+ The notifications of JSON-RPC that the server sent before the
93
+ response of the last request, in its stream of events, as the
94
+ progress of a tool. ::
95
+
96
+ params = {'name': 'count', 'arguments': {'to': 3}}
97
+ t.mcp_ok('tools/call', params, meta={'progressToken': 'count-1'}).status_is(200)
98
+ is_ok(t.notifications[0]['method'], 'notifications/progress', 'progress')
99
+ """
100
+
101
+ self.response: dict[str, Any] | None = None
102
+ """::
103
+
104
+ response = t.response
105
+ t.response = {'jsonrpc': '2.0', 'id': 1, 'result': {}}
106
+
107
+ The response of JSON-RPC of the last request, from JSON or from a
108
+ stream of events, which the assertions of the response test, or
109
+ ``None`` for none. ::
110
+
111
+ # More specific tests
112
+ is_ok(t.response['result']['content'][0]['type'], 'text', 'right type')
113
+ """
114
+
115
+ # The method and the parameters of the last request, which input_ok sends again
116
+ self._request: tuple[str, dict[str, Any]] | None = None
117
+
118
+ @property
119
+ def client(self) -> Client.object:
120
+ """::
121
+
122
+ client = t.client
123
+ t.client = Pyjo.MCP.Client.new('/api/mcp')
124
+
125
+ The :mod:`Pyjo.MCP.Client` of the requests, of the endpoint ``/mcp``
126
+ by default, which sends them with :attr:`~Pyjo.Test.Pyjo.Pyjo_Test_Pyjo.ua`. ::
127
+
128
+ # Another endpoint, client and capabilities
129
+ t.client.url = '/api/mcp'
130
+ t.client.name = 'ExampleClient'
131
+ t.client.capabilities = {'elicitation': {}}
132
+ """
133
+ if self._client is None:
134
+ self._client = Client.new('/mcp', ua=self.ua)
135
+ return self._client
136
+
137
+ @client.setter
138
+ def client(self, value: Client.object) -> None:
139
+ self._client = value
140
+
141
+ def call_ok(self, tool: str, arguments: dict[str, Any] | None = None, /, **kwargs: Any) -> Self:
142
+ """::
143
+
144
+ t = t.call_ok('greet')
145
+ t = t.call_ok('greet', name='Pyjo')
146
+ t = t.call_ok('greet', {'name': 'Pyjo'})
147
+
148
+ Call a tool with ``tools/call``, with its arguments as a :class:`dict`
149
+ or as keyword arguments, and check for transport errors. The headers
150
+ ``Mcp-Param-{name}`` of the ``x-mcp-header`` of a tool come from the
151
+ tools of the last :meth:`list_tools_ok`, as a client knows them from
152
+ its definition. ::
153
+
154
+ t.list_tools_ok().call_ok('execute_sql', region='eu', query='SELECT 1').status_is(200)
155
+ """
156
+ return self.mcp_ok('tools/call', {'name': tool, 'arguments': {**(arguments or {}), **kwargs}})
157
+
158
+ def complete_ok(
159
+ self,
160
+ ref: dict[str, Any],
161
+ argument: str,
162
+ value: str,
163
+ arguments: dict[str, str] | None = None,
164
+ /,
165
+ *,
166
+ headers: dict[str, Any] | None = None,
167
+ meta: dict[str, Any] | None = None,
168
+ ) -> Self:
169
+ """::
170
+
171
+ t = t.complete_ok({'type': 'ref/prompt', 'name': 'review'}, 'language', 'Py')
172
+ t = t.complete_ok({'type': 'ref/resource', 'uri': 'notes://{number}'}, 'number', '1')
173
+
174
+ Complete the value of an argument of a prompt, or of a variable of a
175
+ resource template, with ``completion/complete``, with the values of the
176
+ other arguments, and check for transport errors, with the headers and
177
+ the ``_meta`` of :meth:`mcp_ok`. ::
178
+
179
+ t.complete_ok({'type': 'ref/prompt', 'name': 'review'}, 'language', 'P')
180
+ t.result_is('/completion/values', ['Perl', 'Python'])
181
+ """
182
+ params: dict[str, Any] = {'ref': ref, 'argument': {'name': argument, 'value': value}}
183
+ if arguments is not None:
184
+ params['context'] = {'arguments': arguments}
185
+ return self.mcp_ok('completion/complete', params, headers=headers, meta=meta)
186
+
187
+ def discover_ok(self, *, headers: dict[str, Any] | None = None, meta: dict[str, Any] | None = None) -> Self:
188
+ """::
189
+
190
+ t = t.discover_ok()
191
+ t = t.discover_ok(headers={'MCP-Protocol-Version': '2025-11-25'})
192
+
193
+ Discover the server with ``server/discover``, and check for transport
194
+ errors, with the headers and the ``_meta`` of :meth:`mcp_ok`.
195
+ """
196
+ return self.mcp_ok('server/discover', headers=headers, meta=meta)
197
+
198
+ def error_code_is(self, code: int, desc: str | None = None) -> Self:
199
+ """::
200
+
201
+ t = t.error_code_is(-32602)
202
+ t = t.error_code_is(-32602, 'invalid params')
203
+
204
+ Check the code of the error of JSON-RPC of the response.
205
+ """
206
+ return self.test('is', self._response_value('/error/code'), code, desc or f'error code is {code}')
207
+
208
+ def error_is(self, *args: Any) -> Self:
209
+ """::
210
+
211
+ t = t.error_is({'code': -32601, 'message': 'Method not found'})
212
+ t = t.error_is('/data/supported', ['2026-07-28'])
213
+ t = t.error_is('/code', -32601, 'right code')
214
+
215
+ Check the value of the error of JSON-RPC of the response, at a JSON
216
+ Pointer of :mod:`Pyjo.JSON.Pointer`, which defaults to the whole
217
+ error.
218
+ """
219
+ p, data, desc = _args(args)
220
+ return self.test('is_deeply', self._response_value(f'/error{p}'), data, desc or f'exact match for error "{p}"')
221
+
222
+ def error_message_is(self, message: str, desc: str | None = None) -> Self:
223
+ """::
224
+
225
+ t = t.error_message_is('Unknown tool: power')
226
+ t = t.error_message_is('Unknown tool: power', 'right message')
227
+
228
+ Check the message of the error of JSON-RPC of the response.
229
+ """
230
+ return self.test('is', self._response_value('/error/message'), message, desc or 'exact match for error message')
231
+
232
+ def error_message_like(self, regex: Any, desc: str | None = None) -> Self:
233
+ """::
234
+
235
+ t = t.error_message_like(re.compile(r'^Unknown tool'))
236
+ t = t.error_message_like(re.compile(r'^Unknown tool'), 'right message')
237
+
238
+ Check the message of the error of JSON-RPC of the response for a
239
+ similar match.
240
+ """
241
+ return self.test(
242
+ 'like', self._response_value('/error/message'), regex, desc or 'similar match for error message'
243
+ )
244
+
245
+ def get_prompt_ok(self, name: str, arguments: dict[str, Any] | None = None, /, **kwargs: Any) -> Self:
246
+ """::
247
+
248
+ t = t.get_prompt_ok('review')
249
+ t = t.get_prompt_ok('review', code='x = 1')
250
+ t = t.get_prompt_ok('review', {'code': 'x = 1'})
251
+
252
+ Get the messages of a prompt with ``prompts/get``, with its arguments
253
+ as a :class:`dict` or as keyword arguments, and check for transport
254
+ errors. ::
255
+
256
+ t.get_prompt_ok('review', code='x = 1').status_is(200).text_content_like(r'x = 1')
257
+ """
258
+ return self.mcp_ok('prompts/get', {'name': name, 'arguments': {**(arguments or {}), **kwargs}})
259
+
260
+ def is_error_is(self, value: bool, desc: str | None = None) -> Self:
261
+ """::
262
+
263
+ t = t.is_error_is(False)
264
+ t = t.is_error_is(True, 'error of the tool')
265
+
266
+ Check ``isError`` of the result of a tool, which is true for an error
267
+ of the execution of the tool, as its arguments that the
268
+ ``inputSchema`` does not validate.
269
+ """
270
+ return self.test(
271
+ 'is', self._response_value('/result/isError'), value, desc or f'isError is {Pyjo.JSON.to_json(value)}'
272
+ )
273
+
274
+ def input_ok(
275
+ self,
276
+ responses: dict[str, Any] | None = None,
277
+ /,
278
+ *,
279
+ headers: dict[str, Any] | None = None,
280
+ meta: dict[str, Any] | None = None,
281
+ ) -> Self:
282
+ """::
283
+
284
+ t = t.input_ok({'confirm': {'action': 'accept'}})
285
+
286
+ Send the last request again, as the next round of a request whose
287
+ result is ``input_required``, with the responses to its
288
+ ``inputRequests``, by their keys, and its ``requestState``, and check
289
+ for transport errors, with the headers and the ``_meta`` of
290
+ :meth:`mcp_ok`. ::
291
+
292
+ t.call_ok('delete', path='a.txt').result_is('/resultType', 'input_required')
293
+ t.result_is('/inputRequests/confirm/method', 'elicitation/create')
294
+ t.input_ok({'confirm': {'action': 'accept'}}).text_content_is('Deleted a.txt')
295
+ """
296
+ if (last := self._request) is None:
297
+ raise Pyjo.Exception.new('No request to send again')
298
+ method, params = last
299
+ params = {key: value for key, value in params.items() if key not in ('inputResponses', 'requestState')}
300
+ if responses is not None:
301
+ params['inputResponses'] = responses
302
+ if (state := self._response_value('/result/requestState')) is not None:
303
+ params['requestState'] = state
304
+ return self.mcp_ok(method, params, headers=headers, meta=meta)
305
+
306
+ def list_prompts_ok(self, *, headers: dict[str, Any] | None = None, meta: dict[str, Any] | None = None) -> Self:
307
+ """::
308
+
309
+ t = t.list_prompts_ok()
310
+
311
+ List the prompts with ``prompts/list``, and check for transport
312
+ errors, with the headers and the ``_meta`` of :meth:`mcp_ok`.
313
+ """
314
+ return self.mcp_ok('prompts/list', headers=headers, meta=meta)
315
+
316
+ def list_resources_ok(self, *, headers: dict[str, Any] | None = None, meta: dict[str, Any] | None = None) -> Self:
317
+ """::
318
+
319
+ t = t.list_resources_ok()
320
+
321
+ List the resources with ``resources/list``, and check for transport
322
+ errors, with the headers and the ``_meta`` of :meth:`mcp_ok`.
323
+ """
324
+ return self.mcp_ok('resources/list', headers=headers, meta=meta)
325
+
326
+ def list_resource_templates_ok(
327
+ self, *, headers: dict[str, Any] | None = None, meta: dict[str, Any] | None = None
328
+ ) -> Self:
329
+ """::
330
+
331
+ t = t.list_resource_templates_ok()
332
+
333
+ List the resource templates with ``resources/templates/list``, and
334
+ check for transport errors, with the headers and the ``_meta`` of
335
+ :meth:`mcp_ok`.
336
+ """
337
+ return self.mcp_ok('resources/templates/list', headers=headers, meta=meta)
338
+
339
+ def list_tools_ok(self, *, headers: dict[str, Any] | None = None, meta: dict[str, Any] | None = None) -> Self:
340
+ """::
341
+
342
+ t = t.list_tools_ok()
343
+
344
+ List the tools with ``tools/list``, and check for transport errors,
345
+ with the headers and the ``_meta`` of :meth:`mcp_ok`. The client keeps
346
+ the tools, whose ``x-mcp-header`` give the headers of the arguments of
347
+ :meth:`call_ok`.
348
+ """
349
+ return self.mcp_ok('tools/list', headers=headers, meta=meta)
350
+
351
+ def listen_ok(
352
+ self,
353
+ notifications: dict[str, Any] | None = None,
354
+ /,
355
+ *,
356
+ headers: dict[str, Any] | None = None,
357
+ meta: dict[str, Any] | None = None,
358
+ ) -> Self:
359
+ """::
360
+
361
+ t = t.listen_ok({'toolsListChanged': True})
362
+ t = t.listen_ok({'resourceSubscriptions': ['file:///project/README.md']})
363
+
364
+ Open a subscription with ``subscriptions/listen``, for the
365
+ notifications that the filter asks for, with the headers and the
366
+ ``_meta`` of :meth:`mcp_ok`, and check that its stream of events is
367
+ established, as :meth:`~Pyjo.Test.Pyjo.Pyjo_Test_Pyjo.post_sse_ok`
368
+ does. Its messages are the events of the stream, which
369
+ :meth:`notification_ok` waits for, and
370
+ :meth:`~Pyjo.Test.Pyjo.Pyjo_Test_Pyjo.sse_finish_ok` closes it. ::
371
+
372
+ t.listen_ok({'toolsListChanged': True})
373
+ t.notification_ok().notification_is('/method', 'notifications/subscriptions/acknowledged')
374
+ server.notify_list_changed('tools')
375
+ t.notification_ok().notification_is('/method', 'notifications/tools/list_changed')
376
+ t.sse_finish_ok()
377
+ """
378
+ _, tx = self._build_tx('subscriptions/listen', {'notifications': notifications or {}}, headers, None, meta)
379
+ self.response = None
380
+ return self._sse_ok(tx, f'subscriptions/listen {self.client.url}')
381
+
382
+ def mcp_ok(
383
+ self,
384
+ method: str,
385
+ params: dict[str, Any] | None = None,
386
+ /,
387
+ *,
388
+ headers: dict[str, Any] | None = None,
389
+ id: Any = None,
390
+ meta: dict[str, Any] | None = None,
391
+ ) -> Self:
392
+ """::
393
+
394
+ t = t.mcp_ok('tools/list')
395
+ t = t.mcp_ok('tools/call', {'name': 'add', 'arguments': {'a': 1, 'b': 2}})
396
+ t = t.mcp_ok('tools/call', {'name': 'add', 'arguments': {}}, headers={'Mcp-Name': 'foo'})
397
+ t = t.mcp_ok('server/discover', id='discover-1', meta={'io.modelcontextprotocol/clientInfo': None})
398
+
399
+ Send a request of MCP with the method and its parameters, as
400
+ :mod:`Pyjo.MCP.Client` builds it, and check for transport errors. The
401
+ ``id`` replaces the one of the client, a value of ``meta`` replaces
402
+ the one of its key in the ``_meta`` of the request, before the headers
403
+ are made from the body, and a value of ``headers`` replaces the header
404
+ of its name after it, ``None`` dropping either, so that a test sends
405
+ what a client must not. ::
406
+
407
+ # A revision of the specification that the server does not support
408
+ t.mcp_ok('tools/list', meta={'io.modelcontextprotocol/protocolVersion': '1900-01-01'}).status_is(400)
409
+ t.error_code_is(-32022)
410
+ """
411
+ self._request = (method, params or {})
412
+ client = self.client
413
+ request, tx = self._build_tx(method, params, headers, id, meta)
414
+ notifications: list[dict[str, Any]] = []
415
+
416
+ def notification(client: Client.object, message: dict[str, Any]) -> None:
417
+ notifications.append(message)
418
+
419
+ client.on('notification', notification)
420
+ try:
421
+ self.request_ok(tx)
422
+ try:
423
+ self.response = client.read_response(tx, request)
424
+ except Pyjo.Exception.object:
425
+ self.response = None
426
+ finally:
427
+ client.unsubscribe('notification', notification)
428
+ self.notifications = notifications
429
+ return self
430
+
431
+ def notification_is(self, *args: Any) -> Self:
432
+ """::
433
+
434
+ t = t.notification_is({'jsonrpc': '2.0', 'method': 'notifications/tools/list_changed', 'params': {}})
435
+ t = t.notification_is('/method', 'notifications/resources/updated')
436
+ t = t.notification_is('/params/uri', 'file:///project/README.md', 'right resource')
437
+
438
+ Check the value of the message of JSON-RPC of the last event of the
439
+ subscription of :meth:`listen_ok`, a notification, or the response at
440
+ its end, at a JSON Pointer of :mod:`Pyjo.JSON.Pointer`, which defaults
441
+ to the whole message.
442
+ """
443
+ p, data, desc = _args(args)
444
+ got = Pyjo.JSON.Pointer.new(_message(self.sse)).get(p)
445
+ return self.test('is_deeply', got, data, desc or f'exact match for notification "{p}"')
446
+
447
+ def notification_ok(self, desc: str | None = None) -> Self:
448
+ """::
449
+
450
+ t = t.notification_ok()
451
+ t = t.notification_ok('got a notification')
452
+
453
+ Wait for the next message of the subscription of :meth:`listen_ok`, as
454
+ :meth:`~Pyjo.Test.Pyjo.Pyjo_Test_Pyjo.sse_ok` waits for the next event.
455
+ """
456
+ return self.sse_ok(desc or 'notification received')
457
+
458
+ def prompt_exists(self, name: str, desc: str | None = None) -> Self:
459
+ """::
460
+
461
+ t = t.prompt_exists('review')
462
+ t = t.prompt_exists('review', 'has a review')
463
+
464
+ Check if the result of ``prompts/list`` has a prompt of the name.
465
+ """
466
+ return self.test('ok', self._listed('prompts', 'name', name) is not None, desc or f'has prompt "{name}"')
467
+
468
+ def prompt_exists_not(self, name: str, desc: str | None = None) -> Self:
469
+ """::
470
+
471
+ t = t.prompt_exists_not('summary')
472
+ t = t.prompt_exists_not('summary', 'no summary')
473
+
474
+ Opposite of :meth:`prompt_exists`.
475
+ """
476
+ return self.test('ok', self._listed('prompts', 'name', name) is None, desc or f'has no prompt "{name}"')
477
+
478
+ def prompt_is(self, name: str, *args: Any) -> Self:
479
+ """::
480
+
481
+ t = t.prompt_is('review', {'name': 'review', 'arguments': []})
482
+ t = t.prompt_is('review', '/arguments/0/name', 'code')
483
+ t = t.prompt_is('review', '/arguments/0/required', True, 'code required')
484
+
485
+ Check the value of the definition of a prompt of the result of
486
+ ``prompts/list``, at a JSON Pointer of :mod:`Pyjo.JSON.Pointer`, which
487
+ defaults to the whole definition.
488
+ """
489
+ p, data, desc = _args(args)
490
+ got = Pyjo.JSON.Pointer.new(self._listed('prompts', 'name', name)).get(p)
491
+ return self.test('is_deeply', got, data, desc or f'exact match for prompt "{name}" "{p}"')
492
+
493
+ def read_resource_ok(
494
+ self, uri: str, *, headers: dict[str, Any] | None = None, meta: dict[str, Any] | None = None
495
+ ) -> Self:
496
+ """::
497
+
498
+ t = t.read_resource_ok('file:///project/README.md')
499
+
500
+ Read the contents of a resource with ``resources/read``, and check for
501
+ transport errors, with the headers and the ``_meta`` of
502
+ :meth:`mcp_ok`. ::
503
+
504
+ t.read_resource_ok('file:///project/README.md').status_is(200).text_content_like(r'^# ')
505
+ """
506
+ return self.mcp_ok('resources/read', {'uri': uri}, headers=headers, meta=meta)
507
+
508
+ def resource_exists(self, uri: str, desc: str | None = None) -> Self:
509
+ """::
510
+
511
+ t = t.resource_exists('file:///project/README.md')
512
+ t = t.resource_exists('file:///project/README.md', 'has a README')
513
+
514
+ Check if the result of ``resources/list`` has a resource of the URI.
515
+ """
516
+ return self.test('ok', self._listed('resources', 'uri', uri) is not None, desc or f'has resource "{uri}"')
517
+
518
+ def resource_exists_not(self, uri: str, desc: str | None = None) -> Self:
519
+ """::
520
+
521
+ t = t.resource_exists_not('file:///etc/passwd')
522
+ t = t.resource_exists_not('file:///etc/passwd', 'no secrets')
523
+
524
+ Opposite of :meth:`resource_exists`.
525
+ """
526
+ return self.test('ok', self._listed('resources', 'uri', uri) is None, desc or f'has no resource "{uri}"')
527
+
528
+ def resource_is(self, uri: str, *args: Any) -> Self:
529
+ """::
530
+
531
+ t = t.resource_is('file:///project/README.md', {'uri': 'file:///project/README.md', 'name': 'readme'})
532
+ t = t.resource_is('file:///project/README.md', '/mimeType', 'text/markdown')
533
+ t = t.resource_is('file:///project/README.md', '/name', 'readme', 'right name')
534
+
535
+ Check the value of the definition of a resource of the result of
536
+ ``resources/list``, at a JSON Pointer of :mod:`Pyjo.JSON.Pointer`,
537
+ which defaults to the whole definition.
538
+ """
539
+ p, data, desc = _args(args)
540
+ got = Pyjo.JSON.Pointer.new(self._listed('resources', 'uri', uri)).get(p)
541
+ return self.test('is_deeply', got, data, desc or f'exact match for resource "{uri}" "{p}"')
542
+
543
+ def resource_template_exists(self, uri: str, desc: str | None = None) -> Self:
544
+ """::
545
+
546
+ t = t.resource_template_exists('notes://{number}')
547
+ t = t.resource_template_exists('notes://{number}', 'has notes')
548
+
549
+ Check if the result of ``resources/templates/list`` has a resource
550
+ template of the URI Template.
551
+ """
552
+ found = self._listed('resourceTemplates', 'uriTemplate', uri) is not None
553
+ return self.test('ok', found, desc or f'has resource template "{uri}"')
554
+
555
+ def resource_template_exists_not(self, uri: str, desc: str | None = None) -> Self:
556
+ """::
557
+
558
+ t = t.resource_template_exists_not('file:///{+path}')
559
+ t = t.resource_template_exists_not('file:///{+path}', 'no files')
560
+
561
+ Opposite of :meth:`resource_template_exists`.
562
+ """
563
+ found = self._listed('resourceTemplates', 'uriTemplate', uri) is not None
564
+ return self.test('ok', not found, desc or f'has no resource template "{uri}"')
565
+
566
+ def resource_template_is(self, uri: str, *args: Any) -> Self:
567
+ """::
568
+
569
+ t = t.resource_template_is('notes://{number}', {'uriTemplate': 'notes://{number}', 'name': 'note'})
570
+ t = t.resource_template_is('notes://{number}', '/mimeType', 'text/plain')
571
+ t = t.resource_template_is('notes://{number}', '/name', 'note', 'right name')
572
+
573
+ Check the value of the definition of a resource template of the result
574
+ of ``resources/templates/list``, at a JSON Pointer of
575
+ :mod:`Pyjo.JSON.Pointer`, which defaults to the whole definition.
576
+ """
577
+ p, data, desc = _args(args)
578
+ got = Pyjo.JSON.Pointer.new(self._listed('resourceTemplates', 'uriTemplate', uri)).get(p)
579
+ return self.test('is_deeply', got, data, desc or f'exact match for resource template "{uri}" "{p}"')
580
+
581
+ def response_is(self, *args: Any) -> Self:
582
+ """::
583
+
584
+ t = t.response_is({'jsonrpc': '2.0', 'id': 1, 'result': {}})
585
+ t = t.response_is('/id', 1)
586
+ t = t.response_is('/id', 1, 'right id')
587
+
588
+ Check the value of the response of JSON-RPC, at a JSON Pointer of
589
+ :mod:`Pyjo.JSON.Pointer`, which defaults to the whole response.
590
+ """
591
+ p, data, desc = _args(args)
592
+ return self.test('is_deeply', self._response_value(p), data, desc or f'exact match for response "{p}"')
593
+
594
+ def result_is(self, *args: Any) -> Self:
595
+ """::
596
+
597
+ t = t.result_is({'resultType': 'complete', 'tools': []})
598
+ t = t.result_is('/content/0/text', 'Hello, Pyjo!')
599
+ t = t.result_is('/content/0/text', 'Hello, Pyjo!', 'right text')
600
+
601
+ Check the value of the result of the response, at a JSON Pointer of
602
+ :mod:`Pyjo.JSON.Pointer`, which defaults to the whole result.
603
+ """
604
+ p, data, desc = _args(args)
605
+ return self.test(
606
+ 'is_deeply', self._response_value(f'/result{p}'), data, desc or f'exact match for result "{p}"'
607
+ )
608
+
609
+ def structured_content_is(self, *args: Any) -> Self:
610
+ """::
611
+
612
+ t = t.structured_content_is(3.5)
613
+ t = t.structured_content_is('/temperature', 21.5)
614
+ t = t.structured_content_is('/temperature', 21.5, 'right temperature')
615
+
616
+ Check the value of the ``structuredContent`` of the result of a tool,
617
+ at a JSON Pointer of :mod:`Pyjo.JSON.Pointer`, which defaults to the
618
+ whole value.
619
+ """
620
+ p, data, desc = _args(args)
621
+ got = self._response_value(f'/result/structuredContent{p}')
622
+ return self.test('is_deeply', got, data, desc or f'exact match for structured content "{p}"')
623
+
624
+ def text_content_is(self, text: str, desc: str | None = None) -> Self:
625
+ """::
626
+
627
+ t = t.text_content_is('Hello, Pyjo!')
628
+ t = t.text_content_is('Hello, Pyjo!', 'right text')
629
+
630
+ Check the text of the result: of the ``content`` of a tool, of the
631
+ ``content`` of the messages of a prompt, or of the ``contents`` of a
632
+ resource, its items of the type ``text`` joined. ::
633
+
634
+ t.get_prompt_ok('review', code='x = 1').text_content_is('Review this Python code:\\nx = 1')
635
+ t.read_resource_ok('file:///project/README.md').text_content_is('# Project')
636
+ """
637
+ return self.test('is', self._content_text(), text, desc or 'exact match for text content')
638
+
639
+ def text_content_like(self, regex: Any, desc: str | None = None) -> Self:
640
+ """::
641
+
642
+ t = t.text_content_like(re.compile(r'/name: Missing property'))
643
+ t = t.text_content_like(re.compile(r'/name: Missing property'), 'right error')
644
+
645
+ Check the text of the result, as :meth:`text_content_is` takes it, for
646
+ a similar match.
647
+ """
648
+ return self.test('like', self._content_text(), regex, desc or 'similar match for text content')
649
+
650
+ def tool_exists(self, name: str, desc: str | None = None) -> Self:
651
+ """::
652
+
653
+ t = t.tool_exists('greet')
654
+ t = t.tool_exists('greet', 'has a greeting')
655
+
656
+ Check if the result of ``tools/list`` has a tool of the name.
657
+ """
658
+ return self.test('ok', self._listed('tools', 'name', name) is not None, desc or f'has tool "{name}"')
659
+
660
+ def tool_exists_not(self, name: str, desc: str | None = None) -> Self:
661
+ """::
662
+
663
+ t = t.tool_exists_not('power')
664
+ t = t.tool_exists_not('power', 'no power')
665
+
666
+ Opposite of :meth:`tool_exists`.
667
+ """
668
+ return self.test('ok', self._listed('tools', 'name', name) is None, desc or f'has no tool "{name}"')
669
+
670
+ def tool_is(self, name: str, *args: Any) -> Self:
671
+ """::
672
+
673
+ t = t.tool_is('greet', {'name': 'greet', 'inputSchema': {'type': 'object'}})
674
+ t = t.tool_is('greet', '/description', 'Greet somebody by name.')
675
+ t = t.tool_is('greet', '/inputSchema/required', ['name'], 'right required arguments')
676
+
677
+ Check the value of the definition of a tool of the result of
678
+ ``tools/list``, at a JSON Pointer of :mod:`Pyjo.JSON.Pointer`, which
679
+ defaults to the whole definition.
680
+ """
681
+ p, data, desc = _args(args)
682
+ got = Pyjo.JSON.Pointer.new(self._listed('tools', 'name', name)).get(p)
683
+ return self.test('is_deeply', got, data, desc or f'exact match for tool "{name}" "{p}"')
684
+
685
+ def _build_tx(
686
+ self,
687
+ method: str,
688
+ params: dict[str, Any] | None,
689
+ headers: dict[str, Any] | None,
690
+ id: Any,
691
+ meta: dict[str, Any] | None,
692
+ ) -> tuple[dict[str, Any], Any]:
693
+ # A request of the client and its transaction, with the id, the _meta and the headers that replace its own
694
+ client = self.client
695
+ client.ua = self.ua
696
+ request = client.build_request(method, params)
697
+ if id is not None:
698
+ request['id'] = id
699
+ request_meta = cast(dict[str, Any], request['params']['_meta'])
700
+ for key, value in (meta or {}).items():
701
+ if value is None:
702
+ request_meta.pop(key, None)
703
+ else:
704
+ request_meta[key] = value
705
+
706
+ tx = client.build_tx(request)
707
+ for name, value in (headers or {}).items():
708
+ if value is None:
709
+ tx.req.headers.remove(name)
710
+ else:
711
+ tx.req.headers.header(name, value)
712
+ return request, tx
713
+
714
+ def _response_value(self, p: str) -> Any:
715
+ return Pyjo.JSON.Pointer.new(self.response).get(p)
716
+
717
+ def _content_text(self) -> str | None:
718
+ # The text of the content of a tool, of the messages of a prompt, or of the contents of a resource, or None
719
+ result = _object(self._response_value('/result'))
720
+ if isinstance(content := result.get('content'), list):
721
+ items = [_object(item) for item in cast(list[Any], content)]
722
+ elif isinstance(messages := result.get('messages'), list):
723
+ items = [_object(_object(message).get('content')) for message in cast(list[Any], messages)]
724
+ elif isinstance(contents := result.get('contents'), list):
725
+ items = [{'type': 'text', **_object(item)} for item in cast(list[Any], contents) if 'text' in _object(item)]
726
+ else:
727
+ return None
728
+ return ''.join(str(item.get('text', '')) for item in items if item.get('type') == 'text')
729
+
730
+ def _listed(self, kind: str, key: str, value: str) -> dict[str, Any] | None:
731
+ # The definition of the result of a list, of the tools, the prompts or the resources, by its name or its URI
732
+ listed = self._response_value(f'/result/{kind}')
733
+ if not isinstance(listed, list):
734
+ return None
735
+ return next((item for item in map(_object, cast(list[Any], listed)) if item.get(key) == value), None)
736
+
737
+
738
+ def _args(args: tuple[Any, ...]) -> tuple[str, Any, str | None]:
739
+ # The JSON Pointer, the value and the description of an assertion, as json_is of Pyjo.Test.Pyjo takes them
740
+ values = list(args)
741
+ if len(values) > 1:
742
+ return values[0], values[1], values[2] if len(values) > 2 else None
743
+ return '', values[0] if len(values) else None, None
744
+
745
+
746
+ def _message(event: Any) -> Any:
747
+ # The message of JSON-RPC of an event, or None
748
+ try:
749
+ return Pyjo.JSON.decode_json(_object(event).get('text') or '')
750
+ except ValueError:
751
+ return None
752
+
753
+
754
+ def _object(value: Any) -> dict[str, Any]:
755
+ # An object of JSON, or an empty one for another value
756
+ return cast(dict[str, Any], value) if isinstance(value, dict) else {}
757
+
758
+
759
+ new = Pyjo_MCP_Test.new
760
+ object = Pyjo_MCP_Test
761
+ Test = Pyjo_MCP_Test