# 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=`**: 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 [--server ] [--token ]`**: 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.