lazy-mcp-router
by naive000
README.md
# lazy-mcp-router
Safety-first prototype for a local Codex MCP tool gate.
This project does not modify PJ-Monitor and does not modify `~/.codex`.
## L4 Control Plane Entrypoints
Install from a Git URL:
```bash
uv tool install git+https://github.com/<owner>/lazy-mcp-router.git
```
Product-level commands:
```bash
lazy-mcp-router doctor --pretty
lazy-mcp-router doctor --full --pretty
lazy-mcp-router catalog --query git --pretty
lazy-mcp-router onboard --pretty
```
`doctor` reports the current L0-L5 level, hard blockers, soft blockers, and next
actions. `catalog` exposes router-visible tools and evidence. `onboard` previews
profile changes by default; `onboard --apply` is required before any Codex
profile config is modified.
## Current Loop
```text
search_tools -> describe_tool -> load_server -> call_tool -> audit trace -> status -> cleanup
```
## Safety Contract
Step 2 uses zero-content evidence receipts:
- policy defaults to read-only calls
- missing required env blocks backend startup
- required env is not automatically injected into child processes
- receipts store hashes, policy decisions, latency, state, and redaction evidence
- receipts do not store raw args, raw results, raw stderr, env values, or full schemas
- receipts stay in memory by default; JSONL persistence is opt-in
Safe observability methods:
- `healthz()`
- `status()`
- `backends()`
- `tools()`
- `capabilities()`
- `receipts_recent()`
- `receipt(receipt_id)`
## MCP Control Registry
Step 3 adds a read-only registry layer for local Codex MCP inventory:
- scans `~/.codex/config.toml`, `~/.codex/*.config.toml`, and `~/.codex/mcp-auto.tsv`
- reads only; it does not start servers, inject env values, or modify global config
- builds proof-carrying backend receipts with trust tier and activation level
- uses repo-local `router.config.toml` or `LAZY_MCP_ROUTER_CONFIG` for allowlist overlay
- keeps capability output zero-content: no raw args, raw config, env values, or secret tokens
Trust tiers:
- `T0_BLOCKED`
- `T1_OBSERVED`
- `T2_LOCAL_CANDIDATE`
- `T3_TRUSTED_REMOTE_CANDIDATE`
- `T4_CONFIGURED_BACKEND`
- `T5_RUNTIME_APPROVED`
Optional read-only HTTP surface:
- `GET /healthz`
- `GET /status`
- `GET /backends`
- `GET /tools`
- `GET /capabilities`
- `GET /receipts/recent`
- `GET /receipts/{id}`
The HTTP server binds to `127.0.0.1:7791` by default, supports an optional bearer
token, and exposes no write/control routes.
## Router Runtime
Step 4 adds the runtime bridge from proof receipts to lazy-startable backends.
Runtime approval is stricter than configuration:
- `T4_CONFIGURED_BACKEND` means the backend is configured and visible in
`/capabilities`, but it is not startable.
- `T5_RUNTIME_APPROVED` means the backend passed the runtime gates and is the
only tier compiled into `LazyMcpRouter.registry`.
- `runtime_enabled=true` is not enough by itself; the configured
`runtime_fingerprint` must match the current canonical runtime contract.
- factory construction, `search_tools()`, and `capabilities()` do not spawn
backend processes.
Runtime gates:
- remote MCP uses `https` only, allowlisted hosts, exact allowlisted paths, and
rejects userinfo, query strings, and fragments
- direct stdio uses MCP stdio only and requires command/argv allowlisting
- required env is injectable only when the key is both required and
`env_allowlist`ed
- env values are resolved only at T5 compile/start boundaries and are never
included in receipts, status, capabilities, or HTTP responses
- allowed tools can be declared with `tool_risks`; undeclared risk remains
`unknown` and is denied unless explicitly allowed
Observability contract:
| Endpoint | Scope | Includes | Excludes |
| --- | --- | --- | --- |
| `GET /capabilities` | all T0-T5 inventory | proof, diagnostics, runtime overlay, expected fingerprint hash | raw command, raw URL path/query, env values, allowed tool names |
| `GET /backends` | compiled T5 runtime registry only | runtime state, pid, startup latency, call count, missing env keys | T0-T4 candidates |
| `GET /status` | aggregate only | capability counts, runtime state counts, audit stats | per-backend detail |
| `GET /tools` | declared/loaded tools from compiled T5 backends | tool ids, names, risk, source | raw schemas unless described/loaded |
Step 4 keeps HTTP read-only. Control routes such as `load_tool` and `call_tool`
remain Step 5 work.
## Control Plane
Step 5 adds a transport-neutral control core plus HTTP as the first adapter.
Control commands:
- `status`
- `capabilities`
- `list_backends`
- `search_tools`
- `describe_tool`
- `load_server`
- `call_tool`
All control responses use a safe envelope:
```json
{
"schema_version": "step5.v1",
"ok": true,
"command": "search_tools",
"result": {},
"error": null,
"receipt_id": null,
"ts": 0.0
}
```
HTTP control routes:
- `POST /control/list_backends`
- `POST /control/search_tools`
- `POST /control/describe_tool`
- `POST /control/load_server`
- `POST /control/call_tool`
- `POST /control/status`
- `POST /control/capabilities`
The HTTP adapter reuses the optional bearer token. GET observability routes stay
unchanged. Control errors are returned as safe envelopes; raw command args, env
values, raw URLs, stderr, and unredacted tool arguments are not returned.
## CLI Adapter
Step 5.5 adds a local debug CLI over the same `ControlPlane`:
```bash
lazy-mcp-router status
lazy-mcp-router capabilities
lazy-mcp-router backends
lazy-mcp-router search echo --limit 10
lazy-mcp-router describe '<tool_id>' --no-load
lazy-mcp-router load 'default::server'
lazy-mcp-router call '<tool_id>' --arguments-json '{"message":"hello"}'
lazy-mcp-router call '<tool_id>' --arguments-file args.json
lazy-mcp-router call '<tool_id>' --arguments-stdin
```
Global flags:
- `--codex-dir PATH`
- `--config PATH`
- `--pretty`
- `--no-npx-check`
- `--env KEY=VALUE`
The CLI always prints a `step5.v1` JSON envelope. It does not persist daemon
state between invocations and does not add policy rules beyond the existing
control plane, policy gate, and T5 runtime gates.
## MCP Server Adapter
Step 7 adds a stdio MCP server over the same `ControlPlane`:
```bash
lazy-mcp-router-mcp --no-npx-check
```
It exposes only router meta-tools:
- `list_backends`
- `search_tools`
- `load_tool`
- `call_tool`
The adapter does not expose backend tools directly, does not mutate
`router.config.toml`, does not inject env values from tool payloads, and does
not relax T5 runtime gates. Tool results include the same `step5.v1` envelope
in MCP `structuredContent`; `content[0].text` contains compact JSON for clients
that only read text tool output.
For the canonical local runtime, run the MCP adapter as a thin client to the
PM2-owned HTTP daemon:
```bash
lazy-mcp-router-mcp --proxy-url http://127.0.0.1:7791 --caller codex-mcp
```
Proxy mode does not construct a direct `LazyMcpRouter` and does not spawn
backend child processes. It forwards the four router meta-tools to
`POST /control/*`, adds safe caller metadata for attribution, and keeps backend
runtime state, cooldown, pids, and receipts in the HTTP daemon that PJ-Monitor
observes. Direct mode remains available for tests and local development.
## Clean-room Codex Smoke
Step 10.10.5 verifies the proxy-mode MCP adapter with an isolated `CODEX_HOME`
instead of `codex exec --ignore-user-config`. On Codex `0.138.0`, the
`--ignore-user-config` path can enumerate the injected MCP server but cancels
MCP tool calls during execution. The isolated-home smoke avoids that CLI edge
case while still avoiding the user's base `~/.codex/config.toml`.
```bash
scripts/step10_10_clean_room_smoke
```
The smoke writes `state/step10.10.5-clean-room-smoke.json`, isolates config in a
temporary Codex home, and uses a temporary `auth.json` symlink to the existing
Codex auth file when available. If that auth file is unavailable, it falls back
to `codex login --with-api-key` from `OPENAI_API_KEY`. The temporary home is
deleted after the run, and token values are never written into the artifact.
## Lifecycle Hardening
Step 8 hardens lazy backend runtime behavior:
- state transitions are recorded as receipts
- failed backends enter cooldown before another start attempt
- call timeout clears stale pids and stops router-owned child processes
- backend health probes reconcile dead HOT children during runtime snapshots
- audit receipts are protected by a lock for concurrent control requests
- HTTP control on non-loopback hosts requires a bearer token
Backend runtime rows now include trust tier, activation level, tool count,
failure count, cooldown, last transition, and last health probe metadata. They
still exclude raw command args, env values, stderr, raw input, and raw output.
## PJ-Monitor Observability
Step 9 is consumed by PJ-Monitor through read-only router endpoints:
- `/healthz`
- `/status`
- `/backends`
- `/capabilities`
- `/receipts/recent`
The PJ-Monitor MCP Lazy v2 payload can distinguish profile inventory health from
router runtime availability. It includes per-endpoint latency/error summaries,
read-only path proof, health metadata, backend state/tier/latency/failure rows,
and recent receipt summaries. PJ-Monitor does not call router `/control/*`
routes and does not add start/stop buttons for router backends.
## Activation Pipeline
Step 10 adds an activation compiler for repo-local rollout checks. It reads
`router.activation.toml` by default. If that file is missing, the compiler
returns a safe blocked default instead of touching global Codex config.
Dry-run:
```bash
lazy-mcp-router activate --dry-run
lazy-mcp-router activate --dry-run --manifest router.activation.toml --pretty
```
The dry-run prints a `step10.activation.v1` JSON envelope with:
- manifest summary
- router config plan
- shadow profile plan
- capability and T5 readiness
- blocked reasons
- secret redaction proof
Write a repo-local router config plan:
```bash
lazy-mcp-router activate --dry-run --write-router-config --config router.config.toml
```
The generated `router.config.toml` includes allowlists, runtime fingerprints,
env key names, and tool risk metadata. It does not write env values or token
values.
Secret values are resolved from the router process environment only. Activation
manifests should list `env_keys`, `required_env`, and `env_allowlist`; legacy
`env = { KEY = "value" }` rows are ignored and reported as diagnostics.
Minimal synthetic canary manifest:
```toml
[activation]
name = "synthetic-canary"
[[backends]]
profile = "synthetic"
server = "canary"
kind = "synthetic"
command = "/path/to/python"
args = ["tests/fixtures/fake_mcp_server.py", "--scenario", "normal"]
allowed_tools = ["echo"]
tool_risks = { echo = "read" }
```
Python callers can run the synthetic activation smoke without external GitHub
or token dependencies:
```python
from pathlib import Path
from lazy_mcp_router.activation import activation_report
report = activation_report(Path("router.activation.toml"))
```
The report uses the local fake backend flow
`search_tools -> load_tool -> call_tool` and returns
`step10.activation_report.v1` JSON-compatible data.
## HTTP Daemon
Step 10 also adds a daemon entrypoint over the existing HTTP server:
```bash
lazy-mcp-router-http --manifest router.activation.toml --config router.config.toml --host 127.0.0.1 --port 7791 --no-npx-check
```
Options:
- `--manifest PATH`
- `--config PATH`
- `--host HOST`
- `--port PORT`
- `--token TOKEN`
- `--no-npx-check`
Loopback hosts can run without a token. Non-loopback hosts still require a
bearer token through the existing HTTP guardrail.
## Real Backend Dual Canary
Step 10.11 adds the first real runtime backend while keeping the synthetic
canary as a baseline. The repo-local `router.activation.toml` now compiles:
- `synthetic::canary`
- `repo-ops::git-mcp`
`repo-ops::git-mcp` is a no-secret remote MCP backend at
`https://gitmcp.io/docs` and is limited to the read-risk
`fetch_generic_url_content` tool. The real canary calls that tool with
`{"url":"https://gitmcp.io/docs"}`.
Local MCP candidates are still discovery-only in Step 10.11. Run:
```bash
scripts/step10_11_local_discovery
```
This writes `state/step10.11-local-discovery.json` with command names, argument
counts, env key names, and recommendations. It does not start or call local
MCP servers.
## Persistent Shadow Profile Smoke
Step 10.13 turns the persistent `lazy-router-shadow` profile into a repeatable
acceptance check. It reads `/home/crazy/.codex/lazy-router-shadow.config.toml`,
verifies that the profile exposes only `lazy-mcp-router`, and blocks before
running Codex if direct MCP servers such as `git-mcp` reappear.
```bash
scripts/step10_13_shadow_profile_smoke
```
The smoke runs Codex with the real shadow profile:
```text
codex exec -p lazy-router-shadow -s read-only -C /home/crazy/lazy-mcp-router --skip-git-repo-check
```
It then exercises the router meta-tool path
`list_backends -> search_tools -> load_tool -> call_tool` against the
`repo-ops::git-mcp` canary and writes
`state/step10.13-shadow-profile-smoke.json`. The artifact records profile
server names, router/PJ-Monitor postflight status, backend call evidence, and
sanitized Codex command output. It does not set `CODEX_HOME`, does not modify
`~/.codex`, and does not store token or env values.
## Fake Backend Scenarios
- `normal_backend`
- `slow_start_backend`
- `crash_on_start_backend`
- `hang_on_call_backend`
- `duplicate_tool_backend`
- `secret_leak_backend`
## Real Smoke
Phase 3 uses GitMCP through the documented `mcp-remote` stdio bridge:
```text
npx -y mcp-remote https://gitmcp.io/docs
```
Run the live smoke test explicitly:
```bash
RUN_GIT_MCP_SMOKE=1 uv run pytest -q tests/test_phase3_mcp_stdio.py::test_git_mcp_docs_real_smoke -s
```
Step 4 factory/runtime tests cover the local T5 bridge. A future live load-only
smoke can use the same GitMCP bridge without calling tools.
## Verification
```bash
uv run pytest -q
uv run ruff check .
```
TDQS
A3.5/5.0
Scored across 4 tools
Disambiguation5/5
Each tool has a distinct role: listing backends, searching tools, loading a specific tool, and invoking a tool. No overlap in purpose, and the descriptions clearly differentiate the lifecycle stages.
Naming Consistency5/5
All tool names follow the verb_noun pattern in snake_case: list_backends, search_tools, load_tool, call_tool. The naming is uniform and predictable.
Tool Count5/5
Four tools is a focused, well-scoped set for a router server. Each tool serves a necessary part of the workflow without redundancy or bloat.
Completeness4/5
The core router lifecycle is covered: inspect, search, load, and call. A minor gap is the lack of an explicit unload or backend status operation, but the essential functionality is complete.
Maintenance
ActivityInactive
ResponsivenessNo issues