mirror of https://github.com/razor-ai/soup.git
feat(mcp): stdio server + soup mcp serve command + [mcp] extra (v0.71.28 Part D)
This commit is contained in:
parent
d4fe661f05
commit
96c04f9716
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
"""Soup CLI — Fine-tune and post-train LLMs in one command."""
|
||||
|
||||
__version__ = "0.71.27"
|
||||
__version__ = "0.71.28"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue