design phase 8 c2 - sinrimin/harness-nexus GitHub Wiki

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.

⚠️ **GitHub.com Fallback** ⚠️