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/Tool.py
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pyjo.MCP.Tool - Tool of a server of MCP
|
|
3
|
+
=======================================
|
|
4
|
+
::
|
|
5
|
+
|
|
6
|
+
from Pyjo.MCP.Tool import Tool
|
|
7
|
+
|
|
8
|
+
def add(a: int, b: int) -> int:
|
|
9
|
+
\"\"\"Add two numbers.
|
|
10
|
+
|
|
11
|
+
:param a: The first number.
|
|
12
|
+
:param b: The second number.
|
|
13
|
+
\"\"\"
|
|
14
|
+
return a + b
|
|
15
|
+
|
|
16
|
+
tool = Tool(add)
|
|
17
|
+
print(tool.name, tool.description, tool.input_schema)
|
|
18
|
+
|
|
19
|
+
# Or import the module, as in Google Python Style Guide
|
|
20
|
+
|
|
21
|
+
import Pyjo.MCP.Tool
|
|
22
|
+
|
|
23
|
+
tool = Pyjo.MCP.Tool.new(add, name='math.add', title='Addition')
|
|
24
|
+
tool.call_p({'a': 1, 'b': 2}).then(print).wait()
|
|
25
|
+
|
|
26
|
+
:mod:`Pyjo.MCP.Tool` is a tool of :mod:`Pyjo.MCP.Server`, a function that a
|
|
27
|
+
language model calls with the arguments of its ``inputSchema``. The name, the
|
|
28
|
+
description and the schemas of the tool are those of the function by default:
|
|
29
|
+
its name, the first paragraph of its docstring, the schema of its parameters,
|
|
30
|
+
with the descriptions of its docstring, and the one of what it returns, of
|
|
31
|
+
:mod:`Pyjo.JSON.Schema`.
|
|
32
|
+
|
|
33
|
+
The function returns the result of the tool, as a value, a :mod:`Pyjo.Promise`
|
|
34
|
+
of it or a coroutine, of an ``async def``:
|
|
35
|
+
|
|
36
|
+
* a value of the ``outputSchema`` of the tool, as of the annotation of what the
|
|
37
|
+
function returns, is the ``structuredContent`` of the result, and its JSON
|
|
38
|
+
the text of its ``content``;
|
|
39
|
+
* a string, without an ``outputSchema``, is the text of the ``content``;
|
|
40
|
+
* a :class:`dict` with ``content``, without an ``outputSchema``, is the result
|
|
41
|
+
as it is, for the content of another type, as an image;
|
|
42
|
+
* ``None`` is a result without content, and another value is its JSON.
|
|
43
|
+
|
|
44
|
+
Arguments that the ``inputSchema`` does not validate, and a function that
|
|
45
|
+
raises :class:`Error`, give a result with ``isError``, whose text the language
|
|
46
|
+
model reads to call the tool again, as a tool reports the errors of its
|
|
47
|
+
execution. Another exception is an error of the server, which the result does
|
|
48
|
+
not tell.
|
|
49
|
+
|
|
50
|
+
See also
|
|
51
|
+
--------
|
|
52
|
+
|
|
53
|
+
:mod:`Pyjo.MCP.Server`, :mod:`Pyjo.JSON.Schema`, :mod:`Pyjo.MCP`.
|
|
54
|
+
|
|
55
|
+
Classes
|
|
56
|
+
-------
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
import Pyjo.Base
|
|
60
|
+
import Pyjo.Exception
|
|
61
|
+
import Pyjo.JSON
|
|
62
|
+
import Pyjo.JSON.Schema
|
|
63
|
+
import Pyjo.Promise
|
|
64
|
+
|
|
65
|
+
import builtins
|
|
66
|
+
import dataclasses
|
|
67
|
+
import datetime
|
|
68
|
+
import enum
|
|
69
|
+
import inspect
|
|
70
|
+
import re
|
|
71
|
+
import uuid
|
|
72
|
+
|
|
73
|
+
from collections.abc import Callable, Mapping
|
|
74
|
+
from typing import Any, cast
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
type Anything = builtins.object
|
|
78
|
+
|
|
79
|
+
# The characters of a token of a name of a field of HTTP (RFC 9110)
|
|
80
|
+
_TOKEN = re.compile(r"[!#$%&'*+\-.^_`|~0-9A-Za-z]+")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Error(Pyjo.Exception.object):
|
|
84
|
+
"""
|
|
85
|
+
An error of the execution of a tool, which the result of the call tells
|
|
86
|
+
with ``isError``, so that the language model can correct the arguments. ::
|
|
87
|
+
|
|
88
|
+
raise Pyjo.MCP.Tool.Error.new('Invalid departure date: must be in the future')
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Pyjo_MCP_Tool(Pyjo.Base.object):
|
|
93
|
+
"""
|
|
94
|
+
:mod:`Pyjo.MCP.Tool` inherits all methods from :mod:`Pyjo.Base` and
|
|
95
|
+
implements the following new ones.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
cb: Callable[..., Any],
|
|
101
|
+
*,
|
|
102
|
+
name: str | None = None,
|
|
103
|
+
title: str | None = None,
|
|
104
|
+
description: str | None = None,
|
|
105
|
+
input_schema: Mapping[str, Any] | Pyjo.JSON.Schema.object | None = None,
|
|
106
|
+
output_schema: Mapping[str, Any] | Pyjo.JSON.Schema.object | bool | None = None,
|
|
107
|
+
annotations: Mapping[str, Any] | None = None,
|
|
108
|
+
context: bool = False,
|
|
109
|
+
) -> None:
|
|
110
|
+
"""::
|
|
111
|
+
|
|
112
|
+
tool = Pyjo.MCP.Tool.new(add)
|
|
113
|
+
tool = Pyjo.MCP.Tool.new(add, name='math.add', title='Addition')
|
|
114
|
+
tool = Pyjo.MCP.Tool.new(lambda c, **args: ..., input_schema={'type': 'object'}, context=True)
|
|
115
|
+
tool = Pyjo.MCP.Tool.new(report, output_schema=False)
|
|
116
|
+
|
|
117
|
+
Construct a new :mod:`Pyjo.MCP.Tool` object of a function. With
|
|
118
|
+
``context``, the function gets the :class:`~Pyjo.MCP.Server.Context` of
|
|
119
|
+
the request before its arguments. ``output_schema`` of ``False`` gives
|
|
120
|
+
the tool no ``outputSchema``, also when the function has the annotation
|
|
121
|
+
of what it returns.
|
|
122
|
+
"""
|
|
123
|
+
signature = inspect.signature(cb)
|
|
124
|
+
skip = 1 if context else 0
|
|
125
|
+
|
|
126
|
+
if input_schema is None:
|
|
127
|
+
schema = Pyjo.JSON.Schema.parameters(cb, skip=skip)
|
|
128
|
+
data = cast(dict[str, Any], schema.data)
|
|
129
|
+
if description is None:
|
|
130
|
+
description = data.pop('description', None)
|
|
131
|
+
else:
|
|
132
|
+
data.pop('description', None)
|
|
133
|
+
elif isinstance(input_schema, Pyjo.JSON.Schema.object):
|
|
134
|
+
schema = input_schema
|
|
135
|
+
else:
|
|
136
|
+
schema = Pyjo.JSON.Schema.new(dict(input_schema))
|
|
137
|
+
|
|
138
|
+
output: Pyjo.JSON.Schema.object | None = None
|
|
139
|
+
if output_schema is None or output_schema is True:
|
|
140
|
+
returns = Pyjo.JSON.Schema.returns(cb)
|
|
141
|
+
# A string is the text of the content, and None no content
|
|
142
|
+
if returns is not None and cast(dict[str, Any], returns.data).get('type') not in ('string', 'null'):
|
|
143
|
+
output = returns
|
|
144
|
+
elif isinstance(output_schema, Pyjo.JSON.Schema.object):
|
|
145
|
+
output = output_schema
|
|
146
|
+
elif output_schema is not False:
|
|
147
|
+
output = Pyjo.JSON.Schema.new(dict(output_schema))
|
|
148
|
+
|
|
149
|
+
self.annotations: dict[str, Any] = dict(annotations or {})
|
|
150
|
+
"""::
|
|
151
|
+
|
|
152
|
+
annotations = tool.annotations
|
|
153
|
+
|
|
154
|
+
The properties of the behaviour of the tool, as ``readOnlyHint``, which
|
|
155
|
+
a client takes as hints, for the ``annotations`` of the tool.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
self.cb = cb
|
|
159
|
+
"""::
|
|
160
|
+
|
|
161
|
+
cb = tool.cb
|
|
162
|
+
|
|
163
|
+
The function of the tool.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
self.context = context
|
|
167
|
+
"""::
|
|
168
|
+
|
|
169
|
+
boolean = tool.context
|
|
170
|
+
|
|
171
|
+
Whether the function gets the context of the request before its
|
|
172
|
+
arguments.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
self.description = description or ''
|
|
176
|
+
"""::
|
|
177
|
+
|
|
178
|
+
description = tool.description
|
|
179
|
+
|
|
180
|
+
What the tool does, for the language model, the first paragraph of the
|
|
181
|
+
docstring of the function by default.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
self.input_schema: Pyjo.JSON.Schema.object = schema
|
|
185
|
+
"""::
|
|
186
|
+
|
|
187
|
+
schema = tool.input_schema
|
|
188
|
+
|
|
189
|
+
The :mod:`Pyjo.JSON.Schema` of the arguments, the ``inputSchema`` of
|
|
190
|
+
the tool, the one of the parameters of the function by default.
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
self.name = name or cb.__name__
|
|
194
|
+
"""::
|
|
195
|
+
|
|
196
|
+
name = tool.name
|
|
197
|
+
|
|
198
|
+
The name of the tool, the one of the function by default.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
self.output_schema = output
|
|
202
|
+
"""::
|
|
203
|
+
|
|
204
|
+
schema = tool.output_schema
|
|
205
|
+
|
|
206
|
+
The :mod:`Pyjo.JSON.Schema` of the ``structuredContent`` of the result,
|
|
207
|
+
the ``outputSchema`` of the tool, the one of what the function returns
|
|
208
|
+
by default, or ``None``.
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
self.title = title
|
|
212
|
+
"""::
|
|
213
|
+
|
|
214
|
+
title = tool.title
|
|
215
|
+
|
|
216
|
+
The name of the tool for people, or ``None``.
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
self._header_params = _header_params_of(schema.data, self.name)
|
|
220
|
+
self._signature = signature
|
|
221
|
+
|
|
222
|
+
def __json__(self) -> dict[str, Any]:
|
|
223
|
+
"""::
|
|
224
|
+
|
|
225
|
+
definition = tool.__json__()
|
|
226
|
+
|
|
227
|
+
The definition of the tool, as ``tools/list`` gives it, with its
|
|
228
|
+
``name``, ``title``, ``description``, ``inputSchema``,
|
|
229
|
+
``outputSchema`` and ``annotations``, where it has them.
|
|
230
|
+
"""
|
|
231
|
+
definition: dict[str, Any] = {'name': self.name}
|
|
232
|
+
if self.title is not None:
|
|
233
|
+
definition['title'] = self.title
|
|
234
|
+
definition['description'] = self.description
|
|
235
|
+
definition['inputSchema'] = self.input_schema.data
|
|
236
|
+
if self.output_schema is not None:
|
|
237
|
+
definition['outputSchema'] = self.output_schema.data
|
|
238
|
+
if self.annotations:
|
|
239
|
+
definition['annotations'] = self.annotations
|
|
240
|
+
return definition
|
|
241
|
+
|
|
242
|
+
def call_p(self, arguments: Mapping[str, Any], context: Any = None) -> Pyjo.Promise.object:
|
|
243
|
+
"""::
|
|
244
|
+
|
|
245
|
+
promise = tool.call_p({'a': 1, 'b': 2})
|
|
246
|
+
promise = tool.call_p({'a': 1, 'b': 2}, context)
|
|
247
|
+
|
|
248
|
+
Call the function with the arguments, and return a :mod:`Pyjo.Promise`
|
|
249
|
+
of the result of the tool, as ``tools/call`` gives it, without its
|
|
250
|
+
``resultType``, or rejected with an exception of the function that is not an
|
|
251
|
+
:class:`Error`, an error of the server. ::
|
|
252
|
+
|
|
253
|
+
# {'content': [{'type': 'text', 'text': '3'}], 'structuredContent': 3, 'isError': False}
|
|
254
|
+
tool.call_p({'a': 1, 'b': 2}).then(print).wait()
|
|
255
|
+
"""
|
|
256
|
+
if not (result := self.input_schema.validate(dict(arguments))):
|
|
257
|
+
return Pyjo.Promise.resolve(error_result(f'Invalid arguments:\n{result}'))
|
|
258
|
+
try:
|
|
259
|
+
bound = self._signature.bind(*([context] if self.context else []), **arguments)
|
|
260
|
+
except TypeError as e:
|
|
261
|
+
return Pyjo.Promise.resolve(error_result(f'Invalid arguments: {e}'))
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
value = self.cb(*bound.args, **bound.kwargs)
|
|
265
|
+
except Error as e:
|
|
266
|
+
return Pyjo.Promise.resolve(error_result(e.message))
|
|
267
|
+
except Exception as e:
|
|
268
|
+
return Pyjo.Promise.reject(e)
|
|
269
|
+
if inspect.iscoroutine(value):
|
|
270
|
+
value = Pyjo.Promise.new(value)
|
|
271
|
+
if isinstance(value, Pyjo.Promise.object):
|
|
272
|
+
|
|
273
|
+
def failed(e: Any = None) -> Any:
|
|
274
|
+
if isinstance(e, Error):
|
|
275
|
+
return error_result(e.message)
|
|
276
|
+
return Pyjo.Promise.reject(e)
|
|
277
|
+
|
|
278
|
+
return value.then(self.result, failed)
|
|
279
|
+
try:
|
|
280
|
+
return Pyjo.Promise.resolve(self.result(value))
|
|
281
|
+
except Exception as e:
|
|
282
|
+
return Pyjo.Promise.reject(e)
|
|
283
|
+
|
|
284
|
+
@property
|
|
285
|
+
def header_params(self) -> list[tuple[str, list[str], str]]:
|
|
286
|
+
"""::
|
|
287
|
+
|
|
288
|
+
params = tool.header_params
|
|
289
|
+
|
|
290
|
+
The arguments of the ``x-mcp-header`` of the ``inputSchema``, which a
|
|
291
|
+
client of HTTP sends as the headers ``Mcp-Param-{name}`` too, each with
|
|
292
|
+
the name, the path of the properties and the type. A header that the
|
|
293
|
+
specification does not allow, as of a property of an array or of a
|
|
294
|
+
``number``, fails with a :mod:`Pyjo.Exception` when the tool is made.
|
|
295
|
+
"""
|
|
296
|
+
return self._header_params
|
|
297
|
+
|
|
298
|
+
def result(self, value: Any = None) -> dict[str, Any]:
|
|
299
|
+
"""::
|
|
300
|
+
|
|
301
|
+
result = tool.result(3)
|
|
302
|
+
result = tool.result('Three')
|
|
303
|
+
|
|
304
|
+
The result of the tool of what the function returns. A value that the
|
|
305
|
+
``outputSchema`` does not validate fails with a :mod:`Pyjo.Exception`,
|
|
306
|
+
as an error of the server.
|
|
307
|
+
"""
|
|
308
|
+
if self.output_schema is not None:
|
|
309
|
+
data = _plain(value)
|
|
310
|
+
if not (result := self.output_schema.validate(data)):
|
|
311
|
+
raise Pyjo.Exception.new(f'The result of the tool {self.name} is not valid:\n{result}')
|
|
312
|
+
text = Pyjo.JSON.to_json(data)
|
|
313
|
+
return {'content': [{'type': 'text', 'text': text}], 'structuredContent': data, 'isError': False}
|
|
314
|
+
if value is None:
|
|
315
|
+
return {'content': [], 'isError': False}
|
|
316
|
+
if isinstance(value, dict) and 'content' in value:
|
|
317
|
+
return cast(dict[str, Any], value)
|
|
318
|
+
data = _plain(value)
|
|
319
|
+
return text_result(data if isinstance(data, str) else Pyjo.JSON.to_json(data))
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def error_result(text: str) -> dict[str, Any]:
|
|
323
|
+
"""::
|
|
324
|
+
|
|
325
|
+
result = Pyjo.MCP.Tool.error_result('Invalid departure date')
|
|
326
|
+
|
|
327
|
+
A result of a tool with the text of an error of its execution and
|
|
328
|
+
``isError``.
|
|
329
|
+
"""
|
|
330
|
+
return {'content': [{'type': 'text', 'text': text}], 'isError': True}
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def text_result(text: str) -> dict[str, Any]:
|
|
334
|
+
"""::
|
|
335
|
+
|
|
336
|
+
result = Pyjo.MCP.Tool.text_result('Sunny')
|
|
337
|
+
|
|
338
|
+
A result of a tool with a text.
|
|
339
|
+
"""
|
|
340
|
+
return {'content': [{'type': 'text', 'text': text}], 'isError': False}
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _header_params_of(schema: Anything, tool: str) -> list[tuple[str, list[str], str]]:
|
|
344
|
+
params: list[tuple[str, list[str], str]] = []
|
|
345
|
+
_header_params(schema, [], params, False)
|
|
346
|
+
names = [name.lower() for name, _, _ in params]
|
|
347
|
+
if len(set(names)) != len(names):
|
|
348
|
+
raise Pyjo.Exception.new(f'The x-mcp-header of the tool {tool} are not unique')
|
|
349
|
+
return params
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _header_params(schema: Anything, path: list[str], params: list[tuple[str, list[str], str]], hidden: bool) -> None:
|
|
353
|
+
# The properties that have an x-mcp-header, through the chain of properties from the root only
|
|
354
|
+
if not isinstance(schema, dict):
|
|
355
|
+
return
|
|
356
|
+
schema_ = cast(dict[str, Any], schema)
|
|
357
|
+
for keyword, value in schema_.items():
|
|
358
|
+
if keyword == 'properties' and isinstance(value, dict):
|
|
359
|
+
for key, property in cast(dict[str, Any], value).items():
|
|
360
|
+
_header_params(property, [*path, key], params, hidden)
|
|
361
|
+
elif keyword in ('items', 'prefixItems', 'additionalProperties', 'patternProperties', 'contains', '$defs'):
|
|
362
|
+
_header_params(value, path, params, True)
|
|
363
|
+
elif keyword in ('allOf', 'anyOf', 'oneOf', 'not', 'if', 'then', 'else', 'dependentSchemas'):
|
|
364
|
+
for each in cast(list[Any], value) if isinstance(value, list) else [value]:
|
|
365
|
+
_header_params(each, path, params, True)
|
|
366
|
+
if (name := schema_.get('x-mcp-header')) is None:
|
|
367
|
+
return
|
|
368
|
+
type = schema_.get('type')
|
|
369
|
+
if hidden or not path:
|
|
370
|
+
raise Pyjo.Exception.new(f'The x-mcp-header {name} is not of a property of the root')
|
|
371
|
+
if not isinstance(name, str) or not _TOKEN.fullmatch(name):
|
|
372
|
+
raise Pyjo.Exception.new(f'The x-mcp-header {name!r} is not a name of a header')
|
|
373
|
+
if type not in ('integer', 'string', 'boolean'):
|
|
374
|
+
raise Pyjo.Exception.new(f'The x-mcp-header {name} is not of an integer, a string or a boolean')
|
|
375
|
+
params.append((name, path, type))
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _plain(value: Any) -> Any:
|
|
379
|
+
# The value as JSON has it, of the types that Pyjo.JSON.Schema gives a schema
|
|
380
|
+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
381
|
+
return {field.name: _plain(getattr(value, field.name)) for field in dataclasses.fields(value)}
|
|
382
|
+
if isinstance(value, enum.Enum):
|
|
383
|
+
return _plain(value.value)
|
|
384
|
+
if isinstance(value, datetime.datetime | datetime.date | datetime.time):
|
|
385
|
+
return value.isoformat()
|
|
386
|
+
if isinstance(value, uuid.UUID):
|
|
387
|
+
return str(value)
|
|
388
|
+
if isinstance(value, dict):
|
|
389
|
+
return {str(key): _plain(item) for key, item in cast(dict[Any, Any], value).items()}
|
|
390
|
+
if isinstance(value, list | tuple | set | frozenset):
|
|
391
|
+
return [_plain(item) for item in cast(list[Any], value)]
|
|
392
|
+
if value is None or isinstance(value, bool | int | float | str):
|
|
393
|
+
return value
|
|
394
|
+
return Pyjo.JSON.decode_json(Pyjo.JSON.encode_json(value))
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
new = Pyjo_MCP_Tool.new
|
|
398
|
+
object = Pyjo_MCP_Tool
|
|
399
|
+
Tool = Pyjo_MCP_Tool
|
Pyjo/MCP/__init__.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pyjo.MCP - Model Context Protocol for Pyjoyment
|
|
3
|
+
===============================================
|
|
4
|
+
::
|
|
5
|
+
|
|
6
|
+
import Pyjo.MCP
|
|
7
|
+
|
|
8
|
+
print(Pyjo.MCP.PROTOCOL_VERSION)
|
|
9
|
+
|
|
10
|
+
# Pyjoyment.Lite
|
|
11
|
+
from Pyjo.MCP.Server import Server
|
|
12
|
+
from Pyjoyment.Lite import *
|
|
13
|
+
|
|
14
|
+
server = Server(name='calculator', version='1.0.0')
|
|
15
|
+
|
|
16
|
+
@server.tool
|
|
17
|
+
def add(a: int, b: int) -> int:
|
|
18
|
+
\"\"\"Add two numbers.\"\"\"
|
|
19
|
+
return a + b
|
|
20
|
+
|
|
21
|
+
plugin('AllowedHosts')
|
|
22
|
+
any('/mcp', server.to_action())
|
|
23
|
+
|
|
24
|
+
app.start()
|
|
25
|
+
|
|
26
|
+
:mod:`Pyjo.MCP` is the namespace of the `Model Context Protocol
|
|
27
|
+
<https://modelcontextprotocol.io>`_ for :mod:`Pyjoyment`, a server and a client
|
|
28
|
+
made with Pyjoyment, of the revision :data:`PROTOCOL_VERSION` of the
|
|
29
|
+
specification, whose core is stateless: every request carries its version and
|
|
30
|
+
the capabilities of its client in its ``_meta``, without a session.
|
|
31
|
+
|
|
32
|
+
:mod:`Pyjo.MCP.Server` is the server, whose tools, prompts, resources and
|
|
33
|
+
resource templates are :mod:`Pyjo.MCP.Tool`, :mod:`Pyjo.MCP.Prompt`,
|
|
34
|
+
:mod:`Pyjo.MCP.Resource` and :mod:`Pyjo.MCP.ResourceTemplate` objects,
|
|
35
|
+
:mod:`Pyjo.MCP.Client` is the client, :mod:`Pyjo.MCP.Test` tests a server
|
|
36
|
+
with its requests, and the messages are those of :mod:`Pyjo.JSON.RPC`.
|
|
37
|
+
|
|
38
|
+
See also
|
|
39
|
+
--------
|
|
40
|
+
|
|
41
|
+
:mod:`Pyjo.MCP.Server`, :mod:`Pyjo.MCP.Tool`, :mod:`Pyjo.MCP.Prompt`,
|
|
42
|
+
:mod:`Pyjo.MCP.Resource`, :mod:`Pyjo.MCP.ResourceTemplate`,
|
|
43
|
+
:mod:`Pyjo.MCP.Client`, :mod:`Pyjo.MCP.Test`, :mod:`Pyjoyment`,
|
|
44
|
+
https://github.com/dex4er/Pyjoyment.
|
|
45
|
+
|
|
46
|
+
It is inspired by the distribution `MCP <https://metacpan.org/pod/MCP>`_ of
|
|
47
|
+
CPAN, built on Mojolicious.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
import importlib.metadata
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
__version__ = importlib.metadata.version('Pyjo-MCP')
|
|
54
|
+
"""The version of the distribution, of its metadata, which the workflow Automated version sets in pyproject.toml."""
|
|
55
|
+
|
|
56
|
+
PROTOCOL_VERSION = '2026-07-28'
|
|
57
|
+
"""The revision of the specification of the Model Context Protocol, as ``MCP-Protocol-Version`` names it."""
|
|
58
|
+
|
|
59
|
+
SUPPORTED_VERSIONS = (PROTOCOL_VERSION,)
|
|
60
|
+
"""The revisions that the server supports, as ``server/discover`` gives them."""
|
|
61
|
+
|
|
62
|
+
HEADER_MISMATCH = -32020
|
|
63
|
+
"""The code of the error of a header of HTTP that does not match the body of the request, or that is missing."""
|
|
64
|
+
MISSING_REQUIRED_CLIENT_CAPABILITY = -32021
|
|
65
|
+
"""The code of the error of a request that needs a capability that its client does not declare."""
|
|
66
|
+
UNSUPPORTED_PROTOCOL_VERSION = -32022
|
|
67
|
+
"""The code of the error of a request of a revision that the server does not support."""
|
|
68
|
+
|
|
69
|
+
META_CLIENT_CAPABILITIES = 'io.modelcontextprotocol/clientCapabilities'
|
|
70
|
+
"""The key of ``_meta`` of a request with the capabilities of its client."""
|
|
71
|
+
META_CLIENT_INFO = 'io.modelcontextprotocol/clientInfo'
|
|
72
|
+
"""The key of ``_meta`` of a request with the name and the version of its client."""
|
|
73
|
+
META_LOG_LEVEL = 'io.modelcontextprotocol/logLevel'
|
|
74
|
+
"""The key of ``_meta`` of a request with the level of the log messages that its client wants."""
|
|
75
|
+
META_PROTOCOL_VERSION = 'io.modelcontextprotocol/protocolVersion'
|
|
76
|
+
"""The key of ``_meta`` of a request with its revision of the specification."""
|
|
77
|
+
META_SERVER_INFO = 'io.modelcontextprotocol/serverInfo'
|
|
78
|
+
"""The key of ``_meta`` of a result with the name and the version of its server."""
|
|
79
|
+
META_SUBSCRIPTION_ID = 'io.modelcontextprotocol/subscriptionId'
|
|
80
|
+
"""The key of ``_meta`` of a message of a subscription with the ``id`` of its request ``subscriptions/listen``."""
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: Pyjo-MCP
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Model Context Protocol server and client for Pyjoyment.
|
|
5
|
+
Project-URL: Homepage, https://github.com/dex4er/Pyjo-MCP
|
|
6
|
+
Project-URL: Documentation, https://pyjoyment.readthedocs.io/projects/pyjo-mcp/en/latest/
|
|
7
|
+
Author-email: Piotr Roszatycki <piotr.roszatycki@gmail.com>
|
|
8
|
+
License-Expression: Artistic-2.0
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: mcp,model-context-protocol,pyjoyment
|
|
11
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Requires-Python: >=3.12
|
|
15
|
+
Requires-Dist: pyjoyment>=0.1.0
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
[](https://github.com/dex4er/Pyjoyment)
|
|
19
|
+
[](https://github.com/dex4er/Pyjo-MCP)
|
|
20
|
+
[](https://pyjoyment.readthedocs.io/projects/pyjo-mcp/en/latest/)
|
|
21
|
+
[](https://github.com/dex4er/Pyjo-MCP/blob/main/LICENSE)
|
|
22
|
+
[](https://github.com/dex4er/Pyjo-MCP/actions/workflows/tests.yaml)
|
|
23
|
+
|
|
24
|
+
# Pyjo-MCP
|
|
25
|
+
|
|
26
|
+
<p align="center">
|
|
27
|
+
<picture>
|
|
28
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/dex4er/Pyjoyment/main/Pyjoyment/resources/public/pyjo/logo-white-4x.png">
|
|
29
|
+
<img src="https://raw.githubusercontent.com/dex4er/Pyjoyment/main/Pyjoyment/resources/public/pyjo/logo-black-4x.png" alt="Pyjoyment" width="322">
|
|
30
|
+
</picture>
|
|
31
|
+
</p>
|
|
32
|
+
|
|
33
|
+
[Model Context Protocol](https://modelcontextprotocol.io) server and client
|
|
34
|
+
for [Pyjoyment](https://github.com/dex4er/Pyjoyment), of the revision
|
|
35
|
+
2026-07-28 of the specification.
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
From [PyPI](https://pypi.org/project/Pyjo-MCP/), with
|
|
40
|
+
[Pyjoyment](https://pypi.org/project/Pyjoyment/), which it depends on:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
pip install Pyjo-MCP
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
or, in a project managed with [uv](https://docs.astral.sh/uv/):
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
uv add Pyjo-MCP
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The development version is installed from the Git repository:
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
pip install git+https://github.com/dex4er/Pyjo-MCP
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Usage
|
|
59
|
+
|
|
60
|
+
A server of MCP is a route of an application of Pyjoyment, whose tools are
|
|
61
|
+
functions, with the schemas of their type hints and the descriptions of their
|
|
62
|
+
docstrings:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from Pyjo.MCP.Server import Server
|
|
66
|
+
from Pyjo.MCP.Tool import Error
|
|
67
|
+
from Pyjoyment.Lite import *
|
|
68
|
+
|
|
69
|
+
server = Server(name='calculator', version='1.0.0')
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@server.tool
|
|
73
|
+
def divide(a: float, b: float) -> float:
|
|
74
|
+
"""Divide the first number by the second one."""
|
|
75
|
+
if b == 0:
|
|
76
|
+
raise Error.new('Division by zero')
|
|
77
|
+
return a / b
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# Host and Origin of localhost only, against DNS rebinding
|
|
81
|
+
plugin('AllowedHosts')
|
|
82
|
+
|
|
83
|
+
any('/mcp', server.to_action())
|
|
84
|
+
|
|
85
|
+
app.start()
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
uv run python calculator.py daemon -l http://127.0.0.1:3000
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Its endpoint is `http://127.0.0.1:3000/mcp`, of the transport Streamable HTTP.
|
|
93
|
+
The whole calculator is [`examples/calculator.py`](examples/calculator.py).
|
|
94
|
+
|
|
95
|
+
Prompts, the templates of messages that a user picks, and resources, the data
|
|
96
|
+
that a client reads by its URI, are functions too:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
@server.prompt
|
|
100
|
+
def explain(expression: str) -> str:
|
|
101
|
+
"""Explain an arithmetic expression step by step."""
|
|
102
|
+
return f'Explain step by step how to compute {expression}.'
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@server.resource('calculator://precision', mime_type='application/json')
|
|
106
|
+
def precision() -> dict:
|
|
107
|
+
"""The precision of the numbers of the calculator."""
|
|
108
|
+
return {'digits': 15}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The same application serves the transport stdio too, with a hook:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
hook('before_server_start', server.to_stdio())
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
A client of MCP, as Claude Code, then starts it with the command `stdio`:
|
|
118
|
+
|
|
119
|
+
```sh
|
|
120
|
+
claude mcp add calculator -- uv run python examples/calculator.py stdio
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
[`examples/weather.py`](examples/weather.py) is a gateway to the weather of
|
|
124
|
+
[Open-Meteo](https://open-meteo.com), which needs no key, with the tools
|
|
125
|
+
`find_places`, `current_weather`, `forecast` and `hourly_forecast`, which
|
|
126
|
+
request the API with the non-blocking user agent of the application. A client of MCP, as Claude Code,
|
|
127
|
+
connects to it over HTTP:
|
|
128
|
+
|
|
129
|
+
```sh
|
|
130
|
+
uv run python examples/weather.py daemon -l http://127.0.0.1:3000
|
|
131
|
+
claude mcp add --transport http weather http://127.0.0.1:3000/mcp
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
[`examples/mcp_cli.py`](examples/mcp_cli.py) is a client of the command line
|
|
135
|
+
for the tools of any server of MCP: a tool is a command, in kebab-case, and its
|
|
136
|
+
arguments are options, with the descriptions and the constraints of its
|
|
137
|
+
`inputSchema` in the help of the command. The result is text, with tables of
|
|
138
|
+
the lists of objects, or JSON:
|
|
139
|
+
|
|
140
|
+
```sh
|
|
141
|
+
uv run python examples/mcp_cli.py --help
|
|
142
|
+
uv run python examples/mcp_cli.py forecast --place Kraków --language pl --days 2
|
|
143
|
+
uv run python examples/mcp_cli.py hourly-forecast -p Warsaw -H 6 -f json
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
It is made with `Pyjo.MCP.Client`, a client of MCP over Streamable HTTP, which
|
|
147
|
+
sends the `_meta` and the headers that every request carries:
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
from Pyjo.MCP.Client import Client
|
|
151
|
+
|
|
152
|
+
client = Client('http://127.0.0.1:3000/mcp')
|
|
153
|
+
print(client.call_tool('divide', {'a': 7, 'b': 2})['structuredContent'])
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`Pyjo.MCP.Test` tests a server with the requests of the client, without
|
|
157
|
+
starting it:
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
import Pyjo.MCP.Test
|
|
161
|
+
|
|
162
|
+
from Pyjo.File import curfile
|
|
163
|
+
from Pyjo.Test import *
|
|
164
|
+
|
|
165
|
+
t = Pyjo.MCP.Test.new(curfile().sibling('calculator.py'))
|
|
166
|
+
t.call_ok('divide', a=7, b=2).status_is(200).structured_content_is(3.5)
|
|
167
|
+
t.call_ok('divide', a=1, b=0).is_error_is(True).text_content_is('Division by zero')
|
|
168
|
+
|
|
169
|
+
done_testing()
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Documentation
|
|
173
|
+
|
|
174
|
+
The tutorial, which makes servers of MCP step by step, and the API of the package
|
|
175
|
+
are on [Read the Docs](https://pyjoyment.readthedocs.io/projects/pyjo-mcp/en/latest/).
|
|
176
|
+
|
|
177
|
+
## Status
|
|
178
|
+
|
|
179
|
+
A server of the tools, the prompts and the resources of MCP over Streamable
|
|
180
|
+
HTTP and stdio, with `server/discover`, `tools/list`, `tools/call`,
|
|
181
|
+
`prompts/list`, `prompts/get`, `resources/list`, `resources/templates/list`,
|
|
182
|
+
`resources/read`, `subscriptions/listen` and `completion/complete`, a client
|
|
183
|
+
of them over Streamable HTTP, and assertions of tests with its requests. A tool
|
|
184
|
+
sends its progress and its log messages before its result, and a subscription
|
|
185
|
+
gets the changes of the lists and of the resources. A resource template is a
|
|
186
|
+
URI Template of RFC 6570, whose URIs a client reads. A tool, a prompt and a
|
|
187
|
+
resource ask the user, the language model or the roots of the client for
|
|
188
|
+
input, in the rounds of a multi round-trip request, and the arguments of the
|
|
189
|
+
prompts and the variables of the templates complete their values.
|
|
190
|
+
|
|
191
|
+
## License
|
|
192
|
+
|
|
193
|
+
The Artistic License 2.0, as Pyjoyment.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Pyjo/MCP/Client.py,sha256=KJd-rQLzS7RBZymxsXnUd_YaE_oi80-sYAVBP9bdiFU,33292
|
|
2
|
+
Pyjo/MCP/Prompt.py,sha256=KsP46nlimkBYvYa5miniyvtC5EGee5lbPziwwVCtuwg,9338
|
|
3
|
+
Pyjo/MCP/Resource.py,sha256=RgA7t2qBemMqlfR4j4-Ok7NmfZfRQKPlCsmsdu2Q8Jc,6202
|
|
4
|
+
Pyjo/MCP/ResourceTemplate.py,sha256=DtoMGx7HwTOMVjFZtWtye2hc_mfXLgjQLBvRcQkkWbA,9247
|
|
5
|
+
Pyjo/MCP/Server.py,sha256=9f_GeEA8Q6T8NsOhXU-Qhyxm3IDsWhYXiuhFAeCnGpM,63892
|
|
6
|
+
Pyjo/MCP/Test.py,sha256=n9jcYICIVdoV2mPVcbaOf6Vpgdzbqw6hBVAQBdePFq0,31612
|
|
7
|
+
Pyjo/MCP/Tool.py,sha256=WlzTWm7iOBMMODzFJcnhZORoIkAVjSJbVDfpZEHsdE0,14689
|
|
8
|
+
Pyjo/MCP/__init__.py,sha256=zXw7rQlO8uYhgDxeZ-rVCIlnlFNG7G-jA9EX8FxuPaE,3365
|
|
9
|
+
pyjo_mcp-0.1.0.dist-info/METADATA,sha256=wsTqnrngC77eL7Hkd_v6rlw2s27k0x4_pEPOZ2DCAbQ,6843
|
|
10
|
+
pyjo_mcp-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
11
|
+
pyjo_mcp-0.1.0.dist-info/licenses/LICENSE,sha256=wqf_RI5i8Of0e_6xwFe7wBhw72jXAkF_9N8Ny9ScaP4,8894
|
|
12
|
+
pyjo_mcp-0.1.0.dist-info/RECORD,,
|