Skip to content

design phase 8 c2

Ubuntu edited this page Sep 22, 2026 · 3 revisions

Design: Phase 8 C2 — client MCP serving (shims, dial-site routing, /mcp outlet narrowing)

Status: implemented (branch phase-8-c2). Parent design: design-phase-8-client.md § "MCP serving model (C2)". PRD: prd-phase-8-client.md (locked decisions #1/#2/#4). C1: design-phase-8-c1.md (shipped — machines, realtime v0). Verification: shared 33 (dial-site matrix), server 23 (client-config contract incl. secret-leak assertions + 409 matrix), shim E2E scripts/test-hnx-mcp-serve.mjs 5/5 (config fetch → stdio dial → namespaced serving → call round-trip with the resolved secret), smoke [8 C2] block — 192/192 overall.

Scope

C2 moves MCP serving to the client and deletes the proxy/direct split:

  1. Model: McpServer.mode is replaced by dialSite: 'auto' | 'client' | 'server'; Credential gains distributable (personal ⇒ always true; global ⇒ admin opt-in, default false). stdio can never be server-dialed (auto ⇒ client; explicit 'server' + stdio → 409 STDIO_REQUIRES_CLIENT).
  2. Dial-site derivation (pure, in shared): auto ⇒ client iff every ${cred:NAME} the transport references is distributable (or none), else server. Unit-tested; used by BOTH the server (pool filter + config API) and the shim (informational).
  3. packages/mcp-runtime (new): transport-facing pool extracted from McpRegistry — dial upstreams (sse / streamable-http / stdio, the new one), aggregate namespaced tools, route calls. Takes UpstreamDefinition[] with already-resolved transports; placeholder resolution stays with the callers. Server registry keeps config loading + resolution + 2.4 surface and delegates pool ops; the shim reuses the pool directly.
  4. GET /api/client/mcp-config?profile=<id>: the shim's config fetch. Custom auth (like the marketplace routes): accepts an api PAT/JWT or a machine PAT (machine-ctl scope — the one REST exception, bound to its machine's owner). Returns resolved transports for client-dialed servers ONLY; server-dialed servers are listed by name with no transport, plus a platform block iff any entry is server-dialed. Secret-leak assertions: non-distributable plaintext never appears.
  5. Outlet narrowing: the server registry pools exactly the resolves-to-server rows; /mcp (Streamable HTTP + SSE) keeps its contract unchanged. The shim dials the outlet as ONE passthrough upstream when the profile has server-dialed entries (outlet tools arrive already namespaced — the shim must NOT re-namespace them).
  6. hnx mcp serve --profile <id> [--server <url>] [--token <pat>]: the stdio shim. Per agent-tool process; fetches config, dials client-side upstreams via mcp-runtime, dials the outlet if needed, serves one namespaced tool set over stdio (SDK McpServer + StdioServerTransport; logs to stderr only). Secrets exist in process memory only.
  7. Install adapters emit one stdio shim entry per profile (absolute hnx path baked via process.argv[1]; --server URL included). Install-time ${cred:NAME} resolution and the "sensitive/0700" handling disappear. Hermes mcp_servers: merge simplified. Codex adapter ships here as the trivial case: ~/.codex/config.toml [mcp_servers.harness-nexus] command/args (Codex is stdio-only — the shim makes it first-class). Codex non-MCP artifacts follow research-phase-3-plugin-targets.md where the format is documented; anything unverified is explicitly deferred (see Out of scope).
  8. Emitter emitMode: 'client' | 'server' (env EMITTER_MODE, default flips to client with C2): client mode emits ONE stdio shim entry in .mcp.json (hnx mcp serve …, no PAT env var, no ${cred:NAME} warning — replaced by an "hnx enrolled" warning); server keeps today's output as the no-hnx fallback.
  9. Web: MCP form — mode radio → dialSite select with derivation hint; credential form — distributable switch (global rows, admin). The 2.4 status page keeps working (it now reflects the server-dialed set); per-machine MCP status lands with C3.

Migration 0007

  • mcp_servers: add dial_site TEXT NOT NULL DEFAULT 'auto', fold mode: direct → 'client', proxy → 'auto', then drop the mode column.
  • credentials: add distributable INTEGER NOT NULL DEFAULT 0 (existing global creds stay non-distributable — the locked default).

Dial-site × distributability matrix (normative)

Transport creds referenced auto client server
none client client server
all distributable client client server
any non-distributable server 409 at use* server

* an explicit client + non-distributable credential is a config contradiction: rejected at create/update with 409 CREDENTIAL_NOT_DISTRIBUTABLE (the server would have to ship a secret it must not ship). auto silently routes to server — that is the derived default doing its job.

Tasks (dependency order)

  1. shared — dial-site.ts (collectPlaceholderNames(transport), resolveDialSite) + schema flips (dialSite, distributable) + requiresDirect deletion. Unit tests incl. the matrix above.
  2. core — McpServer.dialSite (drop mode), Credential.distributable.
  3. server storage — migration 0007 + both drivers (mode→dial_site, distributable columns).
  4. mcp-runtime — new package; UpstreamPool (connect/disconnect/status/ listTools/callTool/listServerTools + stdio transport support + reconcile sync(definitions)); server registry refactored to delegate (public API unchanged for proxy.ts + routes), pooling filter = resolves-to-server.
  5. server routes — modules/mcp-servers.ts: dialSite field replaces mode, STDIO_REQUIRES_DIRECT → STDIO_REQUIRES_CLIENT, matrix enforcement, not_proxy RegistryError → not_server_dialed; modules/credentials.ts: distributable handling; modules/client-config.ts (new, custom auth).
  6. sdk — field flips + getClientMcpConfig(profileId).
  7. cli — src/mcp/serve.ts shim (registry of upstreams + outlet passthrough + stdio server); install resolver drops transport inlining; Hermes adapter simplification; Codex adapter (adapters/codex.ts + registry + TOML add-only merge per ECC pattern); uninstall paths updated.
  8. emitter — emitMode (client default) + archive snapshot tests updated.
  9. web — form changes (dialSite, distributable).
  10. verify — update every mode-sensitive test (emitter.test.ts, scripts/test-hermes-adapter.mjs, test-hnx-uninstall.mjs, smoke); NEW scripts/test-hnx-mcp-serve.mjs — full shim E2E: fixture stdio MCP upstream (SDK server script) + memory server with a personal-cred/dial-client entry + a server-dialed outlet entry → shim serves both, namespaced + passthrough, call round-trips; smoke [8 C2] block asserts the config-API contract (resolved transports only for client-dialed; non-distributable plaintext absent; machine PAT accepted).

Security notes

  • The config API is the ONLY REST surface a machine PAT can call, and it is profile-visibility-checked against the machine's owner. Non-distributable plaintext never enters any response — asserted by tests.
  • The shim holds resolved secrets in memory only; the sole at-rest client secret remains ~/.hnx/config.json (0600, from C1).
  • The outlet keeps its PAT gate and profile routing exactly as in 2.2.

Out of scope (explicit)

  • Per-machine MCP status reporting (needs the C3 inventory channel).
  • Codex skills/commands/agents artifact formats beyond what the research doc verifies — MCP merge is the C2 deliverable; the rest ships when verified.
  • Offline config cache, hybrid connection broker (parent design: deferred).
  • Config hot-reload inside a running shim (config applies next session).

Post-ship fix (#7, 2026-09-22): the outlet now accepts machine PATs

Found during #6's rig E2E: a profile whose MCP entry resolves SERVER-dialed (e.g. context7 — global row, header references a non-distributable ${cred:context7-key}) deployed fine to claude-code, CC reported the shim ✔ Connected, but the shim served 0 tools. Chain: the emitter's .mcp.json carries no user PAT by design → hnx mcp serve dials the /mcp?profile=… outlet with the machine's PAT → the root auth hook NULLS machine-ctl tokens (REST blast radius) → the outlet gate 401'd → the passthrough upstream failed, silently yielding an empty tool set.

Fix (mirrors the 3.5 emitter's token acceptance): the outlet gate (mcp/proxy.ts) resolves a Bearer machine-ctl PAT itself into the machine's OWNER, sets req.user, and the ordinary profile-visibility check applies — a machine token gains consumption of its owner's VISIBLE profiles' aggregated MCP, strictly weaker than the /ctl control it already grants. The REST API still rejects machine tokens everywhere else; marketplace-scope PATs remain outlet-refused (URL-capability tokens only).

Tests: server/test/outlet-auth.test.ts (machine PAT passes the gate; foreign personal profile → 403 PROFILE_ENTRY_NOT_ACCESSIBLE; anonymous 401; marketplace PAT 401; REST rejection unchanged) + a smoke [#7] pair. Rig-verified: full stdio handshake through the shim now lists context7__resolve-library-id / context7__query-docs.

Post-ship follow-ups (#7, 2026-09-22): tool schemas, settle race, permission cards

Three follow-ups found while the user E2E'd the outlet through the shim (branch fix/7-outlet-followups, merged (#7)):

  1. The outlet dropped tool input schemas. buildSession registered tools through the MCP SDK's registerTool zod-config path; that path re-encodes schemas and silently yields empty ones for passthrough JSON Schema. CC read properties: {}, called the tool with NO arguments, and the upstream rejected it ("CC says no context7"). buildSession is now a low-level Server with explicit ListTools/CallTool handlers that pass registry.listTools()'s inputSchema verbatim. Rule of thumb: the outlet is a PASSTHROUGH — never re-encode an upstream tool schema.
  2. Connect settle race (mcp-runtime). connectOne flipped connected BEFORE refreshTools fetched the tool list; a client that lists tools immediately (CC boots that way) could win the race and get 0 tools from a perfectly healthy upstream. Order is now: connect → refreshTools → only then flip to connected.
  3. Permission/elicitation cards and turn.cancel. Resolutions were not ring-recorded, so a session switch (resync rebuilds from the ring) resurrected an already-answered card; and chat:turn.cancel left pending cards open, so a turn blocked on a permission could never be stopped from the UI. chat:permission.respond / chat:elicitation.respond now push a permission_resolved / elicitation_resolved ring record after forwarding, and chat:turn.cancel settles every pending card (answering the agent cancelled) before issuing session/cancel.

Tests: cli/test/chat.test.ts (a responded permission rides the ring as permission_resolved; turn.cancel settles pending permission AND elicitation cards, late responds get unknown-permission). Rig-verified: outlet ListTools carries full context7__* schemas, a real call round-trips through the shim, and no-delay probes list both tools every time.

Clone this wiki locally