Skip to content

Research — 001 MCP Gateway

Library choices, alternatives evaluated, and references for decisions that are not architecturally significant enough for their own ADR. Architecturally significant choices have their own ADR in docs/decisions/; this file captures the layer below that — concrete dependencies, protocol details, and build-time tooling decisions.

Backend runtime dependencies (additions to backend/pyproject.toml)

AddVersionWhy this choice
sqlalchemy[asyncio]>=2.0,<3.0Async ORM matches FastAPI's async story; SQLAlchemy 2.0 has cleaner typed declarative. Considered raw sqlite3 + handwritten queries — rejected because four kinds × CRUD + retention + audit would be enough hand-written SQL to justify an ORM.
aiosqlite>=0.20Async SQLite driver SQLAlchemy uses under the hood for async sessions.
alembic>=1.13Schema migrations. Considered code-managed schema (call Base.metadata.create_all) — rejected because the constitution requires schema evolution to be reviewed in PRs and reversible.
keyring>=25.0OS keychain access. Only infrastructure/credentials/ imports this (Contract 4).
httpx>=0.27Already a dev dep; promote to runtime. Used for HTTP MCP upstreams and (in tests) the in-process FastAPI test client.
typer>=0.12CLI framework. Considered Click — Typer wraps Click and adds Pydantic-friendly type-hint-driven argument parsing, which matches the existing FastAPI/Pydantic code style.
rich>=13.7CLI output rendering (Typer recommends it; tables + tree views needed by coffer mcp list).
structlog>=24.1Structured JSON logging. Considered stdlib logging + a JSON formatter — rejected because structlog's contextvars integration is needed for the trace-id propagation required by FR-014.
mcp>=1.0 (official Anthropic Python SDK)Used for: (a) the test oracle in contract tests; (b) the framing helpers (stdio_server, streamable_http_server) coffer wraps. Considered handwriting the JSON-RPC framing — rejected because the SDK already implements protocol-level corner cases (cancellation, progress tokens, initialize negotiation) we'd otherwise re-discover.
psutil>=5.9PID liveness + command-line verification for orphan subprocess cleanup (ADR-006). Considered POSIX-only os.kill(pid, 0) — rejected because the orphan sweep needs to verify the process is actually a coffer-spawned MCP server, not an unrelated process that happens to have reused the PID.

MCP protocol — concrete forwarding decisions

These are pinned in ADR-004 and ADR-005; concrete protocol semantics are listed here so the implementation has a single reference point.

Methods coffer forwards

DirectionMethodCoffer behaviour
client → upstreaminitializeCoffer responds itself (declares capabilities to the client). On first need it issues its own initialize to each upstream.
client → upstreamtools/list tools/callForward through namespace transform (Section "Namespace transformation" below).
client → upstreamresources/list resources/read resources/subscribe resources/unsubscribeForward through URI-prefix transform.
client → upstreamprompts/list prompts/getForward through namespace transform.
client → upstreamroots/list (request from upstream)Pass through to downstream client; relay response back.
client → upstreamsampling/createMessage (request from upstream)Capability-negotiated. If downstream client declared sampling capability during initialize, forward; else respond with method-not-found.
upstream → clientnotifications/tools/list_changed notifications/resources/list_changed notifications/prompts/list_changed notifications/resources/updatedForward to downstream client; invalidate coffer's per-upstream cache.
upstream → clientnotifications/progressPass through; preserve progressToken; reset the corresponding pending-request idle timer (Streaming progress decision below).
upstream → clientnotifications/message (server log)Drop (avoid flooding the downstream client; recorded to ~/.coffer/logs/upstream-<name>.log).

Namespace transformation

CapabilityWire form upstream usesWire form coffer presents downstream
Tool<original_name><server_name>__<original_name>
Prompt<original_name><server_name>__<original_name>
Resource URI<original_uri>coffer://<server_name>/<original_uri>

Tool / prompt separator is __ (double underscore). Considered :, ., /, - — all of these collide with characters already legal in upstream tool names. __ is the convention mcpjungle adopted; surveying the public MCP server registry shows zero tool names contain __.

Streaming progress decision

Public MCP gateways (mcpjungle, metamcp, mcp-proxy) all preserve progressToken and reset their request timeout when progress arrives, but none actively forward notifications/progress from upstream to downstream. Verified against mcpjungle/internal/service/mcp/tool.go (progressToken preservation; no progress-forward) and metamcp/apps/backend/src/lib/metamcp/metamcp-proxy.ts (resetTimeoutOnProgress: true; no progress-forward). Coffer matches the ecosystem:

  • preserve incoming _meta.progressToken when forwarding tools/call
  • when upstream sends notifications/progress, reset the per-request idle timer (so a long tool doesn't hit our 120s timeout)
  • v0 does not forward those progress notifications downstream; documented as a non-goal in roadmap.md.

Initialize handshake

Coffer plays both server (to downstream client) and client (to upstream servers). Two separate handshakes:

PairCoffer's roleCapabilities coffer declares
downstream client ↔ cofferservertools, resources, prompts. logging not declared. sampling conditional on downstream client's declared support.
coffer ↔ each upstreamclientroots (so upstream can ask), sampling (pass-through), experimental: {}

Protocol version: coffer declares the highest version supported by its bundled mcp SDK to both sides. Inter-version negotiation is handled by the SDK.

SQLite configuration

PRAGMAs applied at every connection open:

sql
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
PRAGMA synchronous = NORMAL;
PRAGMA busy_timeout = 5000;
PRAGMA cache_size = -64000;
PRAGMA temp_store = MEMORY;

Set via SQLAlchemy event.listens_for(engine.sync_engine, "connect") so they apply to every new connection (WAL mode is per-database but the rest are per-connection).

Daemon discovery file format

~/.coffer/daemon.json (mode 0600):

json
{
  "version": 1,
  "pid": 12345,
  "port": 8001,
  "token": "<32-char URL-safe random>",
  "started_at": "2026-05-20T12:34:56Z",
  "binary_path": "/Applications/Coffer.app/Contents/.../coffer-daemon"
}

version allows future schema evolution. Clients tolerate unknown fields. Mismatched version → client re-reads after a respawn.

Discovery sequence (used by shim, CLI):

  1. Open ~/.coffer/daemon.json; if missing → spawn flow.
  2. Parse JSON; if invalid → backup file + spawn flow.
  3. Check pid liveness via psutil.pid_exists; if dead → spawn flow.
  4. Verify psutil.Process(pid).name() contains coffer-daemon; if not → spawn flow (PID has been recycled).
  5. Open a TCP connection to 127.0.0.1:port; if refused → spawn flow.
  6. Otherwise: connected.

Spawn flow uses flock on ~/.coffer/daemon.lock to serialise concurrent detect-or-spawn races.

Port allocation

Range: 80008009. Tried in order; first free wins. If all 10 are taken, daemon refuses to start with a clear error message and exits non-zero. Range chosen because it's small (operational hygiene), close to the conventional default (8000), and outside the well-known ports range (≥1024).

Test fixtures

fake_mcp_server.py

A real MCP server implemented with the official mcp SDK, parameterised by CLI flags:

bash
python -m coffer.tests.fixtures.fake_mcp_server \
  --scenario basic \
  --tools read_file write_file \
  --resources file:///tmp/example.txt \
  --crash-after-calls 0 \
  --init-delay-ms 0 \
  --notify-list-changed-after 0
ScenarioBehaviour
basicReturns the configured tools/resources/prompts; echoes call args.
slowAdds --init-delay-ms before responding to initialize.
crashExits after --crash-after-calls tool calls.
mutatingSends notifications/tools/list_changed after --notify-list-changed-after calls; subsequent tools/list returns a different set.

Lives at backend/tests/fixtures/fake_mcp_server.py. Importable from integration tests via a pytest fixture that yields a Popen.

References