Skip to main content
Glama
trym-s

Lazy MCP Broker

by trym-s
README.md
# Lazy MCP Broker

A localhost MCP lifecycle broker that keeps reviewed tool schemas available while
scaling expensive stdio MCP servers and browsers to zero when idle.

This repository is a deliberately narrow reference implementation for a hard
operational case: several long-lived AI clients share browser-heavy MCP servers
across WSL and Windows, but those servers must not stay resident, leak processes,
or lose session isolation.

> **Status:** working reference deployment, not a drop-in universal gateway. The
> checked-in `config.json` and systemd unit contain deployment-specific absolute
> paths. Replace them before using this on another machine.

## The problem

Registering Playwright, Chrome DevTools, and similar MCP servers directly in
every agent process creates several problems:

- every client starts or owns its own expensive backend;
- Chromium, Windows Chrome, Node, and receiver processes can survive cancellation
  or transport failure;
- browser state must be isolated per MCP session while some backends must remain
  singleton;
- a health endpoint can be green even when `npx`, the browser executable, or the
  real MCP call path is broken;
- killing processes by executable name is unsafe on a workstation with unrelated
  Chrome, Node, Codex, or browser sessions;
- keeping all heavy servers alive wastes memory and process slots even when only
  their tool schemas are needed.

Lazy MCP Broker keeps one small HTTP service alive, serves validated cached tool
catalogs without starting upstreams, and starts only the backend that receives a
real `tools/call`. After a bounded idle period, that backend and every process it
owns must return to zero.

## Why build this when MCP gateways already exist?

There are good existing projects. Use them when their model fits:

| Project | What it is good at | Why this repository still exists |
| --- | --- | --- |
| [Docker MCP Gateway](https://github.com/docker/mcp-gateway) | Broad, container-oriented MCP orchestration, isolation, credentials, policy, logging, and lifecycle management. | This deployment must supervise host processes on both sides of WSL, including Windows Chrome identified through `Win32_Process`. It needs exact generation ownership and workstation-safe cleanup rather than making containers the ownership boundary. |
| [MetaMCP](https://github.com/metatool-ai/metamcp) | A full aggregator, orchestrator, middleware, and gateway with namespaces and a management surface. | That is a broader control plane. This project is intentionally a small localhost process supervisor with fixed reviewed configuration, stable per-backend URLs, no database, and no remote multi-user surface. |
| [mcp-proxy](https://github.com/sparfenyuk/mcp-proxy) | Bridging stdio MCP servers to Streamable HTTP/SSE and connecting between MCP transports. | Transport conversion alone does not provide this broker's per-session/singleton scopes, bounded FIFO dispatch, idle scale-to-zero, generation invalidation, or exact cross-OS process cleanup. |
| [RaiAnsar/mcp-gateway](https://github.com/RaiAnsar/mcp-gateway) | Reducing schema/context cost by exposing four gateway tools and loading selected servers on demand. | Its proxy-tool model changes how the model discovers and calls tools (`gateway_load_server`, `gateway_call_tool`). This broker preserves each upstream's reviewed native tool schema and stable MCP endpoint, so normal tool routing still works without gateway-tool hints. It also shuts idle backends down and verifies their owned processes are gone. |

So the claim is not “other gateways do not work.” The missing combination for
this deployment was:

1. native cached schemas at stable HTTP MCP endpoints;
2. no heavy process on `initialize` or `tools/list`;
3. per-session browser isolation plus singleton backends;
4. bounded queues, calls, cancellation, shutdown, and recovery;
5. fail-closed ownership cleanup across Linux/WSL and Windows;
6. an installed-service probe that makes real tool calls and then proves
   scale-to-zero.

If you need a general enterprise gateway, start with Docker MCP Gateway or
MetaMCP. If you only need transport conversion, use `mcp-proxy`. If replacing
native tools with a small lazy gateway API is acceptable, RaiAnsar's
`mcp-gateway` is simpler. This project is for the narrower lifecycle and process
ownership problem above.

## Architecture

```text
Hermes CLI 1 ─┐
Hermes CLI 2 ─┼─ Streamable HTTP → broker :8788
Gateway ──────┘                       │
                                      ├─ Playwright MCP + Linux Chromium
                                      │    per session, lazy
                                      ├─ Chrome DevTools MCP + Windows Chrome
                                      │    per session, lazy
                                      └─ Agentation MCP
                                           singleton, lazy
```

The request flow is:

```text
initialize / tools/list
  → validate and return cached catalog
  → do not start an upstream process

tools/call
  → validate the tool against the reviewed catalog
  → resolve the session or singleton backend instance
  → enqueue within a bounded FIFO
  → cold-start one upstream generation if needed
  → execute with a deadline
  → return the MCP result

idle / cancellation / timeout / crash / service shutdown
  → stop accepting work for the affected generation
  → bounded graceful MCP close
  → exact-generation POSIX cleanup
  → exact-marker Windows cleanup when configured
  → re-query ownership
  → stopped only when owned-process count is zero
```

## Lifecycle invariants

### Generation isolation

Every cold start increments a generation. Async callbacks capture the instance
and generation, so stale work from generation N cannot mutate generation N+1.
A timeout, cancellation, or transport failure invalidates the active generation
before recovery.

### Fail-closed cleanup

A backend cannot silently move to `stopped` when cleanup is uncertain:

```text
graceful close
→ POSIX exact-generation cleanup
→ Windows exact-marker query/terminate/re-query
→ zero verified: stopped
→ query failure or survivors: cleanup_failed
```

`cleanup_failed` blocks new calls, previously queued calls, and replacement
generations. Graceful close has a sub-budget so it cannot consume the entire stop
deadline and prevent ownership cleanup.

### Safe process ownership

Linux/WSL children carry exact `LAZY_MCP_INSTANCE_ID` and
`LAZY_MCP_GENERATION` environment markers. Their start identity is revalidated
immediately before signaling; `pidfd` is used when available.

Windows Chrome generations use an exact broker-generated command-line marker:

```text
hermes-lazy-mcp\chrome-{instance_id}-{generation}
```

The broker queries `Win32_Process`, retains bounded process identity fields,
revalidates PID plus creation timestamp before termination, and re-queries until
zero or deadline. It never runs broad `taskkill`, `pkill chrome`, or
name-matched Node cleanup.

### Hard evidence deadline

The synthetic probe places blocking systemd and PowerShell evidence collection
behind a dedicated process-group boundary. If the monotonic deadline expires,
the whole helper group is killed and reaped. Cancelling a worker thread is not
accepted as timeout proof.

## Current reference backends

| Backend | Scope | Idle timeout | Queue limit | Upstream |
| --- | --- | ---: | ---: | --- |
| Playwright | per MCP session | 120 s | 32 | `@playwright/mcp@0.0.79` |
| Chrome DevTools on Windows | per MCP session | 120 s | 32 | `chrome-devtools-mcp@1.8.0` |
| Agentation | singleton | 60 s | 64 | `agentation-mcp@1.2.0` |

Package versions and catalogs are pinned so an upstream schema change cannot
silently expose a new mutating tool.

## Endpoints

- `http://127.0.0.1:8788/playwright/mcp`
- `http://127.0.0.1:8788/chrome-devtools-win/mcp`
- `http://127.0.0.1:8788/agentation/mcp`
- `http://127.0.0.1:8788/health`
- `http://127.0.0.1:8788/ready`
- `http://127.0.0.1:8788/status`

`/health` proves event-loop liveness. `/ready` proves validated configuration,
catalogs, and running supervisors without launching a browser. Neither endpoint
claims that the real upstreams work. Use the synthetic probe for that.

## Requirements

The reference deployment currently assumes:

- Python 3.11+;
- `uv` or another Python environment manager;
- Node.js and `npx`;
- Linux with systemd user services;
- WSL interop and Windows PowerShell for the Windows ownership backend;
- installed Linux and Windows Chromium executables.

The core broker can run without Windows ownership mode, but the checked-in
production configuration is specifically a WSL + Windows Chrome deployment.

## Setup

### 1. Install Python dependencies

```bash
git clone https://github.com/trym-s/lazy-mcp-broker.git
cd lazy-mcp-broker
uv sync
```

### 2. Adapt the reference configuration

Edit `config.json` and replace all machine-specific paths:

- Linux `npx` path;
- Linux Chromium executable;
- Windows home directory;
- Windows Chromium executable;
- catalog paths if the working directory changes.

Do not weaken the Windows ownership marker or replace it with executable-name
matching.

### 3. Validate locally

```bash
PYTHONPATH=src uv run pytest -q
PYTHONPATH=src uv run python -m lazy_mcp_broker.server --config config.json
```

In another shell:

```bash
curl --fail http://127.0.0.1:8788/ready
curl --fail http://127.0.0.1:8788/status
```

### 4. Install the user service

`deploy/hermes-lazy-mcp-broker.service` is a reference unit with absolute paths.
Copy it only after editing `Environment=PYTHONPATH` and `ExecStart`:

```bash
mkdir -p ~/.config/systemd/user
cp deploy/hermes-lazy-mcp-broker.service \
  ~/.config/systemd/user/hermes-lazy-mcp-broker.service
systemctl --user daemon-reload
systemctl --user enable --now hermes-lazy-mcp-broker.service
```

Then point MCP clients at the stable endpoint URLs above.

## Verification

### Fast suite

```bash
PYTHONPATH=src uv run pytest -q
PYTHONPATH=src uv run python -m unittest scripts/test_real_harness.py -v
```

### Installed-service synthetic proof

```bash
PYTHONPATH=src uv run python scripts/synthetic_probe.py \
  --base http://127.0.0.1:8788 \
  --output runtime-evidence/synthetic-latest.json
```

The probe:

1. requires a clean baseline;
2. makes harmless real Playwright, Windows Chrome DevTools, and Agentation calls;
3. observes the exact Chrome generation marker while active;
4. waits only the configured idle-plus-stop bound;
5. requires zero active and queued calls;
6. requires zero non-idle backend instances;
7. requires zero heavy broker-owned Linux processes;
8. requires zero Windows processes for every recorded marker;
9. requires unchanged broker PID and restart count.

The most recent controlled implementation run passed 47 tests, the harness
suite, an exact-marker Windows decoy test, all three real backend calls, and final
scale-to-zero. Raw runtime evidence is intentionally ignored by Git because it is
machine-specific. See [docs/REAL_WORLD_PROOF.md](docs/REAL_WORLD_PROOF.md) for the
recorded acceptance evidence and its limitations.

## Refreshing cached catalogs

After intentionally upgrading an upstream MCP package:

```bash
PYTHONPATH=src uv run python scripts/refresh_real_catalogs.py
```

Review every catalog diff before restarting the broker. A catalog includes the
package name, pinned version, generation timestamp, schema digest, enabled tools,
and explicitly excluded tools. Startup rejects catalog tampering or mismatch.

## Security boundaries

- The server is loopback-only and rejects unexpected Host/Origin values.
- Backend commands, arguments, environment additions, and deadlines come from
  reviewed configuration, never client input.
- Browser profiles are headless and isolated.
- Chrome DevTools usage statistics and CrUX requests are disabled.
- Network headers returned by Chrome DevTools are redacted.
- Status and lifecycle logs omit raw arguments, credentials, URLs, page content,
  backend output, and full process command lines.
- Queues are bounded; overload is explicit rather than unbounded memory growth.
- Cleanup targets exact generation ownership, never global executable names.

This is **not** a remotely exposed multi-user gateway. There is no authentication
layer because the broker is designed to bind only to `127.0.0.1`. Do not expose
port 8788 to a LAN or the internet without adding a real authentication and
authorization boundary.

## Known limitations

- `config.json` and the systemd unit are deployment-specific references, not an
  installer-generated portable configuration.
- The synthetic probe is currently specialized for the three reference
  backends.
- Cached catalogs must be refreshed deliberately after upstream upgrades.
- A literal Windows/WSL reboot acceptance run has not yet been recorded; service
  restart and user-service enablement have been verified.
- A Hermes Agent one-shot shutdown path can emit
  `_watch_stdio_children was never awaited`; it is upstream of this broker and
  remains unresolved here.

## Documentation

- [Architecture](docs/ARCHITECTURE.md)
- [Operations](docs/OPERATIONS.md)
- [Testing](docs/TESTING.md)
- [Real-world proof](docs/REAL_WORLD_PROOF.md)
- [Runtime ownership and synthetic proof specification](docs/specs/runtime-ownership-and-synthetic-proof.md)