diff --git a/pyproject.toml b/pyproject.toml index 2078003..c1163da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "soup-cli" -version = "0.71.27" +version = "0.71.28" description = "Fine-tune and post-train LLMs in one command. No SSH, no config hell." readme = "README.md" license = "Apache-2.0" @@ -47,14 +47,14 @@ train = [ "accelerate>=0.25.0", ] # v0.71.0 — convenience meta-extra pulling the main optional stacks. -all = ["soup-cli[train,serve,ui,data]"] +all = ["soup-cli[train,serve,ui,data,mcp]"] eval = ["lm-eval>=0.4.0"] data = ["datasketch>=1.6.0"] wandb = ["wandb>=0.15.0,<0.18.0"] # Self-references `[train]` so CI / contributors get the full training stack # (CI runs `pip install -e ".[dev]"`; without this every test would fail at # `import torch`). -dev = ["soup-cli[train]", "cryptography>=41.0.0", "reportlab>=4.0.0", "pytest>=7.0", "ruff>=0.1.0", "pytest-cov>=4.0", "httpx>=0.24.0", "mypy>=1.8.0", "pre-commit>=3.5.0"] +dev = ["soup-cli[train,mcp]", "cryptography>=41.0.0", "reportlab>=4.0.0", "pytest>=7.0", "ruff>=0.1.0", "pytest-cov>=4.0", "httpx>=0.24.0", "mypy>=1.8.0", "pre-commit>=3.5.0"] ui = ["fastapi>=0.104.0", "uvicorn>=0.24.0"] serve = ["fastapi>=0.104.0", "uvicorn>=0.24.0"] serve-fast = ["vllm>=0.4.0", "fastapi>=0.104.0", "uvicorn>=0.24.0"] @@ -102,6 +102,10 @@ compile = ["dspy-ai>=2.5.0", "textgrad>=0.1.0", "gepa>=0.0.1"] # Lazy-imported; only needed for `--cloud-submit` (plan-only render needs no # dependency). Modal auth is via `modal setup`. modal = ["modal>=0.60.0"] +# v0.71.28 - `soup mcp serve` MCP server. The official `mcp` python SDK is +# lazy-imported (only src/soup_cli/mcp_server/server.py touches it), so the CLI +# stays light without it. Floor pinned to guard against SDK API churn. +mcp = ["mcp>=1.2.0"] [project.scripts] soup = "soup_cli.cli:run" diff --git a/src/soup_cli/__init__.py b/src/soup_cli/__init__.py index a85f878..40f766a 100644 --- a/src/soup_cli/__init__.py +++ b/src/soup_cli/__init__.py @@ -1,3 +1,3 @@ """Soup CLI — Fine-tune and post-train LLMs in one command.""" -__version__ = "0.71.27" +__version__ = "0.71.28" diff --git a/src/soup_cli/cli.py b/src/soup_cli/cli.py index 5f8bc2c..dfdcdf9 100644 --- a/src/soup_cli/cli.py +++ b/src/soup_cli/cli.py @@ -517,6 +517,16 @@ from soup_cli.commands import data_doctor as _data_doctor_cmd # noqa: E402 data.app.command(name="doctor")(_data_doctor_cmd.doctor) data.app.command(name="lint")(_data_doctor_cmd.lint) +# v0.71.28 — MCP server: drive Soup from any MCP client (Claude Code / Cursor / +# Cline / Continue) over stdio. +from soup_cli.commands import mcp as _mcp_cmd # noqa: E402 + +app.add_typer( + _mcp_cmd.app, + name="mcp", + help="Model Context Protocol server — drive Soup from any MCP client (v0.71.28).", +) + def _rewrite_advise_argv(argv: list) -> list: """Inject `run` between `advise` and a non-subcommand first argument. diff --git a/src/soup_cli/commands/mcp.py b/src/soup_cli/commands/mcp.py new file mode 100644 index 0000000..d11e748 --- /dev/null +++ b/src/soup_cli/commands/mcp.py @@ -0,0 +1,55 @@ +"""soup mcp — Model Context Protocol server (v0.71.28). + +``soup mcp serve`` exposes Soup's read-only commands (plus two plan-only +mutating tools) to any MCP client (Claude Code / Cursor / Cline / Continue) +over stdio. The heavy ``mcp`` SDK is behind the ``[mcp]`` extra and imported +lazily, so this command errors friendly-ly when the extra is missing. +""" + +from __future__ import annotations + +import typer + +app = typer.Typer( + no_args_is_help=True, + help="Model Context Protocol server — drive Soup from any MCP client.", +) + + +@app.command() +def serve( + allow_mutating: bool = typer.Option( + False, + "--allow-mutating", + help=( + "Enable the plan-only mutating tools (train_start / export). Even " + "when enabled they only render the command that would run — v1 " + "never executes training or export. Off by default: those tools " + "refuse." + ), + ), +) -> None: + """Start the stdio MCP server (read-only tools by default). + + stdout is the JSON-RPC channel — all human-facing output goes to stderr. + Wire it into a client, e.g. `.mcp.json`: + + {"mcpServers": {"soup": {"command": "soup", "args": ["mcp", "serve"]}}} + """ + from rich.console import Console + + # stderr-only: stdout is reserved for the MCP JSON-RPC stream. + console = Console(stderr=True) + + try: + from soup_cli.mcp_server.server import run_stdio_server + except ImportError: + console.print( + "[red]The MCP server needs the 'mcp' SDK.[/] " + "Install it with: [bold]pip install 'soup-cli[mcp]'[/]" + ) + raise typer.Exit(1) from None + + mode = "mutating tools ENABLED (plan-only)" if allow_mutating else "read-only" + console.print(f"[dim]soup mcp serve — stdio transport — {mode}. Waiting for a client...[/]") + run_stdio_server(allow_mutating=allow_mutating) diff --git a/src/soup_cli/mcp_server/server.py b/src/soup_cli/mcp_server/server.py new file mode 100644 index 0000000..c5277b7 --- /dev/null +++ b/src/soup_cli/mcp_server/server.py @@ -0,0 +1,78 @@ +"""MCP stdio server wiring for ``soup mcp serve`` (v0.71.28). + +This is the ONLY module that imports the ``mcp`` SDK — importing it therefore +requires the ``[mcp]`` extra. The pure tool table lives in +:mod:`soup_cli.mcp_server.registry` (no SDK dependency, fully unit-testable). +""" + +from __future__ import annotations + +import json +import sys +from contextlib import redirect_stdout +from typing import List + +import anyio +import mcp.types as types +from mcp.server.lowlevel import Server +from mcp.server.stdio import stdio_server + +from soup_cli.mcp_server.registry import McpToolError, ToolSpec, _sanitize, build_registry + +SERVER_NAME = "soup" + + +def build_server(specs: List[ToolSpec]) -> Server: + """Build a low-level MCP :class:`Server` that dispatches to ``specs``. + + Each tool result is a plain ``dict`` from the handler; it is sanitized + (C0/ESC-stripped) and returned as a single pretty-printed JSON + ``TextContent`` block. Handler failures become an ``isError`` result with a + path-free message so the server survives bad calls. + """ + by_name = {spec.name: spec for spec in specs} + server: Server = Server(SERVER_NAME) + + @server.list_tools() + async def _list_tools() -> List[types.Tool]: + return [ + types.Tool( + name=spec.name, + title=spec.title, + description=spec.description, + inputSchema=spec.input_schema, + ) + for spec in specs + ] + + @server.call_tool() + async def _call_tool(name: str, arguments: dict) -> List[types.TextContent]: + spec = by_name.get(name) + if spec is None: + # The SDK stringifies this into an isError result; keep it generic. + raise ValueError("unknown tool") + try: + # Any core that prints (e.g. a Rich warning) must not corrupt the + # JSON-RPC stdout channel — send stray stdout to stderr for the + # duration of the (synchronous) handler call. + with redirect_stdout(sys.stderr): + result = spec.handler(arguments or {}) + except McpToolError as exc: + raise ValueError(str(exc)) from None + except Exception as exc: # never leak a stack trace / path to the client + raise ValueError(f"internal error ({type(exc).__name__})") from None + text = json.dumps(_sanitize(result), indent=2, ensure_ascii=False) + return [types.TextContent(type="text", text=text)] + + return server + + +def run_stdio_server(*, allow_mutating: bool) -> None: + """Run the MCP server over stdio until the client disconnects.""" + server = build_server(build_registry(allow_mutating=allow_mutating)) + + async def _main() -> None: + async with stdio_server() as (read_stream, write_stream): + await server.run(read_stream, write_stream, server.create_initialization_options()) + + anyio.run(_main) diff --git a/tests/test_v07128.py b/tests/test_v07128.py index 77b1e76..3b0eaab 100644 --- a/tests/test_v07128.py +++ b/tests/test_v07128.py @@ -478,3 +478,162 @@ class TestMutatingTools: def test_registry_count_is_16_with_mutating(self): assert len(reg.build_registry(allow_mutating=True)) == 16 assert len(reg.build_registry(allow_mutating=False)) == 16 + + +# --------------------------------------------------------------------------- +# Server wiring (Part D) — via the SDK's in-memory transport +# --------------------------------------------------------------------------- + + +def _run(coro): + import asyncio + + return asyncio.run(coro) + + +def _roundtrip(server, tool_name, args): + from mcp.shared.memory import create_connected_server_and_client_session + + async def _go(): + async with create_connected_server_and_client_session(server) as client: + await client.initialize() + return await client.call_tool(tool_name, args) + + return _run(_go()) + + +class TestServerRoundTrip: + @pytest.fixture(autouse=True) + def _need_mcp(self): + # The SDK + its in-memory transport are only present with the [mcp] + # extra. Installing `mcp` forces anyio>=4.5 at resolve time, so this one + # guard covers both. Skips cleanly on a partial install. + pytest.importorskip("mcp") + + def test_list_tools_returns_all_16(self): + from mcp.shared.memory import create_connected_server_and_client_session + + from soup_cli.mcp_server.registry import build_registry + from soup_cli.mcp_server.server import build_server + + server = build_server(build_registry(allow_mutating=True)) + + async def _go(): + async with create_connected_server_and_client_session(server) as client: + await client.initialize() + return await client.list_tools() + + result = _run(_go()) + names = {t.name for t in result.tools} + assert len(names) == 16 + assert "recipes_search" in names and "train_start" in names + # every advertised tool carries an inputSchema object + assert all(t.inputSchema.get("type") == "object" for t in result.tools) + + def test_call_recipes_search_returns_json(self): + from soup_cli.mcp_server.registry import build_registry + from soup_cli.mcp_server.server import build_server + + server = build_server(build_registry(allow_mutating=False)) + res = _roundtrip(server, "recipes_search", {"query": "qwen"}) + assert res.isError is False + payload = json.loads(res.content[0].text) + assert payload["count"] >= 1 + + def test_unknown_tool_is_error(self): + from soup_cli.mcp_server.registry import build_registry + from soup_cli.mcp_server.server import build_server + + server = build_server(build_registry(allow_mutating=False)) + res = _roundtrip(server, "no_such_tool", {}) + assert res.isError is True + + def test_mutating_refused_without_allow(self, tmp_path, monkeypatch): + from soup_cli.mcp_server.registry import build_registry + from soup_cli.mcp_server.server import build_server + + monkeypatch.chdir(tmp_path) + (tmp_path / "soup.yaml").write_text(_MIN_CONFIG, encoding="utf-8") + server = build_server(build_registry(allow_mutating=False)) + res = _roundtrip(server, "train_start", {"config": "soup.yaml"}) + assert res.isError is True + + def test_bad_arg_is_error_not_crash(self, tmp_path, monkeypatch): + from soup_cli.mcp_server.registry import build_registry + from soup_cli.mcp_server.server import build_server + + monkeypatch.chdir(tmp_path) + server = build_server(build_registry(allow_mutating=False)) + res = _roundtrip(server, "data_inspect", {"data": "does-not-exist.jsonl"}) + assert res.isError is True + + def test_output_is_sanitized(self): + from soup_cli.mcp_server.registry import ToolSpec + from soup_cli.mcp_server.server import build_server + + spec = ToolSpec( + name="echo", + title="Echo", + description="echo", + input_schema={"type": "object", "properties": {}}, + handler=lambda a: {"v": "a\x1bb\x07c"}, + mutating=False, + ) + server = build_server([spec]) + res = _roundtrip(server, "echo", {}) + payload = json.loads(res.content[0].text) + assert payload["v"] == "abc" # control bytes stripped by _sanitize + + +# --------------------------------------------------------------------------- +# CLI wiring (Part D) +# --------------------------------------------------------------------------- + + +def _strip_ansi(text): + import re + + return re.sub(r"\x1b\[[0-9;]*m", "", text) + + +class TestMcpCli: + def test_registered_in_main_app(self): + from typer.testing import CliRunner + + from soup_cli.cli import app + + r = CliRunner().invoke(app, ["mcp", "--help"], env={"COLUMNS": "200"}) + assert r.exit_code == 0, (r.output, repr(r.exception)) + assert "serve" in _strip_ansi(r.output) + + def test_serve_help(self): + from typer.testing import CliRunner + + from soup_cli.cli import app + + r = CliRunner().invoke(app, ["mcp", "serve", "--help"], env={"COLUMNS": "200"}) + assert r.exit_code == 0, (r.output, repr(r.exception)) + assert "mutating" in _strip_ansi(r.output).lower() + + def test_missing_sdk_exits_friendly(self, monkeypatch): + import sys + + from typer.testing import CliRunner + + from soup_cli.cli import app + + # Simulate the `mcp` SDK being absent: importing the server module fails. + monkeypatch.setitem(sys.modules, "soup_cli.mcp_server.server", None) + r = CliRunner().invoke(app, ["mcp", "serve"]) + assert r.exit_code == 1 + + +class TestRegistryNoSdkImport: + def test_registry_source_has_no_mcp_import(self): + import inspect + + import soup_cli.mcp_server.registry as registry_mod + + src = inspect.getsource(registry_mod) + assert "import mcp" not in src + assert "from mcp" not in src