|
| 1 | +# ADR-0101: MCP stdio Principal Admission — env-supplied API-key identity, fail-closed, no system bypass |
| 2 | + |
| 3 | +**Status**: Proposed (2026-07-19) |
| 4 | +**Deciders**: ObjectStack Protocol Architects |
| 5 | +**Builds on**: [ADR-0096](./0096-execution-surface-identity-admission.md) (execution-surface identity admission — this ADR closes its last unadmitted transport), [ADR-0024](./0024-mcp-connectors.md) §4 (trust model), [ADR-0036](./0036-app-as-rest-api-and-mcp-server.md) (the app *as* an MCP server — the surface being admitted), [ADR-0099](./0099-posture-adjudicated-tiering-and-external-rung.md) (posture rides the `ExecutionContext` this ADR threads) |
| 6 | +**Composes with**: framework#3055 (consume-side declarative stdio **spawn** policy — the sibling trust decision: #3055 gates *who may start* a local MCP process from metadata; this ADR gates *as whom* our own stdio server reads data), [ADR-0097](./0097-declarative-connector-instances.md) |
| 7 | +**Tracking**: framework#3246 (v1 implementation) · framework#3167 (parent decision issue — deferred this) · PR #3217 / #3228 (PR-B, landed: switch split + HTTP e2e proof) |
| 8 | +**Consumers**: `@objectstack/mcp` (`MCPServerPlugin.start()`, `bridgeResources`), `@objectstack/core` (`resolveApiKeyPrincipal` / `resolveAuthzContext` — the shared verify chain), `@objectstack/qa` dogfood (`mcp-stdio-authority` matrix row) |
| 9 | + |
| 10 | +--- |
| 11 | + |
| 12 | +## TL;DR |
| 13 | + |
| 14 | +The long-lived MCP **stdio** transport bridges the RAW metadata service + data |
| 15 | +engine with **no per-request principal** — a stdio-attached client reads |
| 16 | +metadata and records with full, unscoped authority (no RLS / FLS / tenant). |
| 17 | +The `mcp-stdio-authority` conformance row records this as `experimental` with |
| 18 | +an explicit ADMISSION REQUIREMENT before stdio can ever be promoted or served |
| 19 | +beyond a single-operator local tool. |
| 20 | + |
| 21 | +Three decisions close it: |
| 22 | + |
| 23 | +1. **D1 — stdio's identity is an env-supplied API key.** `OS_MCP_STDIO_API_KEY=osk_...` |
| 24 | + is resolved through the **one shared verify chain** (`@objectstack/core` |
| 25 | + `resolveApiKeyPrincipal` → `resolveAuthzContext`, the same path the HTTP |
| 26 | + dispatcher and REST use), yielding the `ExecutionContext` every stdio data |
| 27 | + read runs under. Re-resolved **per call**, so revocation applies to a live |
| 28 | + session. |
| 29 | +2. **D2 — fail-closed.** stdio auto-start without a valid key **refuses to |
| 30 | + start** (loud configuration fault). The HTTP surface is unaffected. |
| 31 | +3. **D3 — there is NO `system` bypass mode.** Full authority is obtained the |
| 32 | + same way any authority is: provision an identity (platform admin, or better |
| 33 | + a dedicated service identity) and mint a key for it. A supplied credential — |
| 34 | + never a mode that skips identity. |
| 35 | + |
| 36 | +## Context |
| 37 | + |
| 38 | +Two transports, two postures — pinned by ADR-0096's matrix (rows added in |
| 39 | +PR #3202, #3167 PR-A): |
| 40 | + |
| 41 | +- **HTTP `/api/v1/mcp`** — `enforced`: `handleMcp` denies anonymous (401), |
| 42 | + OAuth scopes narrow tool families, `buildMcpBridge(context)` threads the |
| 43 | + caller `ExecutionContext` into every data op. End-to-end proven |
| 44 | + (`showcase-mcp-http-identity.dogfood.test.ts`, PR #3228). |
| 45 | +- **stdio** — `experimental`: `MCPServerPlugin.start()` bridges resources onto |
| 46 | + the long-lived server from the raw services. The `record_by_id` resource |
| 47 | + calls `dataEngine.findOne(...)` with **no context** — RLS/FLS/tenant never |
| 48 | + run. Opt-in only (`OS_MCP_STDIO_ENABLED` / `autoStart`, split from the HTTP |
| 49 | + switch in PR #3217 precisely because the shared switch silently attached |
| 50 | + this unscoped transport). |
| 51 | + |
| 52 | +Today's posture is *bounded*: stdio is opt-in, local, single-operator — whoever |
| 53 | +can attach stdio already owns the process and its dev database. This ADR is |
| 54 | +not an incident response; it is the hardening that must precede any promotion |
| 55 | +(default-on, multi-user, hosted) and the removal of the platform's last |
| 56 | +identity-less execution path. |
| 57 | + |
| 58 | +### Why the transport itself stays credential-less (and that is correct) |
| 59 | + |
| 60 | +The MCP specification splits the problem into two layers: |
| 61 | + |
| 62 | +- **Transport auth (client ↔ server)**: HTTP transports SHOULD implement the |
| 63 | + MCP Authorization spec; **stdio SHOULD NOT** — the server is a child process |
| 64 | + of the client, and transport trust is inherited from local process execution. |
| 65 | + We comply: no OAuth handshake is added to stdio. |
| 66 | +- **Backend identity (server ↔ data)**: the spec's guidance is that stdio |
| 67 | + servers take credentials **from the environment** and act with *that |
| 68 | + credential's* authority. This is exactly how the ecosystem's backend-shaped |
| 69 | + servers behave: a Postgres MCP server connects with the supplied role's |
| 70 | + grants (superuser only if you supplied superuser); the GitHub MCP server acts |
| 71 | + as the PAT's user with the PAT's scopes. Anthropic's own agent-identity model |
| 72 | + is the same shape — agents run under **admin-provisioned, scoped, revocable |
| 73 | + service identities**, never an identity-less full-authority mode. |
| 74 | + |
| 75 | +ObjectStack's stdio today is *weaker than all of these*: it demands no |
| 76 | +credential and grants full authority. The native ObjectStack analogue of |
| 77 | +`POSTGRES_PASSWORD` / `GITHUB_TOKEN` is an **`osk_` API key**. Use it. |
| 78 | + |
| 79 | +## Decisions |
| 80 | + |
| 81 | +### D1 — `OS_MCP_STDIO_API_KEY` is the stdio principal, resolved via the shared chain |
| 82 | + |
| 83 | +At `MCPServerPlugin.start()` (stdio auto-start path only): |
| 84 | + |
| 85 | +- Read `OS_MCP_STDIO_API_KEY`. Resolve it through |
| 86 | + `resolveApiKeyPrincipal(ql, { 'x-api-key': key })` → |
| 87 | + `resolveAuthzContext(...)` in `@objectstack/core` — the **same** verify + |
| 88 | + context chain the HTTP dispatcher and REST `/data` use (`@objectstack/mcp` |
| 89 | + already depends on `core`; no new auth surface is invented). |
| 90 | +- Build the `ExecutionContext` mirroring the runtime's |
| 91 | + `resolve-execution-context` mapping (userId, tenantId, permissions → |
| 92 | + posture per ADR-0099), and thread it into a **principal-bound resource |
| 93 | + bridge**: `record_by_id` and every other data-touching resource goes through |
| 94 | + the engine **with `{ context }`** — never the raw `dataEngine`. |
| 95 | +- **Per-call re-resolution**: each resource read re-verifies the key (indexed |
| 96 | + at-rest-hash lookup — cheap). A revoked or expired key takes effect on the |
| 97 | + next call of a *live* stdio session, matching HTTP semantics. No long-lived |
| 98 | + cached authority. |
| 99 | + |
| 100 | +### D2 — Fail-closed on a missing/invalid key |
| 101 | + |
| 102 | +`OS_MCP_STDIO_ENABLED=true` (or `autoStart`) **without** a resolvable key is a |
| 103 | +configuration fault: stdio does not start, the error says exactly how to mint |
| 104 | +a key (Setup → Connect an Agent, or `POST /api/v1/keys`). The HTTP surface — |
| 105 | +default-on, independently admitted — is untouched. Fall-open (start unscoped |
| 106 | +anyway) is exactly the ADR-0096 failure class this ADR exists to remove. |
| 107 | + |
| 108 | +### D3 — No `system` bypass (rejected alternative: `OS_MCP_STDIO_IDENTITY=system`) |
| 109 | + |
| 110 | +A configurable "run as system" mode was considered as a local-dev escape hatch |
| 111 | +and **rejected**. Against the native alternative — mint a key for a |
| 112 | +platform-admin or dedicated service identity — it is strictly worse: |
| 113 | + |
| 114 | +| | admin/service `osk_` key | `OS_MCP_STDIO_IDENTITY=system` | |
| 115 | +|---|---|---| |
| 116 | +| Audit | attributable to a real identity/key | synthetic `system`, attributable to no one | |
| 117 | +| Revocation | revoke the key, access ends | no credential to revoke; edit config + restart | |
| 118 | +| Rotation / expiry | supported (`sys_api_key`) | none | |
| 119 | +| Scope | that identity's grants, under posture rules (ADR-0099) | `isSystem` skips *everything*, incl. the tenant wall | |
| 120 | +| Nature | a supplied credential (industry shape) | an RLS bypass switch | |
| 121 | + |
| 122 | +The deeper reason: "run as superuser" in the Postgres analogy means *you |
| 123 | +supplied a superuser credential* — it does not mean *the server offers a mode |
| 124 | +that skips authentication and disables RLS*. No mainstream backend MCP server |
| 125 | +ships the latter. And `OS_MCP_SERVER_ENABLED=true` silently attaching an |
| 126 | +unscoped transport was the footgun PR #3217 just closed; a blessed |
| 127 | +`IDENTITY=system` env var recreates it with a nicer name. Naming a fall-open |
| 128 | +does not make it not a fall-open. |
| 129 | + |
| 130 | +Full authority remains available — by provisioning it: mint a key for a |
| 131 | +platform-admin identity, or (better, matching the agent-identity pattern) a |
| 132 | +dedicated service identity with exactly the grants the agent needs. |
| 133 | + |
| 134 | +## Consequences |
| 135 | + |
| 136 | +- **`mcp-stdio-authority` graduates** `experimental` → `enforced` when #3246 |
| 137 | + lands: the surface admits a declared principal, fail-closed. The |
| 138 | + `bridgeResources(metadataService, dataEngine)` probe key goes STALE by |
| 139 | + design — the re-classification this forces in CI *is* the change. |
| 140 | +- **Bootstrap friction, accepted**: a fresh database needs a minted key before |
| 141 | + stdio can start (log in once, mint). Same class as "create a role before |
| 142 | + connecting to Postgres". The common dev loop is unaffected — the HTTP |
| 143 | + surface (OAuth as the dev admin, principal-bound, default-on) already covers |
| 144 | + "point a coding agent at the running app", and `os dev` prints exactly that. |
| 145 | +- **v2 conveniences (tracked in #3246, not blocking)**: `os dev` may auto-mint |
| 146 | + and print a local, scoped, revocable dev key from the seeded dev-admin — a |
| 147 | + credential, not a bypass — and named service identities / restricted |
| 148 | + permission sets + rotation guidance address the static-token failure mode. |
| 149 | +- **One identity model across transports**: HTTP = per-request caller |
| 150 | + credential; stdio = per-process env credential; both resolve through the |
| 151 | + same chain to the same `ExecutionContext` shape. Every future transport |
| 152 | + inherits the rule: *no principal, no data*. |
0 commit comments