From 219255b49dd4b88aa905419ec53731be2ba55108 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:21:05 +0200 Subject: [PATCH] Add mk_mcp_from_refs(['module:function', ...]) + public import_object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One call from configuration strings to a runnable MCP server — what tools that read tool references from a file (e.g. coact's mcp realize backend) need, so they don't reimplement the importlib dance. - util.import_object(ref): resolve 'module:attr' (preferred) or 'module.path.attr' - main.mk_mcp_from_refs(refs, ...): resolve each ref and delegate to mk_mcp_server Additive; existing API unchanged. Tests + doctests green. Closes #1 --- py2mcp/__init__.py | 5 ++++- py2mcp/main.py | 24 +++++++++++++++++++++++- py2mcp/tests/test_basic.py | 34 +++++++++++++++++++++++++++++++++- py2mcp/util.py | 30 +++++++++++++++++++++++++++++- 4 files changed, 89 insertions(+), 4 deletions(-) diff --git a/py2mcp/__init__.py b/py2mcp/__init__.py index d933d44..f1a1dd1 100644 --- a/py2mcp/__init__.py +++ b/py2mcp/__init__.py @@ -15,13 +15,16 @@ >>> # mcp.run() # Start the server """ -from py2mcp.main import mk_mcp_server, mk_mcp_from_store +from py2mcp.main import mk_mcp_server, mk_mcp_from_store, mk_mcp_from_refs from py2mcp.trans import mk_input_trans +from py2mcp.util import import_object __version__ = "0.1.0" __all__ = [ "mk_mcp_server", "mk_mcp_from_store", + "mk_mcp_from_refs", "mk_input_trans", + "import_object", ] diff --git a/py2mcp/main.py b/py2mcp/main.py index 6988b98..a048e30 100644 --- a/py2mcp/main.py +++ b/py2mcp/main.py @@ -4,7 +4,7 @@ from fastmcp import FastMCP from py2mcp.base import _normalize_to_iterable, _wrap_with_input_trans -from py2mcp.util import store_to_funcs +from py2mcp.util import import_object, store_to_funcs def mk_mcp_server( @@ -57,6 +57,28 @@ def mk_mcp_server( return mcp +def mk_mcp_from_refs( + refs: Iterable[str], + *, + name: str = "py2mcp Server", + input_trans: Optional[Callable[[dict], dict]] = None, +) -> FastMCP: + """Create an MCP server from ``'module:function'`` reference strings. + + Resolves each reference to a callable via :func:`py2mcp.util.import_object` + and delegates to :func:`mk_mcp_server`. One call from config strings to a + runnable server — what tools that read tool references from a file (e.g. + ``coact``'s ``mcp`` backend) need. + + Examples: + >>> mcp = mk_mcp_from_refs(['os.path:basename', 'os.path:dirname'], name='Paths') + >>> mcp.name + 'Paths' + """ + funcs = [import_object(ref) for ref in refs] + return mk_mcp_server(funcs, name=name, input_trans=input_trans) + + def mk_mcp_from_store( store: MutableMapping[Any, Any], *, diff --git a/py2mcp/tests/test_basic.py b/py2mcp/tests/test_basic.py index f9a5991..23518b9 100644 --- a/py2mcp/tests/test_basic.py +++ b/py2mcp/tests/test_basic.py @@ -3,7 +3,13 @@ import pytest import asyncio -from py2mcp import mk_mcp_server, mk_mcp_from_store, mk_input_trans +from py2mcp import ( + mk_mcp_server, + mk_mcp_from_store, + mk_mcp_from_refs, + mk_input_trans, + import_object, +) # --------------------------------------------------------------------------- @@ -237,5 +243,31 @@ def compute(x: int, y: int) -> int: assert _call(mcp, 'compute', {'x': 3, 'y': 1}) == 31 +# --------------------------------------------------------------------------- +# Reference resolution +# --------------------------------------------------------------------------- + + +def test_import_object_colon_and_dotted(): + import os.path + + assert import_object('os.path:basename') is os.path.basename + assert import_object('os.path.basename') is os.path.basename + + +def test_import_object_invalid_raises(): + with pytest.raises(ValueError): + import_object('no-separator') + + +def test_mk_mcp_from_refs_builds_and_calls(): + mcp = mk_mcp_from_refs(['os.path:basename'], name='Paths') + assert mcp.name == 'Paths' + tool = _run(mcp.get_tool('basename')) + assert tool.name == 'basename' + result = _run(mcp.call_tool('basename', {'p': '/a/b/c.txt'})) + assert result.content[0].text == 'c.txt' + + if __name__ == '__main__': pytest.main([__file__, '-v']) diff --git a/py2mcp/util.py b/py2mcp/util.py index 4b38875..41a1147 100644 --- a/py2mcp/util.py +++ b/py2mcp/util.py @@ -1,12 +1,40 @@ """General utilities for py2mcp.""" -from typing import MutableMapping, Callable, TypeVar +from importlib import import_module +from typing import Any, MutableMapping, Callable, TypeVar from collections.abc import Iterator KT = TypeVar("KT") VT = TypeVar("VT") +def import_object(ref: str) -> Any: + """Resolve a ``'module.path:attr'`` (preferred) or ``'module.path.attr'`` reference. + + Useful for building MCP servers from configuration strings (e.g. tool + references declared in a file), so callers don't reimplement the + ``importlib`` dance. + + >>> import_object('json:dumps') # doctest: +ELLIPSIS + + >>> import_object('os.path.join') # doctest: +ELLIPSIS + + """ + if ":" in ref: + module_name, _, attr = ref.partition(":") + else: + module_name, _, attr = ref.rpartition(".") + if not module_name or not attr: + raise ValueError( + f"Invalid object reference {ref!r}; expected 'module:attr' " + f"or 'module.path.attr'." + ) + obj = import_module(module_name) + for part in attr.split("."): + obj = getattr(obj, part) + return obj + + def _store_to_funcs( store: MutableMapping[KT, VT], *,