diff --git a/py2mcp/__init__.py b/py2mcp/__init__.py index f1a1dd1..290c6ef 100644 --- a/py2mcp/__init__.py +++ b/py2mcp/__init__.py @@ -16,10 +16,27 @@ """ from py2mcp.main import mk_mcp_server, mk_mcp_from_store, mk_mcp_from_refs +from py2mcp.serve import serve_stdio, resolve_server_config, load_server_config from py2mcp.trans import mk_input_trans from py2mcp.util import import_object -__version__ = "0.1.0" + +def _resolve_version() -> str: + """Read the installed distribution version (SSOT = pyproject), else a sentinel. + + Sourcing ``__version__`` from installed metadata keeps it in step with + ``pyproject.toml`` (which wads bumps on release) instead of a hand-edited + literal that silently drifts. + """ + from importlib.metadata import PackageNotFoundError, version + + try: + return version("py2mcp") + except PackageNotFoundError: # pragma: no cover - only in an uninstalled tree + return "0.0.0+unknown" + + +__version__ = _resolve_version() __all__ = [ "mk_mcp_server", @@ -27,4 +44,7 @@ "mk_mcp_from_refs", "mk_input_trans", "import_object", + "serve_stdio", + "resolve_server_config", + "load_server_config", ] diff --git a/py2mcp/__main__.py b/py2mcp/__main__.py new file mode 100644 index 0000000..14a3c8a --- /dev/null +++ b/py2mcp/__main__.py @@ -0,0 +1,10 @@ +"""``python -m py2mcp`` — serve an MCP server over stdio from refs/config. + +See :mod:`py2mcp.serve`. This is the command a packaged integration (e.g. a +Claude Desktop ``.mcpb`` bundle) launches to run the server. +""" + +from py2mcp.serve import main + +if __name__ == "__main__": + main() diff --git a/py2mcp/serve.py b/py2mcp/serve.py new file mode 100644 index 0000000..bcdb008 --- /dev/null +++ b/py2mcp/serve.py @@ -0,0 +1,127 @@ +"""Serve a py2mcp ``FastMCP`` server over **stdio** — the runner a packaged +integration launches. + +:func:`py2mcp.mk_mcp_server` / :func:`py2mcp.mk_mcp_from_refs` build a server +*object* but deliberately leave *running* it to the caller (the only run hint in +``main`` is a comment). This module adds the thin "actually serve it over stdio" +layer plus a small JSON-config loader, so a one-click bundle (e.g. a Claude +Desktop ``.mcpb`` Desktop Extension) can point its ``manifest.json`` at:: + + python -m py2mcp --config ${__dirname}/server/py2mcp_config.json + +and get a live MCP server. The config is just ``{"name": ..., "refs": [...]}`` +where each ref is a ``'module:function'`` string resolved by +:func:`py2mcp.util.import_object`. + +stdio note: an MCP stdio server speaks newline-delimited JSON-RPC on +stdout, so nothing else may be written there. ``FastMCP``'s ``run`` handles this; +keep application logging on stderr. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any, Callable, Iterable, Optional + +from py2mcp.main import mk_mcp_from_refs + +#: Default server name when neither a config nor ``--name`` supplies one. +DFLT_SERVER_NAME = "py2mcp Server" + + +def load_server_config(path: str | Path) -> dict: + """Load a server config JSON of the form ``{"name": str, "refs": [str, ...]}``. + + Raises ``ValueError`` if the file is not a JSON object carrying a ``refs`` + list — an actionable error beats a server that starts with no tools. + """ + data = json.loads(Path(path).read_text()) + if not isinstance(data, dict) or not isinstance(data.get("refs"), list): + raise ValueError( + f"py2mcp server config {str(path)!r} must be a JSON object with a " + '"refs" list, e.g. {"name": "My Tools", "refs": ["pkg.mod:func"]}.' + ) + return data + + +def resolve_server_config( + *, + config: Optional[str | Path] = None, + refs: Iterable[str] = (), + name: Optional[str] = None, +) -> tuple[list[str], str]: + """Merge a config file and explicit ``refs``/``name`` into ``(refs, name)``. + + Refs from ``--ref`` are appended after any from the config file; an explicit + ``name`` wins over the config's. Pure (no I/O beyond reading ``config``), so + it is unit-testable without standing up a server. + + >>> resolve_server_config(refs=['os.path:basename'], name='Paths') + (['os.path:basename'], 'Paths') + """ + cfg_refs: list[str] = [] + cfg_name: Optional[str] = None + if config is not None: + cfg = load_server_config(config) + cfg_refs = list(cfg.get("refs") or []) + cfg_name = cfg.get("name") + merged_refs = cfg_refs + list(refs) + if not merged_refs: + raise ValueError( + "No tool references to serve. Provide a --config file with a 'refs' " + "list and/or one or more --ref 'module:function' arguments." + ) + return merged_refs, (name or cfg_name or DFLT_SERVER_NAME) + + +def serve_stdio( + refs: Iterable[str], + *, + name: str = DFLT_SERVER_NAME, + input_trans: Optional[Callable[[dict], dict]] = None, +) -> None: + """Build an MCP server from ``'module:function'`` refs and run it over stdio. + + Blocks, serving the MCP protocol on stdin/stdout until the host disconnects. + Thin wrapper over :func:`py2mcp.mk_mcp_from_refs` + ``FastMCP.run`` so that + packaged integrations have one command to launch. + """ + server = mk_mcp_from_refs(refs, name=name, input_trans=input_trans) + server.run(transport="stdio") + + +def main(argv: Optional[list[str]] = None) -> None: + """CLI: ``python -m py2mcp --config cfg.json`` (or ``--ref mod:func ...``).""" + parser = argparse.ArgumentParser( + prog="py2mcp", + description="Serve an MCP server over stdio from 'module:function' refs.", + ) + parser.add_argument( + "--config", + help="Path to a JSON config: {\"name\": str, \"refs\": [\"module:function\", ...]}.", + ) + parser.add_argument( + "--ref", + action="append", + dest="refs", + default=[], + metavar="module:function", + help="A tool reference to expose (repeatable; merged after --config refs).", + ) + parser.add_argument( + "--name", default=None, help="Server name (overrides the config's name)." + ) + args = parser.parse_args(argv) + try: + refs, name = resolve_server_config( + config=args.config, refs=args.refs, name=args.name + ) + except (ValueError, OSError) as e: + parser.error(str(e)) + serve_stdio(refs, name=name) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index e07ccdd..d526ef5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,9 @@ Homepage = "https://github.com/i2mint/py2mcp" Repository = "https://github.com/i2mint/py2mcp" Documentation = "https://i2mint.github.io/py2mcp" +[project.scripts] +py2mcp = "py2mcp.serve:main" + [project.optional-dependencies] dev = [ "pytest>=7.0", diff --git a/tests/test_serve.py b/tests/test_serve.py new file mode 100644 index 0000000..c0c78a5 --- /dev/null +++ b/tests/test_serve.py @@ -0,0 +1,60 @@ +"""Tests for the stdio serve runner's pure config-resolution helpers. + +The blocking ``serve_stdio`` (which runs a live server) is not exercised here; +the unit-testable surface is ``resolve_server_config`` / ``load_server_config``. +""" + +import json + +import pytest + +from py2mcp.serve import load_server_config, resolve_server_config + + +def test_resolve_explicit_refs(): + refs, name = resolve_server_config(refs=["os.path:basename"], name="Paths") + assert refs == ["os.path:basename"] + assert name == "Paths" + + +def test_resolve_default_name(): + refs, name = resolve_server_config(refs=["a:b"]) + assert refs == ["a:b"] + assert name == "py2mcp Server" + + +def test_resolve_from_config(tmp_path): + cfg = tmp_path / "c.json" + cfg.write_text(json.dumps({"name": "Cfg", "refs": ["a:b"]})) + refs, name = resolve_server_config(config=str(cfg)) + assert refs == ["a:b"] + assert name == "Cfg" + + +def test_config_refs_then_cli_refs_and_name_override(tmp_path): + cfg = tmp_path / "c.json" + cfg.write_text(json.dumps({"name": "Cfg", "refs": ["a:b"]})) + refs, name = resolve_server_config(config=str(cfg), refs=["c:d"], name="Override") + assert refs == ["a:b", "c:d"] + assert name == "Override" + + +def test_no_refs_raises(): + with pytest.raises(ValueError): + resolve_server_config() + + +def test_load_bad_config_raises(tmp_path): + cfg = tmp_path / "bad.json" + cfg.write_text(json.dumps({"name": "x"})) # missing the 'refs' list + with pytest.raises(ValueError): + load_server_config(str(cfg)) + + +def test_main_missing_config_is_clean_error(): + # a missing/unreadable bundled config must exit cleanly (argparse error), + # not dump a raw OSError traceback (the .mcpb launches this command). + from py2mcp.serve import main + + with pytest.raises(SystemExit): + main(["--config", "/nonexistent/py2mcp_config.json"])