diff --git a/docs/adr/0100-serve-side-mcp-default-wiring-identity-admission.md b/docs/adr/0100-serve-side-mcp-default-wiring-identity-admission.md new file mode 100644 index 0000000000..25145e99ff --- /dev/null +++ b/docs/adr/0100-serve-side-mcp-default-wiring-identity-admission.md @@ -0,0 +1,122 @@ +# ADR-0100: Default wiring for the serve-side MCP server — identity admission is the gate, not the dev/prod default + +**Status**: Proposed (2026-07-18) +**Deciders**: ObjectStack Protocol Architects +**Builds on**: [ADR-0096](./0096-execution-surface-identity-admission.md) (execution-surface identity admission — the D4 conformance matrix + meta-test this ADR registers MCP with), [ADR-0090](./0090-permission-model-v2-concept-convergence.md) (D10 agent ceiling∩user intersection — the agent posture MCP runs OAuth callers under), [ADR-0097](./0097-declarative-connector-instances.md) / framework#2977 / framework#3056 (the CONSUME side — ObjectStack calling external MCP servers; this ADR is its serve-side counterpart), [ADR-0024](./0024-mcp-connectors.md) §4 (trust model) +**Composes with**: [ADR-0099](./0099-posture-adjudicated-tiering-and-external-rung.md) — 0096/0099 split admission (may this call reach the engine, as whom) from tiering (which rows each layer grants). This ADR is a pure *admission* application: it registers one more execution surface (MCP tool-execution) with the 0096 matrix. +**Tracking**: framework#3167 (this decision issue); framework#2698 (the default-on MCP HTTP surface + OAuth admission this ADR ratifies); ADR-0096 Evidence E1 (the stdio `bridgeResources` context-less read this ADR classifies) + +--- + +## TL;DR + +The premise of #3167 — "the `MCPServerPlugin` is wired nowhere by default" — is **stale against current `main`**. As of the #2698-era work, the serve-side MCP server is already a **default-on core capability**: `isMcpServerEnabled()` defaults ON (`packages/types/src/env.ts`), the CLI pushes the `mcp` capability into `os serve` / `os dev` unless opted out (`packages/cli/src/commands/serve.ts`), and the dispatcher route gate consults the same single decision point. So "an endpoint that answers but a capability never registered" no longer describes `main` — the plugin **is** registered by the same env decision that serves the route. + +What is genuinely missing is exactly what #3167 names as decision #1 and calls the gate: **the MCP tool-execution surface is not registered in the ADR-0096 D4 authz conformance matrix.** MCP appears in that matrix only incidentally (the readonly-strip row names the MCP create ingress); there is **no row that pins _whose_ `ExecutionContext` runs a `tools/call`, and no source probe that fails CI if that answer regresses.** ADR-0096's own Evidence table asserts "REST/MCP object CRUD — caller's `ExecutionContext`, threaded — by design," but that is prose, not a CHECKED artifact. + +This ADR therefore: + +1. **Pins the identity answer (D1)** and lands it as ADR-0096 D4 conformance rows + source probes (this PR), so the answer is a red-CI-on-regression property, not a comment. +2. **Reframes the dev/prod question (D2)**: the safety asymmetry #3167 worries about is delivered by the **fail-closed identity gate + the off-switch**, not by a dev-vs-prod default flip. The single `isMcpServerEnabled()` decision point stays; `OS_MCP_SERVER_ENABLED=false` is the deliberate prod-off lever (and gates both plugin registration and the route). +3. **Keeps the default surface HTTP-only (D3)**: stdio auto-start stays explicit opt-in — `os dev` owns the process, and the stdio resource path still carries the ADR-0096 E1 context-less read (now classified, with a tripwire, so it cannot silently ride onto a networked transport). +4. **Bounds default exposure (D4)** to what already ships: a fixed generic tool set over `apiEnabled` objects (`sys_*` fail-closed), business actions only when the author sets `ai.exposed`, OAuth scopes narrowing tool families. Metadata-declared per-surface exposure is the future story. + +Nothing in #3167 "proceeds before" the identity gate — and the gate is the only part that was actually missing, so it is the only new runtime-facing artifact this ADR lands. + +--- + +## Context — the verified current state (against `main`) + +### The wiring is already default-on (the #3167 premise is stale) + +| Concern | #3167's stated state | Verified on `main` | +|:---|:---|:---| +| HTTP surface `/api/v1/mcp` | default-ON (correct) | default-ON — `handleMcp` in `runtime/http-dispatcher.ts`, gated by `isMcpEnabled()` → `isMcpServerEnabled()` | +| `MCPServerPlugin` registration | "wired nowhere by default" | **default-ON** — `cli/src/commands/serve.ts`: `if (isMcpServerEnabled() && !requires.includes('mcp')) requires.push('mcp')`, resolved via `CAPABILITY_PROVIDERS.mcp` → `kernel.use(new MCPServerPlugin())`. `os dev` inherits it (it spawns `os serve --dev`). | +| off-switch | `OS_MCP_SERVER_ENABLED=false` disables the HTTP surface | disables **both** the route gate **and** the capability push (same helper), so the plugin is not registered either | +| stdio transport | optional via `autoStart`/`=true` | unchanged — `MCPServerPlugin.start()` connects stdio only under `autoStart` or `OS_MCP_SERVER_ENABLED==='true'` (strict); the dispatcher never networks stdio | + +The single decision point is `isMcpServerEnabled()` (`packages/types/src/env.ts`): unset ⇒ ON; `false`/`0`/`off`/`no` ⇒ OFF; `true` ⇒ ON **and** stdio auto-starts. Every consumer (route gate, CLI capability push, REST `/discovery` advertisement, auth OAuth/DCR follow-default, the Connect-an-Agent UI) reads that one helper. + +### Whose `ExecutionContext` runs a `tools/call` today (the answer #3167 D1 asks for) + +On the **HTTP surface** (the only networked one), the answer is already correct and fail-closed — it is just unregistered: + +- **Fail-closed admission.** `handleMcp` (`runtime/http-dispatcher.ts`) rejects any request whose resolved context lacks a real principal — `if (!ec || (!ec.userId && !ec.isSystem)) → 401`. A **guest** context (which the resolver will happily produce) has no `userId`, so it never reaches a tool. There is **no dev-admin fallback and no fall-open** on this surface. +- **Principal provenance** (`runtime/src/security/resolve-execution-context.ts`, `acceptOAuthAccessToken` true only on `/mcp`): an `osk_` API key → its **owning user**; a better-auth session → the **logged-in human**; an OAuth 2.1 access token → an **agent principal on behalf of** the human `sub`, whose reach is the **scope-derived ceiling ∩ the user's own grants** (ADR-0090 D10 — confused-deputy prevention). A JWT-shaped bearer that fails to verify resolves hard-anonymous (no cookie fallback) → 401. +- **Tool execution runs AS that principal.** `buildMcpBridge` binds every object-CRUD / query / aggregate call to the request `ec` via `callData(..., ec)` — the exact RLS/FLS/permission path REST `/data` uses; an agent can never exceed the credential's authority. OAuth callers are further narrowed by a scope → tool-family gate (#2698: no MCP scope ⇒ 403 `insufficient_scope`). +- **Business actions** are gated at invoke time by `ai.exposed` (author opt-in, #2849) + the ADR-0066 D4 capability gate + record load under the caller's RLS; the handler body then runs **trusted** by design (per #2964's `buildActionEngineFacade`) — the same posture actions already carry everywhere. + +In dev, that principal is the seeded `admin@objectos.ai` platform admin (`plugin-auth` `maybeSeedDevAdmin`, hard-gated to `NODE_ENV==='development'`), authenticated by its session or an API key it mints — **not** an implicit dev bypass; `handleMcp`'s `userId || isSystem` gate holds even under single-tenant `requireAuth:false`. + +### The one real fall-open — stdio resources (ADR-0096 E1) + +The **persistent** `MCPServerRuntime` (the object that carries `bridgeResources`/`bridgeTools`/`bridgePrompts`) is only reachable over **stdio**. Its `record_by_id` resource template calls `dataEngine.findOne(objectName, { where: { id } })` with **no `ExecutionContext`** (`mcp/src/mcp-server-runtime.ts`) → the plugin-security empty-principal skip = full-authority cross-tenant read. This is ADR-0096's E1 evidence. It is **not** reachable on the default HTTP surface (which builds a fresh per-request `McpServer` with only the principal-bound object/action tools), and stdio is opt-in — but it is a genuine context-less read that must not silently ride onto any future networked transport. + +--- + +## Decisions + +### D1 — Identity admission: caller-principal posture, registered as a CHECKED artifact (the gate) + +**The answer.** MCP tool execution runs under the **caller's principal** (ADR-0073 posture: `user`), resolved from the request credential, fail-closed to 401 when absent. Object CRUD is RLS/FLS/permission-bounded as that principal; OAuth callers run as `agent` on-behalf-of the human at the ADR-0090 D10 ceiling∩user intersection; actions are `ai.exposed` + capability gated with trusted bodies. + +**The gate.** That answer is registered in the ADR-0096 D4 authz conformance matrix (`packages/qa/dogfood/test/authz-conformance.matrix.ts`) as an **enforced** row, with source **probes** (`authz-conformance.test.ts`) that pin (a) the fail-closed identity gate and (b) the `buildMcpBridge` context threading. Dropping either makes a `covers` key STALE → red CI. This is what turns "REST/MCP CRUD is caller-scoped by design" from ADR-0096 prose into a checked property — and it is the precondition #3167 says everything else waits on. **This PR lands it.** + +**The stdio fall-open (E1)** is registered honestly in the same matrix as an **experimental** row (posture `system`, trusted-local, opt-in) with a probe on the context-less `dataEngine.findOne(` so it is classified rather than silent. It is **not** patched with an ad-hoc `{ isSystem: true }` literal — ADR-0096 D2 retires those, and the D2 `systemContext(reason)` constructor does not exist yet. The **admission requirement** before stdio is ever networked or default-wired: thread the caller/session `ExecutionContext` (or an explicit D2 grant) into the resource reads. Until then the tripwire keeps it from leaking onto a transport unreviewed. + +### D2 — The dev/prod boundary: identity + off-switch, not a default flip + +#3167 proposed "dev default-on, prod explicit." Given the verified state, that split is **superseded**: MCP already ships default-on in both, and the safety asymmetry #3167 rightly cares about is delivered by **D1's fail-closed identity gate** (a principal-less caller gets nothing) plus a clean **off-switch** — `OS_MCP_SERVER_ENABLED=false` is the *deliberate* prod-off choice, and it gates plugin registration and the route together. + +**Recommended (this ADR):** keep the single `isMcpServerEnabled()` decision point; wiring follows that env answer uniformly in dev and prod. The "deliberate choice in prod" is expressed by setting the off-switch, not by a code-level dev/prod fork — one decision point, no drift between the route gate and the plugin loader. + +**Amendment option (for ratification):** if the maintainers want prod to be opt-*in* rather than opt-*out*, make `isMcpServerEnabled()`'s **default** `NODE_ENV`-aware (ON in dev, OFF otherwise) — the single helper, so every consumer stays consistent. This is a one-line default change behind the same seam; it is a behavior change for existing default-on deployments and should be a conscious ratification, which is why it is called out separately rather than assumed. + +Either way, **D1 is unchanged and is the actual gate** — the dev/prod default is a policy dial on an already-fail-closed surface, not the thing that makes it safe. + +### D3 — Transport & mount: HTTP-only default; stdio stays explicit + +The default surface is the **per-request HTTP** transport. Stdio auto-start remains explicit opt-in (`autoStart` / `OS_MCP_SERVER_ENABLED=true`): `os dev` owns the process, spawning a second stdio MCP server under it is redundant, and the stdio path still carries the E1 context-less resource read (D1). **No stdio in the dev default.** + +Dev-boot affordance (low-risk DX, recommended follow-up, not required by the gate): print the resolved MCP URL + a connect hint on `os dev` boot — the `connect-ui` Setup page and the public `GET /api/v1/mcp/skill` download already exist; surfacing the URL at boot completes the "point your coding agent at the app you're building" loop the dispatcher's skill/connect pieces were built for. + +### D4 — Default exposure scope: what already ships, bounded + +The default tool surface is a **fixed generic set** — `list_objects`, `describe_object`, `query_records`, `aggregate_records`, `get_record`, `create_record`, `update_record`, `delete_record`, plus `list_actions` / `run_action` — **not** one CRUD tool per object (which would explode the list and the misuse surface). Exposure is bounded by, in layers: `sys_*` objects fail-closed (unless `allowSystemObjects`); the `apiEnabled` / `apiMethods` object gate enforced at execution via `callData` (the same gate REST honors); RLS/FLS by principal; business actions only when the author sets `ai.exposed`; OAuth scopes narrowing tool families at registration. + +Future story (ADR-0097 metadata-first, out of scope here): a **metadata-declared per-object / per-action MCP exposure config**, so "what is exposed to AI" is itself an authorable, reviewable artifact rather than derived only from `apiEnabled` + `ai.exposed`. + +--- + +## Optional extension (after the above, separately gated) + +With a stable in-repo serve-side endpoint, the showcase's declarative `provider: 'mcp'` connector gains a second demo target: **the platform connecting to itself**. As #3167 notes, this was rejected for boot-ordering (automation `start()` runs before HTTP listens); the #3049 degrade+retry heals the self-connection seconds after boot, but the dogfood gate must not become timing-sensitive. Treat as a separate, carefully-gated follow-up — **not** part of the identity-admission gate. + +--- + +## Consequences + +**Positive** +- The "whose identity runs a `tools/call`" question becomes a red-CI-on-regression property (D1), joining GraphQL and realtime in the ADR-0096 D4 matrix — the next refactor that drops the MCP fail-closed gate or the bridge threading gets a failing build with a checklist, not an adversarial-review finding later. +- The stale #3167 premise is corrected in the record: MCP is default-on and fail-closed today; the only missing piece (the matrix registration) is now landed. +- The E1 stdio fall-open stops being silent — it is classified, tripwired, and carries its admission requirement, so it cannot leak onto a networked transport unreviewed. + +**Negative / costs** +- Two more conformance rows + three probes to maintain; a formatting refactor near the pinned sites can trip the ratchet (intended — that is a re-review prompt, not a false positive). +- D2 leaves prod default-on (behind the off-switch). Deployments that want MCP off in prod must set `OS_MCP_SERVER_ENABLED=false`; the amendment option exists if the maintainers prefer opt-in. + +**Explicitly unchanged** +- The HTTP identity path, the OAuth admission (#2698), the `ai.exposed` + capability action gate, and the trusted-body posture — all already landed; this ADR registers them, it does not alter them. +- ADR-0096 D2/D3/D5 (the `systemContext` constructor, the identity-required signatures, strict mode) — untouched; the stdio row's hardening is deferred onto them. + +--- + +## References +- framework#3167 — this decision issue +- framework#2698 — the default-on MCP HTTP surface + OAuth 2.1 admission (RFC 9728) this ADR ratifies +- ADR-0096 — execution-surface identity admission (the D4 matrix + meta-test); Evidence E1 (`mcp/src/mcp-server-runtime.ts` context-less resource read) +- ADR-0090 D10 — agent ceiling∩user intersection (the OAuth caller posture) +- ADR-0097 / framework#2977 / framework#3056 — the consume-side declarative MCP connector (closed); this ADR is the serve-side counterpart +- `packages/mcp` (`MCPServerPlugin`, `MCPServerRuntime`, `connect-ui`, `skill`), `packages/runtime/src/http-dispatcher.ts` (`handleMcp`, `buildMcpBridge`), `packages/cli/src/commands/serve.ts` (the capability push), `packages/types/src/env.ts` (`isMcpServerEnabled`) +- `packages/qa/dogfood/test/authz-conformance.matrix.ts` + `authz-conformance.test.ts` — the D4 matrix + ratchet this ADR registers MCP with diff --git a/packages/qa/dogfood/package.json b/packages/qa/dogfood/package.json index 3a565cdc84..bc9ccdd6a8 100644 --- a/packages/qa/dogfood/package.json +++ b/packages/qa/dogfood/package.json @@ -14,6 +14,7 @@ "@objectstack/connector-rest": "workspace:*", "@objectstack/example-crm": "workspace:*", "@objectstack/example-showcase": "workspace:*", + "@objectstack/mcp": "workspace:*", "@objectstack/objectql": "workspace:*", "@objectstack/plugin-audit": "workspace:*", "@objectstack/plugin-auth": "workspace:*", diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index 31ba832c9a..8a74318618 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -89,6 +89,21 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ { id: 'realtime-delivery-authz', summary: 'realtime delivery fan-out has NO per-recipient authorization — trusted server-internal subscribers only (#2992 surface 2)', state: 'experimental', covers: ['realtime:in-memory-realtime-adapter.ts:publish(trusted-fan-out)'], note: 'Surface posture: system (trusted-implicit), pre-wiring — no end-user transport exists (handleUpgrade unimplemented, no REST subscribe route, client RealtimeAPI is a placeholder); the only subscribers are server-internal plugins (webhook auto-enqueuer, knowledge sync). Structural defect: Subscription carries no principal, matchesSubscription filters only by object+eventTypes (RealtimeSubscriptionOptions.filter is declared but never read), and the engine publishes the FULL after-row — so any future external subscriber would receive record bodies cross-tenant that its own find would hide. ADMISSION REQUIREMENT before any WebSocket/SSE/subscribe transport ships: per-recipient RLS/FLS/tenant re-check on delivery (subscription carries the subscriber ExecutionContext) OR id-only payload + client re-fetch. The transport tripwire probes in authz-conformance.test.ts turn a wired transport into an UNCLASSIFIED surface → red CI until this row is upgraded with the enforcement site.' }, + // ── #3167 / ADR-0096 D4 / ADR-0100 — the serve-side MCP tool-execution + // surface (@objectstack/mcp). MCP is default-ON (isMcpServerEnabled() default + // true; serve.ts capability push) and agent/external-reachable, so its + // identity posture is registered here as a CHECKED artifact, not incidental + // prose in ADR-0096's Evidence table. The probes in authz-conformance.test.ts + // pin the fail-closed 401 gate AND the buildMcpBridge context threading: drop + // either and the covers key goes STALE → red CI. + { id: 'mcp-tool-exec-identity', summary: 'MCP tool execution runs under the caller principal, fail-closed on the default-on /api/v1/mcp surface (#3167 D1, ADR-0096 D4, ADR-0100)', state: 'enforced', + enforcement: 'runtime/http-dispatcher.ts handleMcp — 401 fail-closed unless the request resolves a real principal (ec.userId || ec.isSystem; a guest context has no userId → 401, no dev-admin fallback, no fall-open), then the OAuth scope→tool-family gate (#2698: no MCP scope → 403 insufficient_scope). buildMcpBridge binds every object-CRUD/query/aggregate + action call to the caller ExecutionContext via callData(..., ec) — the same RLS/FLS/permission path REST /data uses; principal resolved by runtime/security/resolve-execution-context.ts (osk_ API-key owner / better-auth session human / OAuth agent-on-behalf-of-human = ADR-0090 D10 ceiling∩user). Business actions additionally gated at invoke time by ai.exposed (#2849) + the ADR-0066 D4 capability gate; bodies run trusted by design (#2964).', + covers: ['mcp:http-dispatcher.ts:handleMcp(identity-fail-closed)', 'mcp:http-dispatcher.ts:buildMcpBridge(context-threaded)'], + proof: 'showcase-mcp-identity.dogfood.test.ts', + note: 'Surface posture: user (caller identity), ENFORCED and live — this is the default-on serve-side surface (unlike the latent graphql-identity-thread / realtime-delivery-authz D4 rows). Answers the ADR-0096 Evidence row "REST/MCP object CRUD — caller ExecutionContext, threaded" as a checked property. Default-on + fail-closed posture proven on a real showcase boot in the proof (anonymous POST /mcp → 401 not 404/501, an authenticated member clears the gate, public GET /mcp/skill → 200). Also unit-proven in runtime/http-dispatcher.mcp.test.ts (anonymous 401) + http-dispatcher.mcp-oauth.test.ts (scope gate); the principal-bound identity binding + capability + ai.exposed gates are exercised end-to-end against the real buildMcpBridge over JSON-RPC in examples/app-todo/test/mcp-actions.e2e.ts; OAuth tool-family narrowing in packages/mcp mcp-http-tools.scopes.test.ts. Removing the 401 gate or the bridge threading makes a covers key STALE → red CI.' }, + { id: 'mcp-stdio-resource-read', summary: 'stdio MCP resource reads (record_by_id / bridgeResources) run context-less — trusted local process only, NOT on the default HTTP surface (#3167, ADR-0096 E1, ADR-0100 D1/D3)', state: 'experimental', + covers: ['mcp:mcp-server-runtime.ts:record_by_id(stdio-context-less-read)'], + note: 'Surface posture: system (trusted-implicit), opt-in. The persistent MCPServerRuntime.bridgeResources record_by_id template calls dataEngine.findOne with NO ExecutionContext (mcp/src/mcp-server-runtime.ts) → the plugin-security empty-principal skip = full-authority cross-tenant read (ADR-0096 Evidence E1). Reachable ONLY over the stdio transport, which the dispatcher NEVER exposes on the network and which auto-starts only under explicit opt-in (MCPServerPlugin autoStart / OS_MCP_SERVER_ENABLED=true). The default-on surface is HTTP-only and builds a fresh per-request McpServer with ONLY the principal-bound object/action tools (buildMcpBridge) — never these resources. NOT patched with an ad-hoc { isSystem: true } literal (ADR-0096 D2 retires those; the systemContext(reason) constructor does not exist yet). ADMISSION REQUIREMENT before stdio is ever networked or default-wired: thread the caller/session ExecutionContext (or an explicit ADR-0096 D2 grant) into the resource reads. The probe on dataEngine.findOne( keeps this classified so the fall-open cannot silently ride onto a networked transport; ADR-0100 D3 keeps stdio explicit-opt-in for exactly this reason.' }, { id: 'default-profile', summary: 'app-declared default profile (isDefault)', state: 'enforced', enforcement: 'plugin-security/security-plugin.ts fallback resolution', proof: 'showcase-default-profile.dogfood.test.ts' }, { id: 'readonly-static-write', summary: 'static `readonly: true` stripped from non-system UPDATE (#2948 / #3003) AND INSERT (#3043) payloads — neither a direct PATCH nor a direct POST can forge approval/status/amount columns the UI never renders', state: 'enforced', diff --git a/packages/qa/dogfood/test/authz-conformance.test.ts b/packages/qa/dogfood/test/authz-conformance.test.ts index a90a27ee3f..d2533960c2 100644 --- a/packages/qa/dogfood/test/authz-conformance.test.ts +++ b/packages/qa/dogfood/test/authz-conformance.test.ts @@ -78,6 +78,35 @@ const PROBES: ReadonlyArray<{ file: string; re: RegExp; key: (m: RegExpExecArray key: () => 'realtime:in-memory-realtime-adapter.ts:publish(trusted-fan-out)', }, + // ── #3167 / ADR-0096 D4 / ADR-0100 — the serve-side MCP tool-execution + // surface identity pins (`mcp-tool-exec-identity` row). MCP is default-ON and + // agent/external-reachable, so BOTH halves of its admission are pinned: + // (1) the fail-closed 401 gate in handleMcp — a principal-less caller gets + // nothing; drop it and the key goes STALE → red CI. + { + file: 'packages/runtime/src/http-dispatcher.ts', + re: /!ec\.userId\s*&&\s*!ec\.isSystem/g, + key: () => 'mcp:http-dispatcher.ts:handleMcp(identity-fail-closed)', + }, + // (2) the principal-bound bridge — every MCP tool runs AS the caller's + // ExecutionContext through buildMcpBridge (callData(..., ec)). If a + // refactor drops the bridge the key vanishes → STALE → red CI. + { + file: 'packages/runtime/src/http-dispatcher.ts', + re: /private\s+buildMcpBridge\s*\(/g, + key: () => 'mcp:http-dispatcher.ts:buildMcpBridge(context-threaded)', + }, + // stdio resource read (`mcp-stdio-resource-read` row): the persistent + // bridgeResources record_by_id template reads via dataEngine.findOne with NO + // ExecutionContext (ADR-0096 E1) — a context-less full-authority read that is + // safe ONLY because stdio is opt-in and never networked. Pinning it keeps the + // fall-open classified so it cannot silently ride onto a networked transport. + { + file: 'packages/mcp/src/mcp-server-runtime.ts', + re: /dataEngine\.findOne\s*\(/g, + key: () => 'mcp:mcp-server-runtime.ts:record_by_id(stdio-context-less-read)', + }, + // ── #2992 transport TRIPWIRES — deliberately covered by NO row ────────── // Delivery today is a pure fan-out with no per-recipient authorization // (subscriptions carry no principal, payload is the full record), which is @@ -138,6 +167,9 @@ const HIGH_RISK = [ // is false compliance (declared ≠ enforced) and approval/status columns are // one direct PATCH away from self-approval. 'readonly-static-write', + // #3167 — the MCP tool-execution surface is agent/external-reachable and + // default-on, so it must carry an end-to-end boundary proof (ADR-0096 D4). + 'mcp-tool-exec-identity', ]; describe('ADR-0056 D10 — authorization conformance matrix', () => { diff --git a/packages/qa/dogfood/test/showcase-mcp-identity.dogfood.test.ts b/packages/qa/dogfood/test/showcase-mcp-identity.dogfood.test.ts new file mode 100644 index 0000000000..94fef18f75 --- /dev/null +++ b/packages/qa/dogfood/test/showcase-mcp-identity.dogfood.test.ts @@ -0,0 +1,82 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #3167 / ADR-0096 D4 / ADR-0100 — the serve-side MCP tool-execution surface +// (@objectstack/mcp) is DEFAULT-ON and agent/external-reachable, so its identity +// posture is a checked property (the `mcp-tool-exec-identity` authz-conformance +// row), not incidental prose. The matrix row + source probes pin the code; this +// proof pins the runtime BEHAVIOUR on a real showcase boot: the /api/v1/mcp +// surface is served by default, yet tool execution is FAIL-CLOSED on identity — +// a principal-less caller gets 401, never a fall-open. The MCPServerPlugin is +// registered via `extraPlugins` because the verify harness boots the plugin set +// directly (it does not run the CLI capability push at serve.ts that wires MCP +// in `os dev`/`serve`); registering it here reproduces the default-on surface. +// +// The authenticated principal-bound execution path (tools run AS the caller's +// ExecutionContext through buildMcpBridge, RLS/FLS-bounded, with the ai.exposed +// + ADR-0066 D4 capability action gates) is exercised end-to-end against the +// real bridge in examples/app-todo/test/mcp-actions.e2e.ts; here we assert the +// complementary property a real HTTP boot uniquely shows — the fail-closed gate +// and the default-on service resolution. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { MCPServerPlugin } from '@objectstack/mcp'; + +const MCP = '/mcp'; +const TOOLS_LIST = { jsonrpc: '2.0', id: 1, method: 'tools/list' } as const; +const MCP_HEADERS = { + 'content-type': 'application/json', + // The Streamable-HTTP transport advertises both JSON and SSE responses. + accept: 'application/json, text/event-stream', +} as const; + +describe('showcase: serve-side MCP tool execution is default-on and fail-closed on identity (#3167 / ADR-0100)', () => { + let stack: VerifyStack; + let memberToken: string; + + beforeAll(async () => { + // Platform default (deny anonymous) + the default-on MCP surface registered + // exactly as `os dev`/`serve` would via the isMcpServerEnabled() capability push. + stack = await bootStack(showcaseStack, { extraPlugins: [new MCPServerPlugin()] }); + await stack.signIn(); // seed the dev admin (first user) + memberToken = await stack.signUp('mcp-identity-member@verify.test'); + }, 60_000); + + afterAll(async () => { + await stack?.stop(); + }); + + // ── The gate: no principal → no tool execution (fail-closed, not fall-open) ── + it('anonymous POST /mcp (tools/list) is denied 401 — no dev-admin fallback, no fall-open', async () => { + const r = await stack.api(MCP, { + method: 'POST', + headers: MCP_HEADERS, + body: JSON.stringify(TOOLS_LIST), + }); + // 401 (not 404/501): the surface IS served and the service IS registered — + // the denial is the identity gate, exactly what mcp-tool-exec-identity pins. + expect(r.status, 'a principal-less MCP tool call must be 401').toBe(401); + }); + + it('an authenticated member clears the MCP identity gate (not 401)', async () => { + // Past the gate the transport may 200/400/406 depending on the exact MCP + // handshake — the point is it is NOT the anonymous 401. Mirrors the #2567 + // GraphQL "authenticated clears the gate" assertion. + const r = await stack.api(MCP, { + method: 'POST', + headers: { ...MCP_HEADERS, Authorization: `Bearer ${memberToken}` }, + body: JSON.stringify(TOOLS_LIST), + }); + expect(r.status, 'an authenticated MCP caller must clear the 401 identity gate').not.toBe(401); + }); + + // ── Default-on: the public SKILL.md download proves the surface is advertised + // even though tool execution is gated (isMcpServerEnabled() default true). ── + it('GET /mcp/skill is served publicly (surface is default-on)', async () => { + const r = await stack.api(`${MCP}/skill`, { method: 'GET' }); + expect(r.status, 'the public MCP SKILL.md must be served (default-on surface)').toBe(200); + const body = await r.text(); + expect(body.length, 'SKILL.md must have content').toBeGreaterThan(0); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 467c4940d2..47ef805eb4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1584,6 +1584,9 @@ importers: '@objectstack/example-showcase': specifier: workspace:* version: link:../../../examples/app-showcase + '@objectstack/mcp': + specifier: workspace:* + version: link:../../mcp '@objectstack/objectql': specifier: workspace:* version: link:../../objectql