fix(mcp): the stdio transport honours the ADR-0049 apiEnabled / apiMethods exposure declaration (#8083) - #8265
Conversation
…8083) Both MCP transports register the same tools from the same McpDataBridge, but the two hosts implement that bridge over different seams: HTTP runs every data verb through callData, which gates on the object's declared apiEnabled / apiMethods before dispatch, while the stdio bridge went straight to the engine and applied no gate. One declaration, two transports, two answers. This is a surface-area declaration leak, not an authorization bypass -- the gate is a surface-area control by api-exposure.ts's own ADR note, and every stdio call passed the engine's CRUD/FLS/RLS before this change and after it. What was leaking is the author's exposure declaration. The decision is not re-implemented here: it comes from the spec's single source of truth (resolveEffectiveApiMethods / isApiOperationAllowed), the same helpers checkApiExposure (runtime) and apiAccessDenialFromEnable (rest) delegate to, so each surface owns only its envelope. Refusals carry the same two machine codes REST answers with. Gated verbs are exactly the six buildMcpBridge routes through callData; list_objects / describe_object stay ungated because the HTTP bridge answers both off the metadata service, and gating them would be a fresh divergence pointing the other way. The isSystem bypass, the fail-open on unresolvable metadata and the flat-shape fallback are matched to the HTTP path deliberately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016pY4Xb2iDecfDtT3CWoiTW
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 11 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
…-stdio-exposure-gate # Conflicts: # packages/mcp/src/stdio-data-bridge.ts
Fixes #8083
What was wrong
Both MCP transports register the same tools from the same
McpDataBridge, but the twohosts implement that bridge over different seams. HTTP runs every data verb through
callData, which gates on the object's declaredapiEnabled/apiMethodsbeforedispatch. The stdio bridge went straight to the engine and applied no gate. One
declaration, two transports, two answers.
This is a surface-area declaration leak, not an authorization bypass — the framing the
card argues and triage adopted.
packages/runtime/src/api-exposure.tsdocuments the gateas a surface-area control rather than the authorization boundary, and every stdio call
passed the engine's CRUD / FLS / RLS middleware before this change and after it. What was
leaking is the author's exposure declaration.
What this does
Applies the gate on the stdio bridge (option C on the card). The decision is not
re-implemented: it comes from the spec's single source of truth
(
resolveEffectiveApiMethods/isApiOperationAllowed) — the same helpers the repo's twoexisting enforcement points already delegate to:
checkApiExposure(runtime)apiAccessDenialFromEnable(rest)enforceApiExposure(this PR)Each surface owns only its own envelope, so the three-state whitelist, the
action-to-operation mapping and the derived verbs resolve identically on all three. No new
package dependency:
packages/mcpalready depends on@objectstack/spec, and this filealready imported
@objectstack/spec/data. Nothing inpackages/specchanged.Which verbs are gated is the parity claim, so it is data (
GATED_ACTIONS) rather thansix literals: exactly the six methods
buildMcpBridgeroutes throughcallData, withremovereaching it as thedeleteaction word.list_objects/describe_objectstayungated because the HTTP bridge answers both straight off the metadata service —
gating them here would be a fresh divergence pointing the other way.
Three behaviours are matched to the HTTP path deliberately: a system context bypasses,
unresolvable metadata fails open to the schema defaults, and the flat legacy definition
shape is still read when there is no nested
enableblock. Refusals carry the same twomachine codes REST answers with (
OBJECT_API_DISABLED404,OBJECT_API_METHOD_NOT_ALLOWED405, with the effective operation set attached).The gate runs before the existence probe in
update/remove: gating after it wouldanswer "that id names no row" for one id and succeed for another, an existence oracle on an
object the author declared unexposed.
Tests
packages/mcp/src/stdio-data-bridge.exposure.test.ts— 24 cases, in the shape of #8034'stransport parity: one bridge, one tool surface.The parity is pinned the way it is actually reviewable from inside
packages/mcp: theHTTP verdict function lives in
packages/runtime, which this package neither depends onnor may read (that is exactly the cross-package test input
check:cross-package-test-inputsexists to catch). So the declaration-to-verdict table mirrors the one
packages/runtime/src/api-exposure.test.tspins the HTTP side against, case for case. Thehalf that is not a hand-copied table is
GATED_ACTIONS, asserted structurally againstthe spec's own action map — a typo in an action word would otherwise fall through
DATA_ACTION_TO_API_OPERATION[action] ?? actionand silently stop gating that verb.Reverse-verified: with only the six gate call sites removed (exports kept), the file goes
14 failed / 10 passed — every refusal case red, the structural, fail-open, bypass and
allow-path cases still green, which is the direction predicted before running. Worth noting
that the existence-probe case failed with
code: undefined, because the ungated paththrows a bare
recordNotFound: a plain.toThrow()assertion would have stayed greenthere, which is why the refusals assert the
codeandstatusenvelope.Local:
pnpm --filter @objectstack/mcp test162 passed (14 files),typecheckclean.Gates run:
check:cross-package-test-inputs,check:nul-bytes,check:engine-double-contract,check:error-code-casing,check:query-options-erasure,check:type-check-coverage— all green.Scope
Options A and B on the card (unifying the two transports behind one transport-neutral data
seam) are out of scope by the triage ruling and are untouched. The rest of the divergence
table in the module docblock — the protocol layer's ingress
readonlystrip, existenceprobes, spec-shaped receipts,
expand/select— is left intact and still points at thatfollow-up work; only the ADR-0049 line was retired.
Not addressed here, filed separately: the ADR-0101 record resource
(
objectstack://objects/{objectName}/records/{recordId}) reads rows through its owngetRecordseam inplugin.tsand is still ungated. Out of scope: #7279 also touches thatarea and is deliberately not in flight, so this PR does not go near it.
Generated by Claude Code