Skip to content

fix(mcp): the stdio transport honours the ADR-0049 apiEnabled / apiMethods exposure declaration (#8083) - #8265

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-8083-mcp-stdio-exposure-gate
Aug 13, 2026
Merged

fix(mcp): the stdio transport honours the ADR-0049 apiEnabled / apiMethods exposure declaration (#8083)#8265
os-zhuang merged 2 commits into
mainfrom
claude/issue-8083-mcp-stdio-exposure-gate

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #8083

What was wrong

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. 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.ts documents the gate
as 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 two
existing enforcement points already delegate to:

surface wrapper delegates to
HTTP / MCP-over-HTTP checkApiExposure (runtime) the spec helpers
REST apiAccessDenialFromEnable (rest) the spec helpers
MCP-over-stdio enforceApiExposure (this PR) the spec helpers

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/mcp already depends on @objectstack/spec, and this file
already imported @objectstack/spec/data. Nothing in packages/spec changed.

Which verbs are gated is the parity claim, so it is data (GATED_ACTIONS) rather than
six literals: exactly the six methods buildMcpBridge routes through callData, with
remove reaching it as the delete action word. list_objects / describe_object stay
ungated 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 enable block. Refusals carry the same two
machine codes REST answers with (OBJECT_API_DISABLED 404,
OBJECT_API_METHOD_NOT_ALLOWED 405, with the effective operation set attached).

The gate runs before the existence probe in update / remove: gating after it would
answer "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's
transport parity: one bridge, one tool surface.

The parity is pinned the way it is actually reviewable from inside packages/mcp: the
HTTP verdict function lives in packages/runtime, which this package neither depends on
nor may read (that is exactly the cross-package test input check:cross-package-test-inputs
exists to catch). So the declaration-to-verdict table mirrors the one
packages/runtime/src/api-exposure.test.ts pins the HTTP side against, case for case. The
half that is not a hand-copied table is GATED_ACTIONS, asserted structurally against
the spec's own action map — a typo in an action word would otherwise fall through
DATA_ACTION_TO_API_OPERATION[action] ?? action and 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 path
throws a bare recordNotFound: a plain .toThrow() assertion would have stayed green
there, which is why the refusals assert the code and status envelope.

Local: pnpm --filter @objectstack/mcp test 162 passed (14 files), typecheck clean.
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 readonly strip, existence
probes, spec-shaped receipts, expand / select — is left intact and still points at that
follow-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 own
getRecord seam in plugin.ts and is still ungated. Out of scope: #7279 also touches that
area and is deliberately not in flight, so this PR does not go near it.


Generated by Claude Code

…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
@vercel

vercel Bot commented Aug 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 13, 2026 2:11am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/mcp.

11 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/actions-as-tools.mdx (via @objectstack/mcp)
  • content/docs/ai/agents.mdx (via @objectstack/mcp)
  • content/docs/ai/connect-mcp.mdx (via @objectstack/mcp)
  • content/docs/ai/index.mdx (via @objectstack/mcp)
  • content/docs/ai/natural-language-queries.mdx (via @objectstack/mcp)
  • content/docs/api/index.mdx (via @objectstack/mcp)
  • content/docs/deployment/environment-variables.mdx (via @objectstack/mcp)
  • content/docs/permissions/authorization.mdx (via @objectstack/mcp)
  • content/docs/permissions/system-context.mdx (via packages/mcp)
  • content/docs/plugins/packages.mdx (via @objectstack/mcp)
  • content/docs/protocol/knowledge.mdx (via @objectstack/mcp)

1 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/mcp)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 13, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 13, 2026 01:41
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 13, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Aug 13, 2026
…-stdio-exposure-gate

# Conflicts:
#	packages/mcp/src/stdio-data-bridge.ts
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 13, 2026
Merged via the queue into main with commit e472bbe Aug 13, 2026
26 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-8083-mcp-stdio-exposure-gate branch August 13, 2026 02:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The two MCP transports run their data verbs through different seams — stdio skips the ADR-0049 apiEnabled / apiMethods exposure gate that HTTP applies

2 participants