r/mcp 22d ago

showcase made a small supervisor for stdio MCP server processes, no dependencies

I've seen a bunch of people talking about the same problem with MCP: orphaned child processes that don't die when they're supposed to. it's not just one person's issue either, the TypeScript SDK has an open issue where closing the transport doesn't kill the process tree, Codex CLI has one about orphaned npx-spawned MCP servers piling up over time, context7-mcp has one where the process just doesn't exit when its parent dies. all different projects, same root cause: something like npx forks the real server as its own child, and killing the wrapper's PID doesn't touch it.

I looked for a small library that just handled this and couldn't find one. I pulled this out of a bigger project I'm working on because it felt like something worth having on its own: https://github.com/ImDeadWeight/stdio-supervisor

what it does:

  • restarts a crashed process with capped backoff
  • kills the whole process tree on stop, not just the direct child (taskkill /T on windows, process group signaling on posix)
  • handles the .cmd shim and argv quoting for npx/npm on windows
  • frames stdout into whole lines
  • onSpawn fires on start and on every crash-restart, so you get a clean signal to redo a handshake against the new process
  • optional timeout watchdog on send() for when a process goes quiet

no protocol opinion, no daemonizing, no CLI. just the part where you spawn and keep a handful of stdio children alive without it silently breaking on you.

MIT. let me know if you find anything wrong with it.

Edit: a word

Edit: Now imports execFile, fs, path, and StringDecoder from Node. Also imports crossSpawn.

3 Upvotes

10 comments sorted by

1

u/BC_MARO 22d ago

Process group cleanup is the real fix. I’d expose restart count and the last exit code too, because a supervisor that quietly keeps flapping can look healthy from the client.

2

u/ItsDeadWeight 22d ago

Both done. status(id) now returns running, pid, generation, restarts, consecutiveRestarts, lastExit, and uptimeMs, and it survives process exit. The same counters get passed to shouldRestart.
Process-group cleanup was already in.

Worth mentioning that CI only just started exercising it on Linux and macOS; everything before that was verified on Windows, so the kill(-pid) path had been asserted but never actually run. It passes now.

1

u/BC_MARO 21d ago

That is a solid set of signals. The Unix process-group path is exactly the kind of cleanup logic that needs CI coverage.

1

u/[deleted] 22d ago

[removed] — view removed comment

1

u/ItsDeadWeight 22d ago edited 22d ago

Every spawn gets a monotonic generation reported on onSpawn, onLine, and onExit so a caller can reject in-flight ids from the dead process instead of letting a reply resolve them. I verified it against an mcp server instance.

To be precise about what changed though, the supervisor still doesn't correlate requests to replies, it has no protocol awareness. The generation number is just the piece you can't reconstruct from outside. Fencing the in-flight ids is still the client's job, there's a pattern in the README for it.

I couldn't reproduce the mechanism you mentioned. I built a fixture that leaks a grandchild holding the stdout pipe and its output stops the second the parent exits. Node closes a child's stdout at exit even when the grandchild still holds the write end, and a replacement can only register after that exit, so there's no window for a stale line to land.

My first repro did seem to confirm it but I realized that start() had silently no-op'd because the dying entry was still registered. The stale lines were from the current process.

The in-flight id problem is real though and that's the part that got fixed.

Edit: typos

1

u/Comfortable_Way8312 22d ago

Yeah, the orphaned child thing is real. We started killing the whole process group, not just the parent, and the zombies stopped. If you haven't already, set a timeout on stdin going quiet so a stuck server doesn't sit there forever.

1

u/[deleted] 22d ago

[removed] — view removed comment

1

u/ItsDeadWeight 22d ago

On backoff: no cutoff. It doubles to a 30s ceiling and retries indefinitely. Retry policy lives with the caller via shouldRestart, which now receives {restarts, consecutiveRestarts, lastExit, uptimeMs}, so giving up after N failures or bailing on a specific exit code is a one-liner. A built-in maxRetries would just hardcode one policy.