Repository navigation
design phase 8 c2
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 E2Escripts/test-hnx-mcp-serve.mjs5/5 (config fetch → stdio dial → namespaced serving → call round-trip with the resolved secret), smoke[8 C2]block — 192/192 overall.
C2 moves MCP serving to the client and deletes the proxy/direct split:
-
Model:
McpServer.modeis replaced bydialSite: 'auto' | 'client' | 'server';Credentialgainsdistributable(personal ⇒ always true; global ⇒ admin opt-in, default false). stdio can never be server-dialed (auto ⇒ client; explicit'server'+ stdio →409 STDIO_REQUIRES_CLIENT). -
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). -
packages/mcp-runtime(new): transport-facing pool extracted fromMcpRegistry— dial upstreams (sse / streamable-http / stdio, the new one), aggregate namespaced tools, route calls. TakesUpstreamDefinition[]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. -
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-ctlscope — 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 aplatformblock iff any entry is server-dialed. Secret-leak assertions: non-distributable plaintext never appears. -
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). -
hnx mcp serve --profile <id> [--server <url>] [--token <pat>]: the stdio shim. Per agent-tool process; fetches config, dials client-side upstreams viamcp-runtime, dials the outlet if needed, serves one namespaced tool set over stdio (SDKMcpServer+StdioServerTransport; logs to stderr only). Secrets exist in process memory only. -
Install adapters emit one stdio shim entry per profile (absolute
hnxpath baked viaprocess.argv[1];--serverURL included). Install-time${cred:NAME}resolution and the "sensitive/0700" handling disappear. Hermesmcp_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 followresearch-phase-3-plugin-targets.mdwhere the format is documented; anything unverified is explicitly deferred (see Out of scope). -
Emitter
emitMode: 'client' | 'server'(envEMITTER_MODE, default flips toclientwith 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);serverkeeps today's output as the no-hnxfallback. -
Web: MCP form —
moderadio →dialSiteselect with derivation hint; credential form —distributableswitch (global rows, admin). The 2.4 status page keeps working (it now reflects the server-dialed set); per-machine MCP status lands with C3.
-
mcp_servers: adddial_site TEXT NOT NULL DEFAULT 'auto', foldmode: direct → 'client',proxy → 'auto', then drop themodecolumn. -
credentials: adddistributable INTEGER NOT NULL DEFAULT 0(existing global creds stay non-distributable — the locked default).
| 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.
-
shared —
dial-site.ts(collectPlaceholderNames(transport),resolveDialSite) + schema flips (dialSite,distributable) +requiresDirectdeletion. Unit tests incl. the matrix above. -
core —
McpServer.dialSite(dropmode),Credential.distributable. -
server storage — migration
0007+ both drivers (mode→dial_site, distributable columns). -
mcp-runtime — new package;
UpstreamPool(connect/disconnect/status/ listTools/callTool/listServerTools + stdio transport support + reconcilesync(definitions)); server registry refactored to delegate (public API unchanged forproxy.ts+ routes), pooling filter = resolves-to-server. -
server routes —
modules/mcp-servers.ts: dialSite field replaces mode,STDIO_REQUIRES_DIRECT→STDIO_REQUIRES_CLIENT, matrix enforcement,not_proxyRegistryError →not_server_dialed;modules/credentials.ts: distributable handling;modules/client-config.ts(new, custom auth). -
sdk — field flips +
getClientMcpConfig(profileId). -
cli —
src/mcp/serve.tsshim (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. -
emitter —
emitMode(client default) + archive snapshot tests updated. -
web — form changes (
dialSite,distributable). -
verify — update every mode-sensitive test (
emitter.test.ts,scripts/test-hermes-adapter.mjs,test-hnx-uninstall.mjs, smoke); NEWscripts/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).
- 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.
- 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).
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.
Three follow-ups found while the user E2E'd the outlet through the shim
(branch fix/7-outlet-followups, merged (#7)):
-
The outlet dropped tool input schemas.
buildSessionregistered tools through the MCP SDK'sregisterToolzod-config path; that path re-encodes schemas and silently yields empty ones for passthrough JSON Schema. CC readproperties: {}, called the tool with NO arguments, and the upstream rejected it ("CC says no context7").buildSessionis now a low-levelServerwith explicitListTools/CallToolhandlers that passregistry.listTools()'sinputSchemaverbatim. Rule of thumb: the outlet is a PASSTHROUGH — never re-encode an upstream tool schema. -
Connect settle race (
mcp-runtime).connectOneflippedconnectedBEFORErefreshToolsfetched 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 toconnected. -
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.cancelleft pending cards open, so a turn blocked on a permission could never be stopped from the UI.chat:permission.respond/chat:elicitation.respondnow push apermission_resolved/elicitation_resolvedring record after forwarding, andchat:turn.cancelsettles every pending card (answering the agentcancelled) before issuingsession/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.