py2mcp 0.1.1__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.
- py2mcp/__init__.py +27 -0
- py2mcp/base.py +49 -0
- py2mcp/main.py +91 -0
- py2mcp/tests/test_basic.py +241 -0
- py2mcp/trans.py +105 -0
- py2mcp/util.py +70 -0
- py2mcp-0.1.1.dist-info/METADATA +94 -0
- py2mcp-0.1.1.dist-info/RECORD +10 -0
- py2mcp-0.1.1.dist-info/WHEEL +4 -0
- py2mcp-0.1.1.dist-info/licenses/LICENSE +21 -0
py2mcp/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""py2mcp: Quick MCP server creation from Python functions.
|
|
2
|
+
|
|
3
|
+
This package provides a simple, Pythonic way to create Model Context Protocol (MCP)
|
|
4
|
+
servers from ordinary Python functions. Built on FastMCP, it handles all the protocol
|
|
5
|
+
complexity while letting you focus on your business logic.
|
|
6
|
+
|
|
7
|
+
Basic usage:
|
|
8
|
+
>>> from py2mcp import mk_mcp_server
|
|
9
|
+
>>>
|
|
10
|
+
>>> def add(a: int, b: int) -> int:
|
|
11
|
+
... '''Add two numbers'''
|
|
12
|
+
... return a + b
|
|
13
|
+
>>>
|
|
14
|
+
>>> mcp = mk_mcp_server([add])
|
|
15
|
+
>>> # mcp.run() # Start the server
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from py2mcp.main import mk_mcp_server, mk_mcp_from_store
|
|
19
|
+
from py2mcp.trans import mk_input_trans
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"mk_mcp_server",
|
|
25
|
+
"mk_mcp_from_store",
|
|
26
|
+
"mk_input_trans",
|
|
27
|
+
]
|
py2mcp/base.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Base objects and utilities for py2mcp."""
|
|
2
|
+
|
|
3
|
+
from typing import Callable, Iterable, Any, Optional
|
|
4
|
+
from functools import wraps
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _wrap_with_input_trans(func: Callable, input_trans: Optional[Callable]) -> Callable:
|
|
8
|
+
"""Wrap a function to apply input transformation.
|
|
9
|
+
|
|
10
|
+
>>> def double(x): return x * 2
|
|
11
|
+
>>> def add_one_trans(kwargs): return {k: v + 1 for k, v in kwargs.items()}
|
|
12
|
+
>>> wrapped = _wrap_with_input_trans(double, add_one_trans)
|
|
13
|
+
>>> wrapped(x=5)
|
|
14
|
+
12
|
|
15
|
+
"""
|
|
16
|
+
if input_trans is None:
|
|
17
|
+
return func
|
|
18
|
+
|
|
19
|
+
@wraps(func)
|
|
20
|
+
def wrapper(**kwargs):
|
|
21
|
+
transformed = input_trans(kwargs)
|
|
22
|
+
return func(**transformed)
|
|
23
|
+
|
|
24
|
+
# Preserve original function metadata for introspection
|
|
25
|
+
wrapper.__wrapped__ = func
|
|
26
|
+
return wrapper
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _normalize_to_iterable(funcs: Any) -> Iterable[Callable]:
|
|
30
|
+
"""Normalize input to an iterable of callables.
|
|
31
|
+
|
|
32
|
+
>>> def f(): pass
|
|
33
|
+
>>> def g(): pass
|
|
34
|
+
>>> list(_normalize_to_iterable(f))
|
|
35
|
+
[<function f at ...>]
|
|
36
|
+
>>> len(list(_normalize_to_iterable([f, g])))
|
|
37
|
+
2
|
|
38
|
+
"""
|
|
39
|
+
if callable(funcs):
|
|
40
|
+
return [funcs]
|
|
41
|
+
elif isinstance(funcs, Iterable):
|
|
42
|
+
result = list(funcs)
|
|
43
|
+
if not all(callable(f) for f in result):
|
|
44
|
+
raise TypeError("All items must be callable")
|
|
45
|
+
return result
|
|
46
|
+
else:
|
|
47
|
+
raise TypeError(
|
|
48
|
+
f"Expected callable or iterable of callables, got {type(funcs)}"
|
|
49
|
+
)
|
py2mcp/main.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Main entry point for creating MCP servers from Python functions."""
|
|
2
|
+
|
|
3
|
+
from typing import Callable, Iterable, Optional, MutableMapping, Any
|
|
4
|
+
from fastmcp import FastMCP
|
|
5
|
+
|
|
6
|
+
from py2mcp.base import _normalize_to_iterable, _wrap_with_input_trans
|
|
7
|
+
from py2mcp.util import store_to_funcs
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def mk_mcp_server(
|
|
11
|
+
funcs: Callable | Iterable[Callable],
|
|
12
|
+
*,
|
|
13
|
+
name: str = "py2mcp Server",
|
|
14
|
+
input_trans: Optional[Callable[[dict], dict]] = None,
|
|
15
|
+
) -> FastMCP:
|
|
16
|
+
"""Create an MCP server from Python functions.
|
|
17
|
+
|
|
18
|
+
This is the main entry point for py2mcp. Pass one or more functions,
|
|
19
|
+
and get back a FastMCP server ready to run.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
funcs: A function or iterable of functions to expose as MCP tools
|
|
23
|
+
name: Name of the MCP server
|
|
24
|
+
input_trans: Optional function to transform input kwargs before calling tools
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
A FastMCP server instance ready to run
|
|
28
|
+
|
|
29
|
+
Examples:
|
|
30
|
+
>>> def add(a: int, b: int) -> int:
|
|
31
|
+
... '''Add two numbers'''
|
|
32
|
+
... return a + b
|
|
33
|
+
>>> mcp = mk_mcp_server(add)
|
|
34
|
+
>>> mcp.name
|
|
35
|
+
'py2mcp Server'
|
|
36
|
+
|
|
37
|
+
>>> def greet(name: str) -> str:
|
|
38
|
+
... return f"Hello, {name}!"
|
|
39
|
+
>>> mcp = mk_mcp_server([add, greet], name="Math & Greetings")
|
|
40
|
+
>>> mcp.name
|
|
41
|
+
'Math & Greetings'
|
|
42
|
+
"""
|
|
43
|
+
mcp = FastMCP(name)
|
|
44
|
+
|
|
45
|
+
# Normalize to list of functions
|
|
46
|
+
func_list = list(_normalize_to_iterable(funcs))
|
|
47
|
+
|
|
48
|
+
# Register each function as a tool
|
|
49
|
+
for func in func_list:
|
|
50
|
+
# Wrap with input transformation if provided
|
|
51
|
+
if input_trans is not None:
|
|
52
|
+
func = _wrap_with_input_trans(func, input_trans)
|
|
53
|
+
|
|
54
|
+
# Register as MCP tool
|
|
55
|
+
mcp.tool(func)
|
|
56
|
+
|
|
57
|
+
return mcp
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def mk_mcp_from_store(
|
|
61
|
+
store: MutableMapping[Any, Any],
|
|
62
|
+
*,
|
|
63
|
+
name: str = "item",
|
|
64
|
+
plural: str = "",
|
|
65
|
+
server_name: Optional[str] = None,
|
|
66
|
+
) -> FastMCP:
|
|
67
|
+
"""Create an MCP server from a MutableMapping with CRUD operations.
|
|
68
|
+
|
|
69
|
+
Automatically generates list, get, set, and delete functions for the store.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
store: A MutableMapping to expose via MCP
|
|
73
|
+
name: Singular name for items (e.g., 'project', 'user')
|
|
74
|
+
plural: Plural form (defaults to name + 's')
|
|
75
|
+
server_name: Name of the MCP server (defaults to "{name} Store")
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
A FastMCP server with CRUD operations
|
|
79
|
+
|
|
80
|
+
Examples:
|
|
81
|
+
>>> projects = {'p1': {'name': 'Project 1'}, 'p2': {'name': 'Project 2'}}
|
|
82
|
+
>>> mcp = mk_mcp_from_store(projects, name='project')
|
|
83
|
+
>>> mcp.name
|
|
84
|
+
'project Store'
|
|
85
|
+
"""
|
|
86
|
+
if server_name is None:
|
|
87
|
+
server_name = f"{name} Store"
|
|
88
|
+
|
|
89
|
+
funcs = store_to_funcs(store, name=name, plural=plural)
|
|
90
|
+
|
|
91
|
+
return mk_mcp_server(funcs, name=server_name)
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Tests for py2mcp functionality."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
import asyncio
|
|
5
|
+
|
|
6
|
+
from py2mcp import mk_mcp_server, mk_mcp_from_store, mk_input_trans
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# ---------------------------------------------------------------------------
|
|
10
|
+
# Helpers
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
def _run(coro):
|
|
14
|
+
"""Run a coroutine synchronously."""
|
|
15
|
+
return asyncio.run(coro)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _call(mcp, tool_name, args=None):
|
|
19
|
+
"""Call a tool on an MCP server and return the structured result."""
|
|
20
|
+
result = _run(mcp.call_tool(tool_name, args or {}))
|
|
21
|
+
return result.structured_content.get('result', result.structured_content)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Unit tests
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_single_function():
|
|
30
|
+
"""Test creating an MCP server from a single function."""
|
|
31
|
+
def add(a: int, b: int) -> int:
|
|
32
|
+
return a + b
|
|
33
|
+
|
|
34
|
+
mcp = mk_mcp_server(add)
|
|
35
|
+
assert mcp is not None
|
|
36
|
+
assert mcp.name == "py2mcp Server"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_multiple_functions():
|
|
40
|
+
"""Test creating an MCP server from multiple functions."""
|
|
41
|
+
def add(a: int, b: int) -> int:
|
|
42
|
+
return a + b
|
|
43
|
+
|
|
44
|
+
def multiply(a: int, b: int) -> int:
|
|
45
|
+
return a * b
|
|
46
|
+
|
|
47
|
+
mcp = mk_mcp_server([add, multiply], name="Math Server")
|
|
48
|
+
assert mcp.name == "Math Server"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_input_transformation():
|
|
52
|
+
"""Test input transformation."""
|
|
53
|
+
trans = mk_input_trans({'x': int, 'y': float})
|
|
54
|
+
|
|
55
|
+
result = trans({'x': '42', 'y': '3.14', 'z': 'unchanged'})
|
|
56
|
+
assert result['x'] == 42
|
|
57
|
+
assert result['y'] == 3.14
|
|
58
|
+
assert result['z'] == 'unchanged'
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_input_trans_with_none():
|
|
62
|
+
"""Test that None input_trans works correctly."""
|
|
63
|
+
trans = mk_input_trans(None)
|
|
64
|
+
|
|
65
|
+
result = trans({'a': 1, 'b': 2})
|
|
66
|
+
assert result == {'a': 1, 'b': 2}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_store_to_mcp():
|
|
70
|
+
"""Test creating MCP server from a store."""
|
|
71
|
+
store = {'item1': 'value1', 'item2': 'value2'}
|
|
72
|
+
|
|
73
|
+
mcp = mk_mcp_from_store(store, name='item')
|
|
74
|
+
assert mcp.name == 'item Store'
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_store_operations():
|
|
78
|
+
"""Test that store operations work correctly."""
|
|
79
|
+
from py2mcp.util import store_to_funcs
|
|
80
|
+
|
|
81
|
+
store = {'a': 1, 'b': 2}
|
|
82
|
+
funcs = store_to_funcs(store, name='item')
|
|
83
|
+
|
|
84
|
+
assert len(funcs) == 4
|
|
85
|
+
|
|
86
|
+
func_names = [f.__name__ for f in funcs]
|
|
87
|
+
assert 'list_items' in func_names
|
|
88
|
+
assert 'get_item' in func_names
|
|
89
|
+
assert 'set_item' in func_names
|
|
90
|
+
assert 'delete_item' in func_names
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_store_list_operation():
|
|
94
|
+
"""Test list operation on store."""
|
|
95
|
+
from py2mcp.util import store_to_funcs
|
|
96
|
+
|
|
97
|
+
store = {'a': 1, 'b': 2, 'c': 3}
|
|
98
|
+
funcs = {f.__name__: f for f in store_to_funcs(store, name='item')}
|
|
99
|
+
|
|
100
|
+
items = funcs['list_items']()
|
|
101
|
+
assert set(items) == {'a', 'b', 'c'}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_store_get_operation():
|
|
105
|
+
"""Test get operation on store."""
|
|
106
|
+
from py2mcp.util import store_to_funcs
|
|
107
|
+
|
|
108
|
+
store = {'x': 100, 'y': 200}
|
|
109
|
+
funcs = {f.__name__: f for f in store_to_funcs(store, name='item')}
|
|
110
|
+
|
|
111
|
+
assert funcs['get_item']('x') == 100
|
|
112
|
+
assert funcs['get_item']('y') == 200
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_store_set_operation():
|
|
116
|
+
"""Test set operation on store."""
|
|
117
|
+
from py2mcp.util import store_to_funcs
|
|
118
|
+
|
|
119
|
+
store = {}
|
|
120
|
+
funcs = {f.__name__: f for f in store_to_funcs(store, name='item')}
|
|
121
|
+
|
|
122
|
+
funcs['set_item']('new_key', 'new_value')
|
|
123
|
+
assert store['new_key'] == 'new_value'
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_store_delete_operation():
|
|
127
|
+
"""Test delete operation on store."""
|
|
128
|
+
from py2mcp.util import store_to_funcs
|
|
129
|
+
|
|
130
|
+
store = {'to_delete': 'value'}
|
|
131
|
+
funcs = {f.__name__: f for f in store_to_funcs(store, name='item')}
|
|
132
|
+
|
|
133
|
+
funcs['delete_item']('to_delete')
|
|
134
|
+
assert 'to_delete' not in store
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_store_custom_plural():
|
|
138
|
+
"""Test store functions with custom plural form."""
|
|
139
|
+
from py2mcp.util import store_to_funcs
|
|
140
|
+
|
|
141
|
+
store = {'a': 1}
|
|
142
|
+
funcs = store_to_funcs(store, name='cactus', plural='cacti')
|
|
143
|
+
func_names = [f.__name__ for f in funcs]
|
|
144
|
+
assert func_names == ['list_cacti', 'get_cactus', 'set_cactus', 'delete_cactus']
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_invalid_input():
|
|
148
|
+
"""Test that invalid inputs raise appropriate errors."""
|
|
149
|
+
with pytest.raises(TypeError):
|
|
150
|
+
mk_mcp_server("not a function")
|
|
151
|
+
|
|
152
|
+
with pytest.raises(TypeError):
|
|
153
|
+
mk_mcp_server([lambda x: x, "not a function"])
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
# End-to-end tests (calling tools through the MCP server)
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_e2e_call_tool():
|
|
162
|
+
"""End-to-end: call a tool through the MCP server."""
|
|
163
|
+
def add(a: int, b: int) -> int:
|
|
164
|
+
"""Add two numbers."""
|
|
165
|
+
return a + b
|
|
166
|
+
|
|
167
|
+
mcp = mk_mcp_server(add)
|
|
168
|
+
assert _call(mcp, 'add', {'a': 3, 'b': 4}) == 7
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_e2e_multiple_tools():
|
|
172
|
+
"""End-to-end: register and call multiple tools."""
|
|
173
|
+
def add(a: int, b: int) -> int:
|
|
174
|
+
"""Add."""
|
|
175
|
+
return a + b
|
|
176
|
+
|
|
177
|
+
def multiply(a: int, b: int) -> int:
|
|
178
|
+
"""Multiply."""
|
|
179
|
+
return a * b
|
|
180
|
+
|
|
181
|
+
mcp = mk_mcp_server([add, multiply])
|
|
182
|
+
|
|
183
|
+
assert _call(mcp, 'add', {'a': 2, 'b': 3}) == 5
|
|
184
|
+
assert _call(mcp, 'multiply', {'a': 2, 'b': 3}) == 6
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_e2e_list_tools():
|
|
188
|
+
"""End-to-end: list registered tools."""
|
|
189
|
+
def greet(name: str) -> str:
|
|
190
|
+
"""Greet someone."""
|
|
191
|
+
return f"Hello, {name}!"
|
|
192
|
+
|
|
193
|
+
def farewell(name: str) -> str:
|
|
194
|
+
"""Say goodbye."""
|
|
195
|
+
return f"Goodbye, {name}!"
|
|
196
|
+
|
|
197
|
+
mcp = mk_mcp_server([greet, farewell])
|
|
198
|
+
tools = _run(mcp.list_tools())
|
|
199
|
+
tool_names = {t.name for t in tools}
|
|
200
|
+
assert tool_names == {'greet', 'farewell'}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_e2e_store_crud():
|
|
204
|
+
"""End-to-end: full CRUD cycle through the MCP server."""
|
|
205
|
+
store = {'a': 1, 'b': 2}
|
|
206
|
+
mcp = mk_mcp_from_store(store, name='item')
|
|
207
|
+
|
|
208
|
+
# List
|
|
209
|
+
keys = _call(mcp, 'list_items')
|
|
210
|
+
assert set(keys) == {'a', 'b'}
|
|
211
|
+
|
|
212
|
+
# Get
|
|
213
|
+
assert _call(mcp, 'get_item', {'key': 'a'}) == 1
|
|
214
|
+
|
|
215
|
+
# Set
|
|
216
|
+
_call(mcp, 'set_item', {'key': 'c', 'value': 3})
|
|
217
|
+
assert store['c'] == 3
|
|
218
|
+
|
|
219
|
+
# Delete
|
|
220
|
+
_call(mcp, 'delete_item', {'key': 'a'})
|
|
221
|
+
assert 'a' not in store
|
|
222
|
+
|
|
223
|
+
# List after mutations
|
|
224
|
+
keys = _call(mcp, 'list_items')
|
|
225
|
+
assert set(keys) == {'b', 'c'}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_e2e_input_trans():
|
|
229
|
+
"""End-to-end: input transformation through the MCP server."""
|
|
230
|
+
def compute(x: int, y: int) -> int:
|
|
231
|
+
"""Compute x + y after transformation."""
|
|
232
|
+
return x + y
|
|
233
|
+
|
|
234
|
+
trans = mk_input_trans({'x': lambda v: v * 10})
|
|
235
|
+
mcp = mk_mcp_server(compute, input_trans=trans)
|
|
236
|
+
|
|
237
|
+
assert _call(mcp, 'compute', {'x': 3, 'y': 1}) == 31
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
if __name__ == '__main__':
|
|
241
|
+
pytest.main([__file__, '-v'])
|
py2mcp/trans.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Input and output transformation utilities for py2mcp."""
|
|
2
|
+
|
|
3
|
+
from typing import Callable, Mapping, Iterable, Optional, Iterator
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _name_func_pairs_from_mapping(
|
|
7
|
+
name_func_relationships: Mapping,
|
|
8
|
+
) -> Iterator[tuple[str, Callable]]:
|
|
9
|
+
"""Generate (name, func) pairs from various mapping formats.
|
|
10
|
+
|
|
11
|
+
Supports:
|
|
12
|
+
- {name: func} - standard mapping
|
|
13
|
+
- {name: [func1, func2]} - one name, multiple functions
|
|
14
|
+
- {func: name} - reversed mapping
|
|
15
|
+
- {func: [name1, name2]} - one function, multiple names
|
|
16
|
+
|
|
17
|
+
>>> def double(x): return x * 2
|
|
18
|
+
>>> list(_name_func_pairs_from_mapping({'x': double}))
|
|
19
|
+
[('x', <function double at ...>)]
|
|
20
|
+
"""
|
|
21
|
+
for k, v in name_func_relationships.items():
|
|
22
|
+
if isinstance(k, str):
|
|
23
|
+
name = k
|
|
24
|
+
if callable(v):
|
|
25
|
+
yield name, v
|
|
26
|
+
elif isinstance(v, Iterable):
|
|
27
|
+
for func in v:
|
|
28
|
+
if not callable(func):
|
|
29
|
+
raise TypeError(f"Expected callable, got {type(func)}")
|
|
30
|
+
yield name, func
|
|
31
|
+
else:
|
|
32
|
+
raise TypeError(f"Value must be callable or iterable of callables: {v}")
|
|
33
|
+
elif callable(k):
|
|
34
|
+
func = k
|
|
35
|
+
if isinstance(v, str):
|
|
36
|
+
yield v, func
|
|
37
|
+
elif isinstance(v, Iterable):
|
|
38
|
+
for name in v:
|
|
39
|
+
if not isinstance(name, str):
|
|
40
|
+
raise TypeError(f"Expected string, got {type(name)}")
|
|
41
|
+
yield name, func
|
|
42
|
+
else:
|
|
43
|
+
raise TypeError(f"Value must be string or iterable of strings: {v}")
|
|
44
|
+
else:
|
|
45
|
+
raise TypeError(f"Key must be string or callable: {k}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _to_name_func_map(name_func_relationships: Mapping) -> dict[str, Callable]:
|
|
49
|
+
"""Convert name-func relationships to a simple name->func mapping.
|
|
50
|
+
|
|
51
|
+
>>> def double(x): return x * 2
|
|
52
|
+
>>> _to_name_func_map({'x': double})
|
|
53
|
+
{'x': <function double at ...>}
|
|
54
|
+
"""
|
|
55
|
+
if not isinstance(name_func_relationships, Mapping):
|
|
56
|
+
raise TypeError(
|
|
57
|
+
f"Expected Mapping of name:func or func:name pairs, got {type(name_func_relationships)}"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
pairs = list(_name_func_pairs_from_mapping(name_func_relationships))
|
|
61
|
+
result = dict(pairs)
|
|
62
|
+
|
|
63
|
+
if len(result) != len(pairs):
|
|
64
|
+
raise ValueError("Duplicate names found in relationships")
|
|
65
|
+
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _apply_transformations(
|
|
70
|
+
kwargs: dict, name_func_map: Mapping[str, Callable]
|
|
71
|
+
) -> Iterator[tuple[str, any]]:
|
|
72
|
+
"""Apply transformations to matching kwargs.
|
|
73
|
+
|
|
74
|
+
>>> def double(x): return x * 2
|
|
75
|
+
>>> transforms = {'a': double}
|
|
76
|
+
>>> dict(_apply_transformations({'a': 5, 'b': 10}, transforms))
|
|
77
|
+
{'a': 10, 'b': 10}
|
|
78
|
+
"""
|
|
79
|
+
for name, value in kwargs.items():
|
|
80
|
+
if name in name_func_map:
|
|
81
|
+
yield name, name_func_map[name](value)
|
|
82
|
+
else:
|
|
83
|
+
yield name, value
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def mk_input_trans(
|
|
87
|
+
name_func_relationships: Optional[Mapping] = None,
|
|
88
|
+
) -> Callable[[dict], dict]:
|
|
89
|
+
"""Create an input transformation function from name->func mappings.
|
|
90
|
+
|
|
91
|
+
>>> def to_int(x): return int(x)
|
|
92
|
+
>>> trans = mk_input_trans({'x': to_int})
|
|
93
|
+
>>> trans({'x': '42', 'y': 'hello'})
|
|
94
|
+
{'x': 42, 'y': 'hello'}
|
|
95
|
+
"""
|
|
96
|
+
if name_func_relationships is None:
|
|
97
|
+
return lambda kwargs: dict(kwargs)
|
|
98
|
+
|
|
99
|
+
name_func_map = _to_name_func_map(name_func_relationships)
|
|
100
|
+
|
|
101
|
+
def input_trans(kwargs: dict) -> dict:
|
|
102
|
+
"""Transform input kwargs according to the mapping."""
|
|
103
|
+
return dict(_apply_transformations(kwargs, name_func_map))
|
|
104
|
+
|
|
105
|
+
return input_trans
|
py2mcp/util.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""General utilities for py2mcp."""
|
|
2
|
+
|
|
3
|
+
from typing import MutableMapping, Callable, TypeVar
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
|
|
6
|
+
KT = TypeVar("KT")
|
|
7
|
+
VT = TypeVar("VT")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _store_to_funcs(
|
|
11
|
+
store: MutableMapping[KT, VT],
|
|
12
|
+
*,
|
|
13
|
+
singular: str = "item",
|
|
14
|
+
plural: str = "",
|
|
15
|
+
) -> Iterator[tuple[str, Callable]]:
|
|
16
|
+
"""Generate CRUD functions from a MutableMapping.
|
|
17
|
+
|
|
18
|
+
>>> store = {'a': 1, 'b': 2}
|
|
19
|
+
>>> funcs = dict(_store_to_funcs(store, singular='item'))
|
|
20
|
+
>>> sorted(funcs['list_items']())
|
|
21
|
+
['a', 'b']
|
|
22
|
+
>>> funcs['get_item']('a')
|
|
23
|
+
1
|
|
24
|
+
"""
|
|
25
|
+
plural = plural or f"{singular}s"
|
|
26
|
+
|
|
27
|
+
def list_items() -> list[KT]:
|
|
28
|
+
"""List all keys."""
|
|
29
|
+
return list(store.keys())
|
|
30
|
+
|
|
31
|
+
def get_item(key: KT) -> VT:
|
|
32
|
+
"""Get a value by key."""
|
|
33
|
+
return store[key]
|
|
34
|
+
|
|
35
|
+
def set_item(key: KT, value: VT) -> str:
|
|
36
|
+
"""Set a value."""
|
|
37
|
+
store[key] = value
|
|
38
|
+
return f"Set {singular} '{key}'"
|
|
39
|
+
|
|
40
|
+
def delete_item(key: KT) -> str:
|
|
41
|
+
"""Delete a value."""
|
|
42
|
+
del store[key]
|
|
43
|
+
return f"Deleted {singular} '{key}'"
|
|
44
|
+
|
|
45
|
+
for func, func_name in [
|
|
46
|
+
(list_items, f"list_{plural}"),
|
|
47
|
+
(get_item, f"get_{singular}"),
|
|
48
|
+
(set_item, f"set_{singular}"),
|
|
49
|
+
(delete_item, f"delete_{singular}"),
|
|
50
|
+
]:
|
|
51
|
+
func.__name__ = func_name
|
|
52
|
+
yield func_name, func
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def store_to_funcs(
|
|
56
|
+
store: MutableMapping[KT, VT],
|
|
57
|
+
*,
|
|
58
|
+
name: str = "item",
|
|
59
|
+
plural: str = "",
|
|
60
|
+
) -> list[Callable]:
|
|
61
|
+
"""Convert a MutableMapping into CRUD functions.
|
|
62
|
+
|
|
63
|
+
>>> projects = {'p1': {'name': 'Project 1'}}
|
|
64
|
+
>>> funcs = store_to_funcs(projects, name='project')
|
|
65
|
+
>>> len(funcs)
|
|
66
|
+
4
|
|
67
|
+
>>> [f.__name__ for f in funcs]
|
|
68
|
+
['list_projects', 'get_project', 'set_project', 'delete_project']
|
|
69
|
+
"""
|
|
70
|
+
return [func for _, func in _store_to_funcs(store, singular=name, plural=plural)]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: py2mcp
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Quick MCP server creation from Python functions
|
|
5
|
+
Project-URL: Homepage, https://github.com/i2mint/py2mcp
|
|
6
|
+
Project-URL: Repository, https://github.com/i2mint/py2mcp
|
|
7
|
+
Project-URL: Documentation, https://i2mint.github.io/py2mcp
|
|
8
|
+
Author: Thor Whalen
|
|
9
|
+
License: mit
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai,llm,mcp,model-context-protocol
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Requires-Dist: fastmcp
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
16
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
17
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
18
|
+
Provides-Extra: docs
|
|
19
|
+
Requires-Dist: sphinx-rtd-theme>=1.0; extra == 'docs'
|
|
20
|
+
Requires-Dist: sphinx>=6.0; extra == 'docs'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# py2mcp
|
|
24
|
+
|
|
25
|
+
Quick MCP (Model Context Protocol) server creation from Python functions.
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install py2mcp
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quick Start
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from py2mcp import mk_mcp_server
|
|
37
|
+
|
|
38
|
+
def add(a: int, b: int) -> int:
|
|
39
|
+
"""Add two numbers"""
|
|
40
|
+
return a + b
|
|
41
|
+
|
|
42
|
+
def greet(name: str = "world") -> str:
|
|
43
|
+
"""Greet someone"""
|
|
44
|
+
return f"Hello, {name}!"
|
|
45
|
+
|
|
46
|
+
# Create and run MCP server
|
|
47
|
+
mcp = mk_mcp_server([add, greet])
|
|
48
|
+
|
|
49
|
+
if __name__ == '__main__':
|
|
50
|
+
mcp.run()
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
That's it! Your functions are now available as MCP tools.
|
|
54
|
+
|
|
55
|
+
## Features
|
|
56
|
+
|
|
57
|
+
- **Simple**: Just pass functions to `mk_mcp_server()`
|
|
58
|
+
- **Flexible**: Supports input/output transformations
|
|
59
|
+
- **Pythonic**: Clean, decorator-free function definitions
|
|
60
|
+
- **Powerful**: Built on FastMCP for production-ready servers
|
|
61
|
+
|
|
62
|
+
## Input Transformations
|
|
63
|
+
|
|
64
|
+
Transform inputs before they reach your functions:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from py2mcp import mk_mcp_server, mk_input_trans
|
|
68
|
+
import numpy as np
|
|
69
|
+
|
|
70
|
+
def add_arrays(a, b):
|
|
71
|
+
"""Add two numpy arrays"""
|
|
72
|
+
return (a + b).tolist()
|
|
73
|
+
|
|
74
|
+
# Convert list inputs to numpy arrays
|
|
75
|
+
input_trans = mk_input_trans({'a': np.array, 'b': np.array})
|
|
76
|
+
mcp = mk_mcp_server([add_arrays], input_trans=input_trans)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## From Stores (MutableMapping)
|
|
80
|
+
|
|
81
|
+
Automatically expose CRUD operations from any mapping:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from py2mcp import mk_mcp_from_store
|
|
85
|
+
|
|
86
|
+
projects = {'proj1': {'name': 'Project 1'}, 'proj2': {'name': 'Project 2'}}
|
|
87
|
+
mcp = mk_mcp_from_store(projects, name="project")
|
|
88
|
+
|
|
89
|
+
# Automatically creates: list_projects, get_project, set_project, delete_project
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## License
|
|
93
|
+
|
|
94
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
py2mcp/__init__.py,sha256=O6zE924L-4RGq00DxbVaXVXMn7l7eITQfkTLTM41Dj0,743
|
|
2
|
+
py2mcp/base.py,sha256=uFYe9dN-2Yr0MRV2Dzv0sT3M5awUwPhRoXA_SYwdpLg,1426
|
|
3
|
+
py2mcp/main.py,sha256=ZYjZWZhRFkvtZjwOxVda07GuXKkc7Qg742nH76tTSrE,2739
|
|
4
|
+
py2mcp/trans.py,sha256=pj53X58DIE2ToaY2z3ZM6673d53kEAI-YfTsvs6BOFE,3571
|
|
5
|
+
py2mcp/util.py,sha256=VNk_akypXYH3FqVVuxFEG897KyjQOnxoXZtQ4iflmf8,1853
|
|
6
|
+
py2mcp/tests/test_basic.py,sha256=F-bVB-VLXPYlVC557Yocmkm009yA_6Ol5_T3NUUNw0U,6642
|
|
7
|
+
py2mcp-0.1.1.dist-info/METADATA,sha256=5fvf_4AxUvW1QXRehHERbXTq9MpJoKq9Cq22XblXHTM,2283
|
|
8
|
+
py2mcp-0.1.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
9
|
+
py2mcp-0.1.1.dist-info/licenses/LICENSE,sha256=ekE13yCE9fWWEJfP1NPnUJabZ_Wwrc6coKJV2ZZx27w,1068
|
|
10
|
+
py2mcp-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Thor Whalen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|