Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion py2mcp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
24 changes: 23 additions & 1 deletion py2mcp/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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],
*,
Expand Down
34 changes: 33 additions & 1 deletion py2mcp/tests/test_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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'])
30 changes: 29 additions & 1 deletion py2mcp/util.py
Original file line number Diff line number Diff line change
@@ -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
<function dumps at ...>
>>> import_object('os.path.join') # doctest: +ELLIPSIS
<function join at ...>
"""
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],
*,
Expand Down
Loading