feat(launcher): local launcher for HTTP/SSE upstreams (spec 046)#452
Conversation
Adds the implementation plan for letting mcpproxy spawn a local
command before connecting to its HTTP/SSE endpoint. Today the
command field is silently ignored when protocol is http/sse/
streamable-http; this plan decouples launcher from transport so
{command, url, protocol: http|sse|streamable-http} becomes a
first-class config combo (spawn, wait for URL, connect, own
lifecycle on disconnect/restart/shutdown).
Plan-only commit — no code yet. Phase 0 of the plan is a no-
behaviour-change refactor that lifts env/Docker/working-dir
plumbing out of connection_stdio.go into a new internal/upstream/
launcher/ package; Phase 1 wires it into the HTTP/SSE connect
path; Phase 2 lands tests + docs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New self-contained package that mcpproxy will use to spawn the local
upstream process behind HTTP/SSE/streamable-HTTP transports. Phase 0
of spec 046: the launcher is fully implemented and tested, but no
Connect/Disconnect path uses it yet — that wiring lands in the next
commit so reviewers can read the lifecycle module on its own.
API surface:
- Spec — caller-built *exec.Cmd plus LogSink/Name/StopGrace.
- Handle — Stop/Wait/Done/Pid; Stop is idempotent and waits
for the child to be reaped before returning so
"Stop returned -> port is free" is reliable.
- Spawn(ctx,spec,log) — owns child lifecycle from cmd.Start onward.
Pumps stdout+stderr line-by-line into LogSink
(one Write per line, serialized internally so an
arbitrary io.Writer is safe).
- WaitForURL(ctx,url,timeout) — TCP-dial polling, NOT http.Get
(gotcha #2 in the spec: SSE GETs stream forever).
Infers default ports for http/https/ws/wss.
Process group handling is unix-only via applyProcAttrs (Setpgid +
Pgid=0), so SIGTERM/SIGKILL reach grandchildren spawned by sh -c ...
and docker run .... Windows gets best-effort stubs that match the
process_windows.go TODO already in core/.
Tests: 15 cases covering immediate/late/never-bound listeners, ctx
cancel, bad-URL parse rejection, default-port inference, graceful
SIGTERM exit, SIGKILL fallback after StopGrace, natural exit code
capture, idempotent Stop under concurrent callers, and LogSink
capture. Integration test exercises Spawn + WaitForURL together with
a python listener subprocess (skipped if python3 missing).
No behaviour change in this commit — the package is dead code until
the next one.
Refs spec: specs/046-local-launcher-for-http-sse/plan.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires internal/upstream/launcher into Client.Connect for the http, sse, and streamable-http transports when ServerConfig.Command is also set. The launcher spawns the child process, blocks on launcher.WaitForURL until the listener accepts a TCP connection, then hands off to the existing transport-level connectHTTP / connectSSE. On Disconnect, the MCP client is closed first (so the child sees the network drop cleanly) and the launcher Handle is Stop()ed second (SIGTERM -> grace -> SIGKILL). Stdio is deliberately excluded: mcp-go's Stdio transport spawns its own child via its CommandFunc, and routing that through launcher.Spawn would require patching mcp-go. Instead, both the stdio CommandFunc path and the new HTTP/SSE launcher path call into the same set of *Client command-prep helpers (setupDockerIsolation, wrapWithUserShell, injectEnvVarsIntoDockerArgs, insertCidfileIntoShellDockerCommand), preserving the spec's "Docker isolation in one place" requirement without the riskier stdio refactor. Config / API: - ServerConfig.LauncherWaitTimeout (Duration, default 30s) caps the wait between spawn and listener-up. CopyServerConfig carries it. - Client gains launcherHandle / launcherCIDFile fields. Stdio Clients leave them nil so existing cleanup paths (killProcessGroup, processCmd.Process.Kill) keep their single owner. Behaviour back-compat: when Command is set together with a URL but no explicit protocol, Command still wins -> stdio, URL ignored (matches today). To opt into the launcher you must set protocol to "http", "sse", or "streamable-http" explicitly. Locking: Connect holds c.mu for its duration, so the failure-cleanup path inlines the launcher teardown and releases c.mu briefly around handle.Stop (which can block until the child is reaped). The connecting flag prevents a concurrent Connect from sneaking in during that window. DisconnectWithContext releases c.mu before its stopLauncher call, so the public method uses normal locking. Watch goroutine: on unexpected child exit while connected, watchLauncher invokes Disconnect so the existing reconnect loop in internal/upstream/manager takes over instead of waiting for the transport's keepalive to time out. Verified by go vet + the full internal/upstream/... and internal/config/... test suites (existing tests stay green; deadlock in connect-failure cleanup that surfaced in TestClient_Connect_SSE_ NotSupported was fixed before commit). Refs spec: specs/046-local-launcher-for-http-sse/plan.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…(Phase 2)
- docs/configuration.md: new "Locally-launched HTTP / SSE servers"
section walks through the {command, url, protocol: http|sse|
streamable-http} combo, a config example with launcher_wait_timeout,
per-server log routing, and a back-compat behaviour matrix that
makes the "command + url under auto -> stdio wins" footgun explicit.
- docs/cli-management-commands.md: restart semantics note covering the
launcher stop-then-start order and the 5s SIGKILL grace timeout.
- specs/046-local-launcher-for-http-sse/execution_log.md: design
decisions, deviations from the original plan (stdio refactor scoped
down to shared command-prep helpers), bugs found and fixed during
verification (connect deadlock, bytes.Buffer race, banner false-
positive in SIGKILL test, ftp-with-port acceptance), and the
exact verification commands.
Refs spec: specs/046-local-launcher-for-http-sse/plan.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the gap unit tests can't: proves a built mcpproxy binary actually
spawns a self-launched HTTP MCP server, completes the MCP handshake,
serves tools/list through the proxy, and reaps the child cleanly on
disable / restart / shutdown.
What's new:
- test/launcher-server/main.go — minimal HTTP MCP fixture (~220 LOC).
Implements initialize, tools/list (returns a "ping" tool),
tools/call, plus 405-on-GET so mcp-go's StreamableHTTP transport
falls back to POST-only. Exits cleanly on SIGTERM (5s shutdown).
Heartbeat to stdout proves the per-server log pump works.
- test/e2e-config.template.json — new "launcher-test" upstream
({protocol: http, command: ./test/launcher-server/launcher-server,
url: http://127.0.0.1:39933/mcp, launcher_wait_timeout: 10s}).
- scripts/test-api-e2e.sh:
* Prereq step now `go build`s the fixture before booting mcpproxy.
* New wait_for_launcher_test_server() polls /servers until
launcher-test reports connected.
* New test_launcher_lifecycle() runs six sub-assertions:
1. tools/list returns the fixture's "ping" tool through the
proxy (proves Spawn -> WaitForURL -> connectHTTP -> MCP
initialize -> ListTools end-to-end).
2. pgrep finds the child process by argv.
3. POST /servers/launcher-test/restart yields a different PID.
4. POST /servers/launcher-test/disable reaps the child within 8s.
5. POST /servers/launcher-test/enable respawns it with a fresh
PID.
6. /servers/launcher-test/logs?tail=200 contains the launcher
banner or fixture stdout.
* cleanup() pkill's any leaked launcher-server processes so a
failure mid-test doesn't taint the next run.
.gitignore: ignore the built fixture binary
(/test/launcher-server/launcher-server). Source stays tracked.
Refs spec: specs/046-local-launcher-for-http-sse/plan.md (Phase 2 e2e
follow-up that was listed as outstanding in the PR test plan).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Thanks Roman, this is a really nice piece of work. 🙏 The launcher/transport decoupling is the right factoring, and Threat-model-wise this is a UX expansion, not a security expansion: the launcher reuses the same CI is red on Merging. Thank you again — this is a really useful feature. |
|
Quick heads-up @electrolobzik — while pushing the test fix in #454, the CI on Linux runners caught a second issue I'd missed in the original review: Flipped the order in #454 (pumps drain first, then Wait). 20x with Thanks again for the feature. |
Summary
Implements spec 046 — let mcpproxy spawn the upstream's local process before connecting via HTTP / SSE / streamable-HTTP, instead of only stdio. Decouples launcher (how the process starts) from transport (how the protocol bytes flow) so
{command, url, protocol: "http"|"sse"|"streamable-http"}is a first-class config combo.See
specs/046-local-launcher-for-http-sse/plan.md(already on this branch) for the design rationale, codebase reconnaissance, gotchas, and definition of done.specs/046-local-launcher-for-http-sse/execution_log.mddocuments the deviations from the plan and the bugs found during verification.What's new for users
{ "name": "local-http-mcp", "protocol": "http", "url": "http://127.0.0.1:9999/mcp", "command": "node", "args": ["./server.js", "--port", "9999"], "working_dir": "/path/to/repo", "launcher_wait_timeout": "15s", "enabled": true }mcpproxy now: spawns
node, polls 127.0.0.1:9999 via TCP-dial until it accepts, then runs the HTTP transport handshake. Disconnect / restart / disable / shutdown reap the child with SIGTERM → grace → SIGKILL. Child stdout+stderr land in the per-server log, somcpproxy upstream logs <name>shows them.docs/configuration.mdhas the full behaviour matrix including the back-compat note: underprotocol: "auto",commandstill wins overurland forces stdio — you must setprotocolexplicitly to opt into the launcher.What's new internally
New package
internal/upstream/launcher/— owns the child's lifecycle:Spec/Handle/Spawn(ctx, spec, log) (Handle, error). Stop is idempotent and blocks until the child is reaped, so callers can rely on "Stop returned → port is free."WaitForURL(ctx, url, timeout)— TCP-dial polling, nothttp.Get(SSE GETs stream forever — gotcha Enhance configuration management and signal handling in mcpproxy #2 in the plan). Infers default ports for http/https/ws/wss.Setpgid: true, so signals reach grandchildren spawned bysh -c …anddocker run …. Windows: best-effort stubs matching the existingprocess_windows.goTODO.Wiring:
internal/upstream/core/connection.go— pre-transport launcher dispatch for http/sse/streamable-http whenCommand != "". Stdio is excluded because mcp-go's Stdio transport spawns through its own CommandFunc; running the launcher there would double-spawn.internal/upstream/core/connection_launcher.go—connectWithLauncher,stopLauncher,watchLauncher,buildLauncherCmd. Reuses the existingsetupDockerIsolation,wrapWithUserShell,injectEnvVarsIntoDockerArgs,insertCidfileIntoShellDockerCommandhelpers so Docker isolation lives in one place (gotcha Remove unused isDatabaseOpen method from Manager struct to streamline code and improve maintainability. #4).internal/upstream/core/connection_lifecycle.go—stopLauncheris called after the MCP-client close in Disconnect, so the child sees the network drop before SIGTERM.internal/config/{config,merge}.go—ServerConfig.LauncherWaitTimeout Duration(default 30s), copied byCopyServerConfig.Deviation from plan: Phase 0 originally contemplated rerouting stdio through
launcher.Spawnas well. That would require patching mcp-go (the Stdio transport spawns via its ownCommandFuncand there's no way to feed in an externally-spawned child). To honor the spirit — "Docker-isolation logic must live in one place" — both paths share the command-prep helpers instead. Stdio behaviour is unchanged. Seeexecution_log.mdfor the full reasoning.Test plan
go vet ./internal/upstream/launcher/... ./internal/upstream/core/... ./internal/config/...— clean.go test ./internal/upstream/launcher/...— 15/15 pass. Covers immediately-bound / late-bound / never-bound / ctx-canceled / bad-URLWaitForURL, graceful SIGTERM exit, SIGKILL fallback after StopGrace, natural-exit code capture, idempotent Stop under concurrent callers, LogSink capture, and an integration test that combines Spawn + WaitForURL with a python subprocess.go test ./internal/upstream/... ./internal/config/...— all green.TestClient_Connect_SSE_NotSupportedsurfaced a deadlock in the connect-failure cleanup that was fixed before commit.scripts/test-api-e2e.shnow builds a small in-tree HTTP MCP fixture (test/launcher-server) and runs six sub-assertions via the REST API — tools/list reaches the launched server, pgrep finds the child, restart yields a new PID, disable reaps within 8s, enable respawns with a fresh PID, per-server log captures the launcher banner. Verified end-to-end on a macOS developer host (all six pass with real PIDs).Successfully connected and initializedreachesmcpproxy upstream logs, repeatedtools/listpolls succeed, restart/disable/enable cycles reap and respawn cleanly on a macOS developer host.go build -tags server ./cmd/mcpproxy— sandbox-blocked locally (CDN policy); CI will confirm.go test -race ./internal/upstream/launcher/...— sandbox can't install gcc; CI will confirm.Out of scope / future work
{port}templating in args/url for ephemeral ports.python3 -c …with a Go test-binary helper ininternal/upstream/launcher/testdata/.connectHTTP's auth-fallback ladder so a non-401 transport failure doesn't surface as "OAuth authentication required" (separate concern; predates this PR).🤖 Generated with Claude Code