mcp-orchestrator
by mrconter1
README.md
# mcp-orchestrator
Manages the local MCP servers on a Windows machine. Installs them, starts them
hidden at logon, restarts them when they crash, and shows the lot in a tray
icon.
One hidden process, started by a Scheduled Task, serving MCP on
`http://127.0.0.1:8768/mcp`.
## Install
```powershell
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
.\install\install.ps1
claude mcp add --transport http --scope user orchestrator http://127.0.0.1:8768/mcp
```
Registers the Scheduled Task and adds two Claude Code hooks. Undo with
`.\install\uninstall.ps1`.
## Installable servers
```
mcp_install("queue")
```
| Server | Repository | What it does |
| --- | --- | --- |
| `session` | [session-control-mcp](https://github.com/mrconter1/session-control-mcp) | List, open, fork, close and restart Claude Code sessions |
| `queue` | [queue-mcp](https://github.com/mrconter1/queue-mcp) | A task queue you fill from another terminal |
`mcp_install` also takes any git URL, or a local path for a private repo. It
expects the ordinary Python layout: a package containing `server.py`, with
`requirements.txt` beside it.
### Your own servers: `catalogue.json`
Servers in private repositories, or ones that only make sense on your machine,
go in `~/.mcp-orchestrator/catalogue.json` instead of the built-in list, so this
public repository never names them. `mcp_catalogue` shows both, and
`mcp_install("<name>")` works the same for either. A private repo clones with
whatever git credentials the machine already has.
```json
{
"servers": [
{
"name": "mail",
"repo": "https://github.com/you/mail-mcp",
"module": "mail_mcp.server",
"port": 8772,
"port_env": "MAIL_MCP_PORT",
"description": "Outlook mailbox",
"setup": ["Copy local.env from an existing install.", "Run .\\login.ps1 once."]
}
]
}
```
`setup` lists what a clone cannot bring (a browser login, a git-ignored secret);
`mcp_install` returns it as `next_steps`. A local entry with a built-in's name
replaces the built-in.
### Keeping servers current: `mcp_update`
```
mcp_update() # every managed server
mcp_update("queue") # one
```
Fetches, fast-forwards the checked-out branch to its upstream, reinstalls
`requirements.txt` and restarts the server, only when new commits came in. A
checkout with uncommitted changes, unpushed commits or no upstream is reported
and left alone: that is somebody's work in progress. Sessions keep the tools
they started with, so follow with `sessions_restart_pending` when tools changed.
## Tools
| Tool | Purpose |
| --- | --- |
| `mcp_list` / `mcp_status` / `mcp_stats` | what is running, and how it has behaved |
| `mcp_start` / `mcp_stop` / `mcp_restart` | lifecycle |
| `mcp_catalogue` / `mcp_install` / `mcp_uninstall` | install and remove servers |
| `mcp_update` | fetch and fast-forward managed servers, reinstall, restart |
| `mcp_scaffold` | write a new server from a template |
| `mcp_add` / `mcp_remove` | manage a server already on disk |
| `mcp_autostart` / `mcp_enable` | start at logon, or park a server |
| `mcp_logs` | a hidden server's only voice |
| `sessions_*` | list sessions, and restart them when the server set changes |
## What it does about failure
A hidden server has no window to die in, so the whole design is about not
letting a dead one look like a missing tool.
- **States distinguish causes.** `crashed` (died on its own) is not `stopped`
(somebody stopped it), and `unhealthy` (alive, not listening) is not either.
`external` means the port answers but the orchestrator did not start it, and
will not touch it.
- **A start waits for the port** before reporting success. A pid proves
nothing.
- **Servers outlive the orchestrator.** On startup it re-adopts anything still
running its configured command, matched by command line, so a server started
by a previous orchestrator is supervised rather than written off as
`external` and left to fend for itself.
- **Anything down is restarted** on a 5, 15, 45, 120, 300 second ladder, then
abandoned with a notification. Two minutes of health clears the count. Down
means down: `crashed`, `unhealthy`, or simply not running. The one exception
is a server stopped through `mcp_stop`, which is recorded as deliberate at the
moment it is asked for and left alone until it is started again. `unhealthy`
has to hold for two samples first; one missed 350 ms probe is not a verdict.
- **The orchestrator watches itself too.** If its own endpoint stops answering
for three samples it exits with code 3. The Scheduled Task restarts a failed
run, and the replacement re-adopts the servers, which were never touched.
- **Servers must run on the selector event loop.** Python's default Windows
loop, the Proactor, closes its listening socket when a client resets a
connection before the accept completes (`WinError 64`), and the process then
lives on answering nothing. That single mechanism accounted for every
"unhealthy" restart in the first month. The scaffold template and this
server use `asyncio.run(..., loop_factory=asyncio.SelectorEventLoop)`; any
server added by hand should do the same.
- **The tray colour is the summary.** Green up, amber starting or stopped, red
with an exclamation for trouble.
## Sessions and the restart dance
Claude Code attaches MCP servers at startup and never retries. **Adding or
removing** a server is invisible to running sessions until they restart, so
`mcp_install`, `mcp_scaffold`, `mcp_add` and `mcp_uninstall` ask them to.
**Restarting** a server needs nothing; the client reconnects per call.
The restart is decentralised. A change drops a marker, and each session's Stop
hook acts on it at the end of a turn, when nothing is in flight, resuming the
same transcript so history and queue survive.
## Locking it down (bundled installs)
The orchestrator can run any command line it is told to (`mcp_add`, `mcp_install`, `mcp_scaffold`, `mcp_update`). 127.0.0.1 keeps other machines out and the SDK's Host check keeps web pages out, but another program or another Windows user on the same machine can still reach the port. Two switches, both off by default:
- `MCP_ORCHESTRATOR_TOKEN_FILE=<path>`: every `/mcp` request needs `Authorization: Bearer <token>`. The token is kept DPAPI-protected in that file and made on first start. A file that exists but cannot be read locks the endpoint (503) rather than opening it.
- `MCP_ORCHESTRATOR_READONLY=1`: the add/remove/scaffold/install/update/uninstall tools are not registered. Start, stop, restart, logs and status still work.
An installer that ships its own fixed server set should turn both on.
## Files
State lives in `~/.mcp-orchestrator`, outside this repo, because the hooks must
find it without knowing where the repo was cloned: `servers.json` (hand
editable), `running.json`, `sessions.json`, the restart markers, and `logs/`.
## Known gaps
- The at-logon trigger is configured but has only ever been fired manually.
- The live session restart path (`wt.exe` plus `--resume`) is unproven.
- Windows only, throughout.
[DESIGN.md](DESIGN.md) has the reasoning, including why this is a Scheduled
Task and not a Windows Service, and the Windows traps that cost a debugging
round each.
This server cannot be deployed