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