MCP server: soup mcp serve (v0.71.28)

v0.71.28 ships a Model Context Protocol server, so you can drive Soup from any MCP client — Claude Code, Cursor, Cline, Continue — without leaving the chat. Your agent inspects a dataset, searches recipes, reads runs and gives a ship verdict as tool calls.

Start it

bash
pip install "soup-cli[mcp]"    # official mcp SDK (mcp>=1.10.0,<3), lazy-imported
soup mcp serve                 # stdio; read-only tools
soup mcp serve --allow-mutating   # also run the 2 planning tools
soup mcp serve --allow-execute    # planning + real execution (v0.73.3)

Stdio is the default and is unchanged. v0.74.0 added two network transports for remote and multi-client setups, and they are covered in network transports below.

Execution (v0.73.3)

--allow-execute implies --allow-mutating, and its history is worth two sentences, because copy written about it ages badly. v0.73.2 added the flag as a reserved gate that opened nothing. v0.73.3 opened it.

bash
soup mcp serve --allow-execute   # planning + real execution, behind a token

The server now carries 18 tools, and a planned training or export can actually run. How that is gated is the whole design, and it is covered in the execution model below.

If you are auditing this against the binary, do not trust --help here. At the v0.73.3 tag the help string for --allow-execute, the --allow-mutating help, and the server's own startup banner were all left behind by the feature commit and still say execution is disabled. The changelog, the command reference and the running code agree that it executes. This is the reverse of the usual rule, where the shipped binary settles a documentation disagreement. The banner was corrected in v0.74.0 (it now reads execution ENABLED), but at v0.75.2 the two --help strings still say it never executes, so they remain the part not to trust.

Nothing executes unless you pass the flag. --allow-mutating on its own can never trigger a subprocess, and that is asserted in the code rather than assumed.

Connect a client

Add Soup to your client's MCP config (.mcp.json for Claude Code, claude_desktop_config.json for Claude Desktop):

json
{
  "mcpServers": {
    "soup": { "command": "soup", "args": ["mcp", "serve"] }
  }
}

To expose the planning tools, add the flag:

json
{
  "mcpServers": {
    "soup": { "command": "soup", "args": ["mcp", "serve", "--allow-mutating"] }
  }
}

The tools

The server exposes 18 tools total: 14 read-only + 2 planning + 2 executing. It exposes MCP Tools only — no Resources, no Prompts. Every tool maps to a Soup command and returns JSON.

14 read-only tools (always available)

ToolBacking commandReturns
advisesoup advisePROMPT_ENG / RAG / SFT / DPO / GRPO verdict for a dataset
data_inspectsoup data inspectRow count, columns, length distribution, duplicates
data_validatesoup data validateFormat-compliance report (issues + valid-row count)
data_scoresoup data scoreQuality scorecard: PII, toxicity, language mix, educational value
data_doctorsoup data doctorChat-template compatibility vs a tokenizer (needs [train])
recipes_searchsoup recipes searchSearch the catalog by keyword / task / size (no YAML body)
recipes_showsoup recipes showA full recipe including the ready-to-use soup.yaml
runs_listsoup runsRecent experiment runs from the local tracker
runs_showsoup runs showOne run's full record (accepts an id prefix)
registry_listsoup registry listRegistry entries, filterable by name/tag/base/task
registry_showsoup registry showOne entry by id / prefix / name:tag / registry:// ref
profilesoup profileMemory / speed / GPU-fit estimate from a soup.yaml (no model load)
diagnose_evidencesoup diagnose --evidenceFailure-mode report card from a pre-computed evidence JSON
ship_evidencesoup ship --evidenceSHIP / DON'T-SHIP verdict from a pre-computed evidence JSON

2 planning tools (behind --allow-mutating)

ToolBacking commandBehaviour
train_startsoup trainValidates a soup.yaml, returns {config_valid, task, base, would_run, note}. Never executes by itself
exportsoup exportValidates the format, returns {format, would_run, note}. Never executes by itself

Both are always listed but refuse to run unless you started the server with --allow-mutating, and even then they only render the exact command that would run. Calling them while disabled returns a clean isError telling you to restart with the flag.

Under --allow-execute they do one thing more: each returns a confirmation_token for the plan it just built. That token is the only way to reach the two tools below.

2 executing tools (behind --allow-execute, v0.73.3)

ToolRunsAccepts
train_executeThe training the matching train_start plannedconfirmation_token and nothing else
export_executeThe export the matching export plannedconfirmation_token and nothing else

Both are always listed, carry a destructive annotation, and refuse unless the server was started with --allow-execute. They return {run_id, status, pid, log_path}.

The execution model

The interesting property is what the client is not allowed to send. There is no command parameter, no argv, no shell string, and no environment: the argv was built server-side at plan time, and the only thing a caller can do is name a plan it already made.

  • The token is server-generated, random, bound to both the plan and the execution kind, single-use, and expires after five minutes. A token from a train_start cannot run an export.
  • The config is snapshotted at plan time and the run executes from that copy, so the file cannot be edited underneath a plan that was already approved.
  • Every local input a plan reads is pinned, and from v0.75.1 that is twenty fields rather than five. Until then the digested list named seven paths, two of which are not schema fields at all, so in practice five were covered: a reward model, PRM, teacher model, reward-function file or unlearning set could be swapped between the moment you approved a plan and the moment it ran (0.73.3 to 0.75.0). An input outside the working directory was silently left unpinned; it is now refused at plan time. An input that did not exist at plan time must still not exist at execution, which is what lets a hub id or a built-in reward name be a legitimate pinned input. A test walks the schema and fails when a new path-shaped field is added without being classified either way.
  • Protected inputs are re-validated by content, not by timestamp. The digest walks a directory tree by sorted relative path and per-file hash, refusing symlinks and bounded in both file count and bytes. Since v0.75.1 it also opens with O_NOFOLLOW and reads the path as given rather than its resolved form, because resolving a symlink first is precisely what would stop O_NOFOLLOW from ever firing. Windows has no such flag, so there the earlier check remains the only symlink guard. The earlier mtime-and-size check did not change when a file inside a protected directory was rewritten, which meant a model could be swapped between planning and execution and still pass.
  • The process is spawned with shell=False and stdin closed, its working directory pinned to where the server started, and its output redirected to .soup/mcp-runs/<run_id>.log.
  • The token is consumed, and capacity taken, before the process is spawned. A failed spawn therefore requires a fresh plan rather than allowing a replay.
  • One execution at a time, gated on a persisted run whose recorded process is still alive. That survives a restart: a live child from a previous server blocks a new run, while a stale record whose process is gone frees the slot instead of wedging it shut.
  • The run goes through the normal experiment tracker, so soup runs sees what MCP started.

What it does not do: the snapshot freezes the configuration, not external filesystem assets, and a launch is fire-and-forget, so disconnecting your MCP client does not stop a subprocess that is already running.

Security model

Every point below is implemented and tested:

  • No listener unless you ask for one — stdio is the default and opens no port. stdout is reserved for the JSON-RPC channel; all human-facing text goes to stderr, and handler stdout is redirected so a stray print() can't corrupt the stream. A network transport is opt-in and carries its own mandatory bearer gate, below.
  • Path containment — every path argument re-enters cwd-containment + symlink rejection (O_NOFOLLOW + fstat TOCTOU defence on reads).
  • Output sanitization — C0/ESC/DEL bytes are recursively stripped from every returned string (tab/newline/CR kept), so a malicious dataset string can't smuggle ANSI/OSC terminal escapes into your client.
  • Path-free errors — a failing handler becomes a clean isError result; no filesystem path or stack trace leaks, and the server survives.
  • Bounds — string args ≤ 4096 chars, JSON args ≤ 16 MiB, dataset loads ≤ 1 GiB, int args range-checked (rejected, not silently clamped).
  • Execution is default-off and separately gated — --allow-mutating alone can never spawn a process. With --allow-execute on, authorization is a server-issued one-time token: any client confirmation prompt you see is presentation, and the security lives entirely in the server-side token state.

Install note

The [mcp] extra pulls one dependency, mcp>=1.10.0,<3 (the official MCP Python SDK), and is lazy-imported — only the server module touches it, so the core CLI and the tool registry stay PyTorch-free and SDK-free. If you run soup mcp serve without the extra, you get a friendly one-line install hint and exit 1. The extra is also folded into [all] and [dev].

The bounds moved in v0.74.0. The floor rose from 1.2.0 to 1.10.0, measured against published wheels rather than a changelog: the transport-security module a listener depends on first appears there, and a listener whose origin checking silently disappears on an older SDK is worse than a resolver error. The <2 cap, added when the SDK's 2.0.0 removed two APIs the server round-trips through, is lifted: both majors are bridged, chosen by probing the server constructor rather than by reading a version string, with a test that walks the syntax tree to enforce that and another that fails if a second dispatch implementation appears.

Network transports (v0.74.0)

Until v0.74.0 the server was stdio only, which suits a client that spawns Soup as a subprocess and leaves remote or multi-client setups with nothing.

bash
soup mcp serve --transport sse --host 127.0.0.1 --port 8765 --auth-token "$TOKEN"
soup mcp serve --transport http --host 127.0.0.1 --port 8765 --auth-token "$TOKEN"

Both serve the same registry as stdio, and the end-to-end test compares advertised tool names against the registry rather than a hardcoded count, so a subset cannot creep in.

The defaults are --host 127.0.0.1 and --port 8765. sse serves GET /sse and POST /messages/; http serves /mcp. Leave --auth-token out and Soup generates a fresh urlsafe token on every start and prints it beside the URL; a token you supply must be 16 to 128 urlsafe characters. Whatever your client's config format is, it has to send Authorization: Bearer <token> on every request, the GET that opens the stream included.

One detail of the http transport, observed on a loopback instance of v0.75.2: the URL the server prints ends in /mcp, and a POST to exactly that path is answered with a 307 redirect to /mcp/. A client that follows the redirect and keeps its Authorization header on the second request is unaffected. One that does not follow redirects, or drops the header when it does, should be configured with the trailing slash, /mcp/, which answers directly. A request to /mcp/ without a token is a 401. Which clients behave which way was not tested.

Adding a listener is the risky part, so it is gated three ways.

GateWhat it doesWhy it is not optional
Bearer tokenEvery request needs Authorization: Bearer <token>, compared in constant time. Header only, never a query stringA loopback port is reachable by every process on the box, so there is no opt-out. Header-only means it cannot land in an access log. The token format is validated by the same helper soup ui uses, rather than growing a second format
DNS-rebinding protection421 on a foreign Host, 403 on a foreign OriginThis is the gate a token cannot be: a page the operator merely visits sends no Authorization header, and its request still reaches the port
Bind warningsBinding off loopback warns; a wildcard bind warns againWith a wildcard bind the Host check has nothing left to pin

--allow-execute is refused with either network transport. The refusal is made twice on purpose, once in the command so the operator gets a readable message and once in the app factory so a direct caller cannot put an executing registry behind a listener either. The reasoning is stated rather than assumed: gated execution spawns real training and export processes, and behind a listener a leaked bearer token would mean process execution rather than plan disclosure, while stdio is a pipe to a client the operator already started.

--host, --port and --auth-token are refused under --transport stdio rather than silently ignored.

One operational addition rides along: a crash between spawning a training job and recording its process id used to let a restart double-book a second job, so an unresolved launch now counts as active, and soup mcp runs reconcile --expunge-launching recovers capacity from stale rows without editing the database by hand. It refuses the whole operation if any candidate records a process that is still alive. --older-than-seconds N (default 300, minimum 1) sets how old a launching row must be before it counts as stale.

See also

Soup is free and Apache-2.0. If it saved you a training run, starring the repo costs nothing and helps most. You can also fund the GPU time behind the work a 4 GB laptop cannot reach.