server: support MCP stdio - #26062
Conversation
* server-mcp: harden transport and wire up the tool integration Builds on the transport/manager architecture (server_mcp_transport + server_pipe) with the hardening and integration the draft did not yet have. Hardening: * Reader and stderr pumps are polled (running-aware) instead of blocking on a read that only ends at EOF. subprocess_terminate() SIGKILLs only the direct child, so a grandchild the MCP server spawned that inherited the pipe would otherwise keep the write end open and hang teardown (both warmup shutdown at startup and process shutdown). The writer is likewise non-blocking + polled. * Windows: resolve the command through PATHEXT so "npx" (npm ships npx.cmd, never npx.exe) spawns, matching POSIX's PATH search; and enumerate the parent environment as UTF-8 (GetEnvironmentStringsW) instead of the active code page. * server_pipe gains an opt-in max_size (default unbounded, so the router's streaming use is unchanged); the MCP reply queue uses it so a server that streams unsolicited notifications between requests cannot grow it without bound. Integration: * --mcp-servers-config / --mcp-servers-json flags; enabling MCP restricts default CORS to localhost, same as --tools. * MCP tools are exposed through /tools (and chat-completions) as <server>_<tool>, skipping names that collide with a built-in or another MCP tool. * Manager lifecycle wired into llama_server(): warmup at start, shutdown() from the signal handler before the HTTP server drains, blocking teardown in clean_up(). * SIGPIPE ignored so a child dying mid-write yields EPIPE rather than killing us. Assisted-By: Claude Opus 4.8 <noreply@anthropic.com> * server-mcp: add MCP test suite with grandchild deadlock regression test 21 tests over the /tools endpoint: tool discovery/invocation, timeouts, crash recovery and respawn cooldown, warmup partial failure, malformed and batched notification+response output, tool-definition shape, and prompt shutdown during a slow call. The last test spawns an MCP server that leaves a grandchild inheriting its stdout/stderr and asserts the server both starts and stops promptly. Verified it fails (5s SIGKILL fallback on a deadlocked reader-join) when the pump is made to ignore the running flag, and passes with the polled reader. Assisted-By: Claude Opus 4.8 <noreply@anthropic.com> * clean up * clean up 2 * even stricter life cycle * nits * nits 2 --------- Co-authored-by: Xuan Son Nguyen <son@huggingface.co>
| } | ||
|
|
||
| // note: this is guaranteed to out-live ctx_http and tools | ||
| server_mcp mcp_mgr; |
There was a problem hiding this comment.
note that the life cycle is very strict now:
- server_mcp out live everything else
- tool uses server_mcp, so on-going tool requests is safe while waiting for server_mcp to terminate
- ctx_http out live tool, so req/res objects are safe to access before tool is destroyed
|
@ggml-gh-bot review |
Automated code reviewI have enough context. Here is my review. Code review: MCP server integration (PR #26062)This PR adds a backend MCP integration: Checklists run: Scope/quick-reject, Security, Approach-and-design (new subsystem), Server, General. Blocking(point 1) Scope/approach needs maintainer sign-off and a tracking issue. This adds a large new subsystem ( (point 2) Data race on Will slow the review(point 3) (point 4) Document the new Nits(point 5) (point 6) (point 7) (point 8) (point 9) (point 10) (point 11) (point 12) Test hygiene in (point 13) Things that look right (no change needed)
This review was generated automatically by pi coding agent using |
|
need your final review @pwilkin @ServeurpersoCom |
|
I get 4 issues: Every spawned child leaks a zombie that accumulates for the process lifetime, because join_pumps() never calls subprocess_join(). One tool call timeout kills a healthy child, because server_pipe::read() closes the pipe for good as soon as should_stop fires. The disconnect cancellation in server_mcp_tool::invoke() is unreachable, because support_stream is false so find_tool() rejects the streaming path and st is always null. An MCP server that exits on stdin EOF never sees it, so it can only be killed. That last one comes from vendor/sheredom/subprocess.h creating its pipes without O_CLOEXEC, which should be fixed upstream. Opus 5 patch for the first three: Windows still needs to be tested. |
|
My adversarial review session brought up this: C1 — drop-oldest can evict a pending response: Confirmed in server-common.h that server_pipe::write() does while (queue.size() >= max_size) queue.pop(); — std::queue::pop() removes the front (verified against cppreference), and read() also consumes from the front. Since the reader thread pushes both notifications and responses into the same from_server FIFO (no filtering), and send_rpc is the sole consumer, a sustained notification burst that keeps the queue saturated can evict a pending response id=N right as it reaches the front, causing a spurious timeout despite a valid server reply. |
|
C2 is the same bug as the timeout teardown fixed in the patch above: the per-request deadline no longer closes the pipe, and a late reply is skipped on id mismatch. C1 reproduced, but it takes a deliberately optimized flooder. A server that serializes each notification cannot outpace the consumer: a reply sandwiched between 300k notifications still arrives in about 1s. Blasting pre-serialized notifications with raw 1MB writes does lose the reply and the call spuriously times out at the deadline (reproduced at 200k notifications around the reply). With the patch the damage is limited to that one call: the transport survives, the next call succeeds, and memory stays bounded, which is what drop-oldest is there for. The principled fix would be backpressure instead of drop-oldest, a blocking writer when the queue is full so the child stalls on its own pipe and nothing is lost. |
join_pumps() never reaped the child, leaking one zombie per spawn: call subprocess_join() before subprocess_destroy(). A per-call timeout permanently closed from_server and got a healthy transport evicted: add close_on_stop to server_pipe::read() and pass false from send_rpc(), where should_stop is a per-request deadline and a late reply is already skipped on id mismatch. Also drop the unreachable disconnect cancellation in server_mcp_tool::invoke(): support_stream is false, st is always null. (cherry picked from commit e6de1ec) Assisted-by: Claude Opus 4.8
Add the missing notification guard to mcp_malformed_server.py and mcp_burst_server.py (the latter treated id 0 as a notification and replied to unknown ones; its notification table is now unused). Return -32602 instead of -32601 for unknown tools: tools/call is a valid method, the tool name is the invalid parameter. Also fix the test module docstring: tools are named <server>_<tool>. (cherry picked from commit 74a08e8) Assisted-by: Claude Opus 4.8
|
thanks, both points should be addressed now |
|
Windows native test: the vendored subprocess.h argument quoting breaks on a quoted argument ending in a backslash. An MCP arg C:\un dossier\ (space, trailing backslash) reaches the child merged with every following argument into a single one: C:\un dossier" C:\controle\ MARKER_END. The builder only doubles a backslash when the next source character is a quote, so the trailing backslash ends up escaping the closing quote it generates. Same quoting class I fixed in #25736, should be fixed upstream in sheredom/subprocess.h. On the other hand, the implementation on the llama.cpp side is flawless. Shall we submit a PR to subprocess.h? :D |
ServeurpersoCom
left a comment
There was a problem hiding this comment.
LGTM, the limitations are on the subprocess.h side
|
I'm going to apply the subprocess.h patch to do an adversarial test, and we'll also be able to use it, plus, it'll be ready for a PR. |
|
Yes I think that can be a valid upstream fix, feel free to push it upstream and we will sync in a follow up PR |
|
Success :
Upstream issue sheredom/subprocess.h#100 |
|
Let me do one big adversarial just to be sure and then we can merge. |
|
Heeeere iiiiit cooooooomes! Adversarial review — MCP stdio integration (open findings)Branch: Scope: the 12 branch-only commits vs Method: built the branch and drove a live This document lists open findings only. Three earlier findings — a per-call timeout Summary
Critical1. Spawn cooldown never engages → unbounded respawn
Reproduced at A client can drive process creation at HTTP request rate. Each spawn used to leak a PID as Fix direction: record a cooldown entry on the High2.
|
| Line | Assertion | Why it can never fail |
|---|---|---|
| 199 | [t for t in tools if ":" in get_tool_name(t)] |
names use _, never : |
| 533 | get_tool_name(t).startswith("empty:") |
same |
| 141 | t.get("name", "").startswith("nonexistent_") |
to_json() emits tool, not name |
| 228 | assert res.status_code in (200, 500) |
admits both outcomes |
| 106 | assert "plain_text_response" in body or ... |
checks presence, never value |
Line 106 is why finding #3 passes CI: a tool returning {"plain_text_response":""} for
all non-text content is indistinguishable from success.
Not covered at all: respawn rate limiting (#1), pagination (#2), non-text content (#3),
tool-name validity (#4), concurrency latency and client-disconnect behaviour (#5).
Two mechanical issues:
test_mcp_empty_tool_listwrites_empty_mcp_server.pyinto the repo's
tests/fixtures/at runtime andunlinks it infinally— stranded in the source
tree if the test crashes hard.- Every test hardcodes
server_port = 8085against aglobal server, so the file cannot
run in parallel.test_mcp_config_file_errorsalso binds a localserver, shadowing
the global.
Suite status at 33f3ec1b6: 21 passed in 24.7 s. Worth flagging that this is a weaker
signal than it looks — the two critical bugs fixed in 08a384bd7 were both green under
this suite before the fix, and the assertions that would have caught them are the same
ones still tautological above.
Suggested order of work
- Merging tensors of larger models #1 — the only critical; move the cooldown onto the eviction path.
- Add missing headers for memcpy and assert #3, Repetition penalty #4 — small, self-contained, and both produce silently wrong output today.
- Windows VS2022 Build - Returning nonsense #2 — a
while (nextCursor)loop inlist_tools. - [Q] Memory Requirements for Different Model Sizes #13 — fix the five assertions before anything else lands, so the next regression
is actually caught. - Suppress output that isn't from the model #5 — the one real design change: id-multiplexed concurrency over the single stdio
pair, using the correlation logic that already exists.
|
I'm try the lightest possible patch to test it out, including only critical fixes and maintaining the existing compromises. |
|
Yeah, most of those are fortunately non-blockers or even out-of-scope (the secrets-in-inline-mcp-config one). But I figured out I might as well test Opus 5 - interestingly, unlike Opus 4.8 it didn't launch an agent swarm even though I set it to Ultracode. |
|
Fixed
Not fixed, on purpose
Tested
|
pwilkin
left a comment
There was a problem hiding this comment.
This is good enough for me, I think we can address any further issues in a followup.
|
I had the same concern about the "unbounded respawn" during development, but let's see if it will actually be a problem in normal usage. What I wanted initially was the have respawn serialized, but that's a bit complicated. Merging this as-is, feel free to push follow-up fixes. Thanks again for the review. |
|
A minimal example of using this functionality would be helpful - it's not obvious how to use it. |
Sure, let me give a quick example. In short, llama-server can fork a process that communicates via JSON-RPC, effectively turning it into a built-in tool. Even a plain script can be an MCP stdio server, I'll put together a small self-contained example that shows how to use it safely. |
|
Here is a minimal example. An MCP stdio server is just a process reading JSON-RPC lines on stdin and writing replies on stdout, so a plain POSIX shell script is enough, no SDK, no dependencies: #!/bin/sh
# minimal MCP stdio server in POSIX shell
# protocol: one JSON-RPC message per line (NDJSON) on stdin/stdout, logs go to stderr
while IFS= read -r line; do
id=$(printf '%s' "$line" | sed -En 's/.*"id":([0-9]+).*/\1/p')
method=$(printf '%s' "$line" | sed -En 's/.*"method":"([^"]*)".*/\1/p')
case "$method" in
initialize)
printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"echo-sh","version":"1.0"}}}\n' "$id"
;;
tools/list)
printf '{"jsonrpc":"2.0","id":%s,"result":{"tools":[{"name":"echo","description":"Echo the input text back","inputSchema":{"type":"object","properties":{"text":{"type":"string"}},"required":["text"]}}]}}\n' "$id"
;;
tools/call)
# the value is already JSON-escaped on the wire, so it can be re-embedded as-is
text=$(printf '%s' "$line" | sed -En 's/.*"text":"((\\.|[^"\\])*)".*/\1/p')
printf '{"jsonrpc":"2.0","id":%s,"result":{"content":[{"type":"text","text":"%s"}]}}\n' "$id" "$text"
;;
*)
# notifications have no id and expect no reply
;;
esac
doneSave it as {
"mcpServers": {
"sh": { "command": "/path/to/mcp-echo.sh" }
}
}The server spawns the script at startup, does the MCP handshake, and exposes the tool as Since it is just stdio, the same skeleton wraps any local command. For example replacing the echo line with
|
|
It also works on Windows, using a PowerShell script as an MCP server. That's what I tested to track down the issue with subprocess.h. |
|
For your records - if useful. We picked this one as an interesting and meaningful PR to run a scan on. This is the Lenzon explainer that we also made a video of during testing of our open source tool - https://www.lenzon.ai/viewer/cms3bz2jn000cpdiqikyr5k7s?voice=google-chirp3 |
* move server_pipe to common * init impl * vendor: update subprocess.h * add server_mcp_stdio * stderr drain * server_mcp_transport * server_mcp_stdio is now framing-only, no json * internal/mcp-stdio: integration + tests + fixes (ggml-org#26075) * server-mcp: harden transport and wire up the tool integration Builds on the transport/manager architecture (server_mcp_transport + server_pipe) with the hardening and integration the draft did not yet have. Hardening: * Reader and stderr pumps are polled (running-aware) instead of blocking on a read that only ends at EOF. subprocess_terminate() SIGKILLs only the direct child, so a grandchild the MCP server spawned that inherited the pipe would otherwise keep the write end open and hang teardown (both warmup shutdown at startup and process shutdown). The writer is likewise non-blocking + polled. * Windows: resolve the command through PATHEXT so "npx" (npm ships npx.cmd, never npx.exe) spawns, matching POSIX's PATH search; and enumerate the parent environment as UTF-8 (GetEnvironmentStringsW) instead of the active code page. * server_pipe gains an opt-in max_size (default unbounded, so the router's streaming use is unchanged); the MCP reply queue uses it so a server that streams unsolicited notifications between requests cannot grow it without bound. Integration: * --mcp-servers-config / --mcp-servers-json flags; enabling MCP restricts default CORS to localhost, same as --tools. * MCP tools are exposed through /tools (and chat-completions) as <server>_<tool>, skipping names that collide with a built-in or another MCP tool. * Manager lifecycle wired into llama_server(): warmup at start, shutdown() from the signal handler before the HTTP server drains, blocking teardown in clean_up(). * SIGPIPE ignored so a child dying mid-write yields EPIPE rather than killing us. Assisted-By: Claude Opus 4.8 <noreply@anthropic.com> * server-mcp: add MCP test suite with grandchild deadlock regression test 21 tests over the /tools endpoint: tool discovery/invocation, timeouts, crash recovery and respawn cooldown, warmup partial failure, malformed and batched notification+response output, tool-definition shape, and prompt shutdown during a slow call. The last test spawns an MCP server that leaves a grandchild inheriting its stdout/stderr and asserts the server both starts and stops promptly. Verified it fails (5s SIGKILL fallback on a deadlocked reader-join) when the pump is made to ignore the running flag, and passes with the polled reader. Assisted-By: Claude Opus 4.8 <noreply@anthropic.com> * clean up * clean up 2 * even stricter life cycle * nits * nits 2 --------- Co-authored-by: Xuan Son Nguyen <son@huggingface.co> * fix some edge cases * fix last_error data race * fix response schema + docs * server: fix MCP zombie leak and timeout-induced transport teardown join_pumps() never reaped the child, leaking one zombie per spawn: call subprocess_join() before subprocess_destroy(). A per-call timeout permanently closed from_server and got a healthy transport evicted: add close_on_stop to server_pipe::read() and pass false from send_rpc(), where should_stop is a per-request deadline and a late reply is already skipped on id mismatch. Also drop the unreachable disconnect cancellation in server_mcp_tool::invoke(): support_stream is false, st is always null. (cherry picked from commit e6de1ec) Assisted-by: Claude Opus 4.8 * server: make MCP test fixtures JSON-RPC 2.0 compliant Add the missing notification guard to mcp_malformed_server.py and mcp_burst_server.py (the latter treated id 0 as a notification and replied to unknown ones; its notification table is now unused). Return -32602 instead of -32601 for unknown tools: tools/call is a valid method, the tool name is the invalid parameter. Also fix the test module docstring: tools are named <server>_<tool>. (cherry picked from commit 74a08e8) Assisted-by: Claude Opus 4.8 --------- Co-authored-by: Piotr Wilkin (ilintar) <piotr.wilkin@syndatis.com> Co-authored-by: Pascal <admin@serveurperso.com>
* move server_pipe to common * init impl * vendor: update subprocess.h * add server_mcp_stdio * stderr drain * server_mcp_transport * server_mcp_stdio is now framing-only, no json * internal/mcp-stdio: integration + tests + fixes (ggml-org#26075) * server-mcp: harden transport and wire up the tool integration Builds on the transport/manager architecture (server_mcp_transport + server_pipe) with the hardening and integration the draft did not yet have. Hardening: * Reader and stderr pumps are polled (running-aware) instead of blocking on a read that only ends at EOF. subprocess_terminate() SIGKILLs only the direct child, so a grandchild the MCP server spawned that inherited the pipe would otherwise keep the write end open and hang teardown (both warmup shutdown at startup and process shutdown). The writer is likewise non-blocking + polled. * Windows: resolve the command through PATHEXT so "npx" (npm ships npx.cmd, never npx.exe) spawns, matching POSIX's PATH search; and enumerate the parent environment as UTF-8 (GetEnvironmentStringsW) instead of the active code page. * server_pipe gains an opt-in max_size (default unbounded, so the router's streaming use is unchanged); the MCP reply queue uses it so a server that streams unsolicited notifications between requests cannot grow it without bound. Integration: * --mcp-servers-config / --mcp-servers-json flags; enabling MCP restricts default CORS to localhost, same as --tools. * MCP tools are exposed through /tools (and chat-completions) as <server>_<tool>, skipping names that collide with a built-in or another MCP tool. * Manager lifecycle wired into llama_server(): warmup at start, shutdown() from the signal handler before the HTTP server drains, blocking teardown in clean_up(). * SIGPIPE ignored so a child dying mid-write yields EPIPE rather than killing us. Assisted-By: Claude Opus 4.8 <noreply@anthropic.com> * server-mcp: add MCP test suite with grandchild deadlock regression test 21 tests over the /tools endpoint: tool discovery/invocation, timeouts, crash recovery and respawn cooldown, warmup partial failure, malformed and batched notification+response output, tool-definition shape, and prompt shutdown during a slow call. The last test spawns an MCP server that leaves a grandchild inheriting its stdout/stderr and asserts the server both starts and stops promptly. Verified it fails (5s SIGKILL fallback on a deadlocked reader-join) when the pump is made to ignore the running flag, and passes with the polled reader. Assisted-By: Claude Opus 4.8 <noreply@anthropic.com> * clean up * clean up 2 * even stricter life cycle * nits * nits 2 --------- Co-authored-by: Xuan Son Nguyen <son@huggingface.co> * fix some edge cases * fix last_error data race * fix response schema + docs * server: fix MCP zombie leak and timeout-induced transport teardown join_pumps() never reaped the child, leaking one zombie per spawn: call subprocess_join() before subprocess_destroy(). A per-call timeout permanently closed from_server and got a healthy transport evicted: add close_on_stop to server_pipe::read() and pass false from send_rpc(), where should_stop is a per-request deadline and a late reply is already skipped on id mismatch. Also drop the unreachable disconnect cancellation in server_mcp_tool::invoke(): support_stream is false, st is always null. (cherry picked from commit e6de1ec) Assisted-by: Claude Opus 4.8 * server: make MCP test fixtures JSON-RPC 2.0 compliant Add the missing notification guard to mcp_malformed_server.py and mcp_burst_server.py (the latter treated id 0 as a notification and replied to unknown ones; its notification table is now unused). Return -32602 instead of -32601 for unknown tools: tools/call is a valid method, the tool name is the invalid parameter. Also fix the test module docstring: tools are named <server>_<tool>. (cherry picked from commit 74a08e8) Assisted-by: Claude Opus 4.8 --------- Co-authored-by: Piotr Wilkin (ilintar) <piotr.wilkin@syndatis.com> Co-authored-by: Pascal <admin@serveurperso.com>


Overview
Support MCP with stdio transport; server accepts MCP JSON definition, spawns and manage subprocess. MCP tools are exposed as server tools.
Supersede #25736
Depends on #26061
Additional information
The core implementation is split into 4 separate classes:
server_mcp_server_config: data class, parsed version of mcp.json config fileserver_mcp_transport: manage high-level JSON-RPC messagesserver_mcp_stdio: manage low-level pipes and subprocess (no notions of json here, just byte streams)server_mcp: manage all instances ofserver_mcp_transportThe behavior was designed to match #25736 , but the underlay architecture is different, notably:
server_mcpmanages everything, even the life-cycle of transports and subprocessessubprocess.hshared_ptr--> stricter life-cycle definition. For example:shutdown()is blocking, no pointers are leaked after going out-of-scope, never have detached threadsTODO:
server-tools.cppRequirements
server_pipe,subprocess.h, while introducing design constraints like no redundantshared_ptroratomicsDespite the stated constraints, the model still made some bad assumptions about whether to use
shared_ptr/atomic. During the development, I had to point out these and refine the design at each steps.