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
22 changes: 21 additions & 1 deletion py2mcp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,35 @@
"""

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",
"mk_mcp_from_store",
"mk_mcp_from_refs",
"mk_input_trans",
"import_object",
"serve_stdio",
"resolve_server_config",
"load_server_config",
]
10 changes: 10 additions & 0 deletions py2mcp/__main__.py
Original file line number Diff line number Diff line change
@@ -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()
127 changes: 127 additions & 0 deletions py2mcp/serve.py
Original file line number Diff line number Diff line change
@@ -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()
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
60 changes: 60 additions & 0 deletions tests/test_serve.py
Original file line number Diff line number Diff line change
@@ -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"])
Loading