feat(mcp): stdio server + soup mcp serve command + [mcp] extra (v0.71.28 Part D)

This commit is contained in:
Alpamys 2026-07-04 16:55:00 +05:00
parent d4fe661f05
commit 96c04f9716
6 changed files with 310 additions and 4 deletions

View File

@ -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"

View File

@ -1,3 +1,3 @@
"""Soup CLI — Fine-tune and post-train LLMs in one command."""
__version__ = "0.71.27"
__version__ = "0.71.28"

View File

@ -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.

View File

@ -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)

View File

@ -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)

View File

@ -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