Skip to content

Commit bd7df45

Browse files
authored
docs(adr): ADR-0101 — MCP stdio principal admission (env API-key identity, fail-closed, no system bypass) (#3250)
Records the decision closing the platform's last identity-less execution surface (the long-lived MCP stdio transport): stdio's identity is an env-supplied API key resolved via the shared core verify chain, fail-closed on a missing key, and no `system` bypass mode. Extends ADR-0096. Implementation tracked in #3246 (where the ADR flips Proposed → Accepted alongside the code). Refs #3246, #3167, #3055, ADR-0096.
1 parent f9b118d commit bd7df45

1 file changed

Lines changed: 152 additions & 0 deletions

File tree

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
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

Comments
 (0)