Skip to content

feat(mcp): serve MCP from the stoat binary - #68

Merged
NovusEdge merged 74 commits into
mainfrom
feat/mcp-go
Sep 5, 2026
Merged

NovusEdge merged 74 commits into
mainfrom
feat/mcp-go

Conversation

@NovusEdge

@NovusEdge NovusEdge commented Sep 5, 2026 •

Copy link
Copy Markdown
Owner

What changed

stoat mcp serves the MCP protocol from the binary. internal/mcpsrv registers every tool on github.com/modelcontextprotocol/go-sdk, runs the guards, calls core, and returns wire DTOs, so the --json contract and the MCP schema are one set of types.

New: the four agent_access levels (none, observe, manage, exec) with a legacy allow_exec mapping on load and a hidden --allow-exec alias; in-VM tools for files, directories, processes, services, packages and background jobs over sshx.Run, the single argv quoter, so no tool builds a shell string from input; the recipe index tools; secret redaction over every tool output including the SDK's text fallback; stoat mcp install <client> and stoat mcp doctor; streamable HTTP on loopback only.

mcp/ and its CI job are deleted. Every test in mcp/tests has a Go counterpart, checked by the port table test in internal/mcpsrv.

Why

Spec docs/specs/2026-09-04-mcp-in-go-design.md. fastmcp shipped two breaking majors since the pin, and a static binary needs no interpreter, venv or uv for a client to launch it.

Tests run

  • go build ./... && go test ./... && golangci-lint run ./... clean at the tip, after merging main.
  • Three chunk reviews (opus) with fix rounds, plus a plan-level final review whose fixes are the last seven commits.
  • Live boot at 756bb38 on Debian 13 cloud: stoat exec, and over MCP stdio exec with env, write_file, read_file, list_dir, exec_bg, job_status, job_output, list_jobs, mcp doctor. Evidence in the comment below.

Deviations from the plan, all reviewed

  • Redaction runs as receiving middleware. The SDK's sending middleware never fires for a tools/call; the receiving path wraps the handler result and also rewrites the TextContent fallback the SDK fills before any middleware.
  • register takes an explicit class argument. The tool table lives in table_test.go, so production code cannot read it; TestAnnotationsMatchTable still checks every registered tool against the table.
  • config.VM.AllowExec stays beside AgentAccess. Pre-existing round-trip goldens assert it; the loader maps a legacy allow_exec = true to exec.

Found by the live gate

  • exec_bg failed on a real guest with cannot create /run/stoat/jobs/<id>: /run/stoat is root-owned and the mkdir ran as the ssh user. The runner also lacked the spec's nohup. Fixed in 756bb38: the directory is made escalated and chowned to the ssh user, the wrapper runs under nohup, and the pid file names the command itself so job_kill signals it.

Known follow-ups, not blockers

  • create still writes allow_exec = true regardless of the chosen level, and wire.VM still emits it beside agent_access; no reader enforces it.

Summary by CodeRabbit

  • New Features

    • Added an MCP server directly to the Stoat CLI, supporting stdio and loopback HTTP transports.
    • Added MCP tools for VM management, guest inspection, file operations, services, recipes, command execution, and background jobs.
    • Added client setup and diagnostics through mcp install and mcp doctor.
    • Added configurable agent access levels and guest log-path support.
    • Added rate limiting, secret redaction, and input safety protections.
  • Documentation

    • Updated MCP, JSON contract, guest configuration, and sample configuration documentation.
  • Refactor

    • Replaced the separate Python MCP server with the integrated Go implementation.

Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
@NovusEdge NovusEdge added enhancement New feature needs-live-boot Cannot be verified by agents; needs a real Alpine boot labels Sep 5, 2026
@NovusEdge NovusEdge self-assigned this Sep 5, 2026
@coderabbitai

coderabbitai Bot commented Sep 5, 2026 •

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Team

Run ID: 2b00ba00-4143-47e8-b3f2-29f89c9de4cc

📥 Commits

Reviewing files that changed from the base of the PR and between e4e337b and 756bb38.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (72)
  • .github/workflows/ci.yml
  • docs/design/mcp-server.md
  • docs/reference/guest.md
  • docs/reference/json.md
  • docs/reference/samples/guest.toml
  • go.mod
  • internal/cli/cli.go
  • internal/cli/cli_test.go
  • internal/cli/grammar.go
  • internal/cli/json_test.go
  • internal/cli/run_mcp.go
  • internal/cli/run_mcp_test.go
  • internal/cli/run_state.go
  • internal/cli/wire/dto.go
  • internal/cli/wire/envelope.go
  • internal/cli/wire/envelope_test.go
  • internal/config/config.go
  • internal/config/config_test.go
  • internal/core/apply.go
  • internal/core/core.go
  • internal/core/exec.go
  • internal/core/forward.go
  • internal/core/remote_recipes.go
  • internal/core/update.go
  • internal/core/vm.go
  • internal/guest/bundled/alpine.toml
  • internal/guest/guest.go
  • internal/mcpsrv/access.go
  • internal/mcpsrv/access_test.go
  • internal/mcpsrv/contract.go
  • internal/mcpsrv/doctor.go
  • internal/mcpsrv/doctor_test.go
  • internal/mcpsrv/guards.go
  • internal/mcpsrv/guards_test.go
  • internal/mcpsrv/install.go
  • internal/mcpsrv/install_test.go
  • internal/mcpsrv/jobs.go
  • internal/mcpsrv/jobs_test.go
  • internal/mcpsrv/porttable_test.go
  • internal/mcpsrv/ratelimit.go
  • internal/mcpsrv/ratelimit_test.go
  • internal/mcpsrv/redact.go
  • internal/mcpsrv/redact_test.go
  • internal/mcpsrv/server.go
  • internal/mcpsrv/server_test.go
  • internal/mcpsrv/table_test.go
  • internal/mcpsrv/tools_exec.go
  • internal/mcpsrv/tools_exec_test.go
  • internal/mcpsrv/tools_guest.go
  • internal/mcpsrv/tools_guest_test.go
  • internal/mcpsrv/tools_read.go
  • internal/mcpsrv/tools_read_test.go
  • internal/mcpsrv/tools_recipe.go
  • internal/mcpsrv/tools_recipe_test.go
  • internal/mcpsrv/tools_vm.go
  • internal/mcpsrv/tools_vm_test.go
  • internal/sshx/run.go
  • internal/sshx/run_test.go
  • internal/testutil/fakessh.go
  • internal/tomlx/tomlx.go
  • mcp/.gitignore
  • mcp/README.md
  • mcp/pyproject.toml
  • mcp/stoat_mcp/__init__.py
  • mcp/stoat_mcp/client.py
  • mcp/stoat_mcp/errors.py
  • mcp/stoat_mcp/guards.py
  • mcp/stoat_mcp/server.py
  • mcp/tests/__init__.py
  • mcp/tests/test_client.py
  • mcp/tests/test_guards.py
  • mcp/tests/test_server.py

Walkthrough

The pull request replaces the Python MCP process with an embedded Go server in stoat. It adds MCP tools, transports, access levels, validation, redaction, rate limiting, job handling, client installation, and shared wire DTOs. It also removes the Python package and its CI job.

Changes

Embedded MCP server

Layer / File(s) Summary
Contracts, CLI, and core integration
docs/..., internal/cli/..., internal/config/..., internal/core/..., internal/guest/..., internal/tomlx/..., go.mod
The CLI adds mcp serve, mcp install, and mcp doctor. Configuration adds four-level agent_access handling with legacy allow_exec mapping. Wire DTOs and core APIs expose MCP results.
MCP platform, guards, and persistence
internal/mcpsrv/access.go, guards.go, ratelimit.go, redact.go, install.go, doctor.go, jobs.go, server.go
The server adds access gates, input guards, token-bucket limits, secret redaction, client configuration management, diagnostics, job persistence, and stdio or loopback HTTP transport.
Guest, execution, recipe, and VM tools
internal/mcpsrv/tools_*.go
The server registers read, VM lifecycle, recipe, guest, command execution, and background-job tools. Handlers validate inputs, enforce access levels, call core or SSH functions, and return wire DTOs.
Transport, SSH execution, and validation
internal/sshx/..., internal/testutil/..., internal/mcpsrv/*_test.go
SSH execution now preserves argument boundaries and returns remote exit codes as data. Tests cover tool registration, schemas, guards, access levels, redaction, rate limits, jobs, guest tools, recipes, transports, and SSH behavior.
Python MCP removal and CI update
.github/workflows/ci.yml, mcp/*
The Python MCP package, tests, configuration, documentation, and CI job are removed.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant StoatCLI
  participant MCPServer
  participant Core
  participant GuestVM
  Client->>StoatCLI: start mcp serve
  StoatCLI->>MCPServer: serve over stdio or loopback HTTP
  Client->>MCPServer: call MCP tool
  MCPServer->>MCPServer: validate, rate-limit, and redact
  MCPServer->>Core: execute host operation
  MCPServer->>GuestVM: run guest operation over sshx.Run
  Core-->>MCPServer: return result
  GuestVM-->>MCPServer: return output and exit code
  MCPServer-->>Client: return wire DTO
Loading

Poem

A rabbit reads each line,
The patch grows clear beneath the moon,
Small changes hop in place,
Tests guard the garden path,
Reviews bloom before the dawn.

✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/mcp-go

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

A live Debian 13 boot showed exec_bg failing with 'cannot create /run/stoat/jobs/<id>': /run/stoat is root-owned, and the mkdir ran as the ssh user. The runner also had no nohup, so a job could die with the ssh session.

Signed-off-by: NovusEdge <novusedge0@gmail.com>
@NovusEdge

Copy link
Copy Markdown
Owner Author

Live gate at 756bb38 on Debian 13 cloud (2048 MiB, 2 CPUs), VM created with --agent-access exec, driven by a stdio JSON-RPC client against stoat mcp. The first run at c51f34a found the exec_bg defect fixed in 756bb38; this is the rerun.

=== create mcp-live-2048025
=== up
=== wait reachable
{"v":3,"type":"result","cmd":"wait","ok":true,"data":{"reached":true,"until":"reachable","vm":"mcp-live-2048025","waited_ms":29648}}
=== stoat exec (core.Exec over sshx.Run)
{"v":3,"type":"result","cmd":"exec","ok":true,"data":{"vm":"mcp-live-2048025","stdout":"uid=1000(stoat) gid=1000(stoat) groups=1000(stoat)\n","exit_code":0}}
{"v":3,"type":"result","cmd":"exec","ok":true,"data":{"vm":"mcp-live-2048025","stdout":"spaces and /home/stoat\n","exit_code":0}}
=== stoat mcp over stdio
{"tools": 45, "names": ["add_recipe", "apply_recipes", "check_recipes", "clone", "copy_from", "copy_to", "create", "destroy", "doctor", "exec", "exec_bg", "forward", "guest_info", "job_kill", "job_output", "job_status", "list_dir"
{"tool": "exec", "isError": false, "structured": {"exit_code": 0, "stderr": "", "stdout": "uid=1000(stoat) gid=1000(stoat) groups=1000(stoat)\n"}, "text": "{\"exit_code\":0,\"stderr\":\"\",\"stdout\":\"uid=1000(stoat) gid=1000(sto
{"tool": "exec", "isError": false, "structured": {"exit_code": 0, "stderr": "", "stdout": "C\nbar baz\n"}, "text": "{\"exit_code\":0,\"stderr\":\"\",\"stdout\":\"C\\nbar baz\\n\"}"}
{"tool": "write_file", "isError": false, "structured": {"exit_code": 0, "stderr": "", "stdout": ""}, "text": "{\"exit_code\":0,\"stderr\":\"\",\"stdout\":\"\"}"}
{"tool": "read_file", "isError": false, "structured": {"content": "hello from mcp\nsecond line\n", "size": 27, "truncated": false}, "text": "{\"content\":\"hello from mcp\\nsecond line\\n\",\"size\":27,\"truncated\":false}"}
{"tool": "list_dir", "isError": false, "structured": {"entries": [{"mode": "43ff", "mtime": 1788637302, "name": "/tmp/.ICE-unix", "size": 40, "type": "directory"}, {"mode": "43ff", "mtime": 1788637302, "name": "/tmp/.X11-unix", "s
{"tool": "exec_bg", "isError": false, "structured": {"dir": "/run/stoat/jobs/j-aecbb9c7", "job_id": "j-aecbb9c7"}, "text": "{\"dir\":\"/run/stoat/jobs/j-aecbb9c7\",\"job_id\":\"j-aecbb9c7\"}"}
{"tool": "job_status", "isError": false, "structured": {"exit_code": 0, "job_id": "j-aecbb9c7", "state": "running"}, "text": "{\"exit_code\":0,\"job_id\":\"j-aecbb9c7\",\"state\":\"running\"}"}
{"tool": "job_output", "isError": false, "structured": {"content": "tick 1\n", "size": 7, "truncated": false}, "text": "{\"content\":\"tick 1\\n\",\"size\":7,\"truncated\":false}"}
{"tool": "list_jobs", "isError": false, "structured": {"jobs": [{"argv": ["sh", "-c", "for i in 1 2 3; do echo tick $i; sleep 1; done; echo done"], "job_id": "j-aecbb9c7", "started": "2026-09-05T19:41:55.420224162Z", "user": "stoa
{"tool": "exec", "isError": false, "structured": {"exit_code": 0, "stderr": "", "stdout": "PRETTY_NAME=\"Debian GNU/Linux 13 (trixie)\"\nNAME=\"Debian GNU/Linux\"\nVERSION_ID=\"13\"\nVERSION=\"13 (trixie)\"\nVERSION_CODENAME=trixi
{"server_exit": 0}
=== mcp doctor
=== cleanup
LIVE-GATE-DONE

@NovusEdge
NovusEdge marked this pull request as ready for review September 5, 2026 19:42
@NovusEdge
NovusEdge merged commit 232e3de into main Sep 5, 2026
3 of 5 checks passed
@NovusEdge
NovusEdge deleted the feat/mcp-go branch September 5, 2026 19:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature needs-live-boot Cannot be verified by agents; needs a real Alpine boot

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant