Skip to content

fix(mcp): the stdio record resource honours the ADR-0049 apiEnabled / apiMethods exposure declaration (#8266) - #8439

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-8266-record-resource-exposure-gate
Aug 13, 2026
Merged

fix(mcp): the stdio record resource honours the ADR-0049 apiEnabled / apiMethods exposure declaration (#8266)#8439
os-zhuang merged 1 commit into
mainfrom
claude/issue-8266-record-resource-exposure-gate

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#8266

An object that declares enable.apiEnabled: false — or narrows enable.apiMethods so a single-record read falls outside the whitelist — was refused by the get_recordtool and still readable through the ADR-0101 record resource (objectstack://objects/{objectName}/records/{recordId}). Same transport, same key, same declaration, two answers.

Grading: this is a surface-area declaration leak, NOT an authorization bypass

packages/runtime/src/api-exposure.ts records this gate as a surface-area control rather than the authorization boundary. The resource read passed the ObjectQL security middleware (CRUD / FLS / RLS) under the API key's ExecutionContext before this change and after it — that is unchanged and was never at issue. What was leaking is the author's exposure declaration. Please do not read this as a security fix in the release notes.

Why the resource was missed when the tool was gated

The six object-CRUD verbs all flow through createStdioDataBridge, gated since #8083 (PR #8265). The record resource does not use that bridge at all: its reader is a separate closure built inline in packages/mcp/src/plugin.ts that calls ql.find directly and is handed to bridgeResources. Gating the bridge therefore never reached it — a seam that skipped the decision, not a seam that got the decision wrong.

The change (route A, as scoped on the card)

Route B (rebuilding the resource reader from the bridge) was explicitly out of scope and was not needed — route A lands cleanly, so there is no fork to report.

Tests: the parity claim, and its ablation

plugin.record-resource-exposure.test.ts boots the real plugin once per case against one fake metadata service holding one declaration, and drives both reads down a real StdioServerTransport: resources/read on the record URI and tools/call get_record. The verdicts are asserted against each other rather than against two hand-written literals — asserting the resource alone would stay green on a build where the tool had silently stopped gating too.

The failure mode the card names — a parity that passes because both paths refuse for some unrelated reason — is closed by an ablation that changes only the declaration and requires both paths to serve the row, reach the engine, and still carry the key's identity.

Measured before the fix, with the gate absent and the identical fixture:

AssertionError: the record resource SERVED a row for an object declaring apiEnabled: false
Expected: undefined
Received: { "id": "t1", "title": "the row behind the declaration" }

The tool half refused in that same run, which is the divergence itself, reproduced. After the fix both paths refuse with the identical sentence and the engine is never asked for the row.

The ADR-0112 envelope (code + status) is asserted at the seam that carries it, because both surfaces forward only .message to the wire (the resource's catch in bridgeResources, and the tool's errorResult(messageOf(err))). That is pre-existing on both and is not this card's to change.

Verification

  • pnpm --filter @objectstack/mcp test — 170 passed (15 files), whole package suite
  • pnpm --filter @objectstack/mcp typecheck — clean
  • Gate families derived against the actual changed paths via scripts/pm/dispatch-gates.mjs: check:nul-bytes, check:cross-package-test-inputs, check:changeset-gate-self-tests, check:objectui-changeset, check:query-options-erasure, check:type-check-coverage, check-changeset-no-major — all pass.
  • check-objectui-pin-fresh fails, and does so identically with this PR's changeset removed — a stale .objectui-sha in the repo, independent of this diff.

Note for #7279, which is not in flight and is untouched here

While gating the reader I measured that the two read paths hand the engine their ExecutionContext in different argument positions: the bridge sends find(obj, query, { context }), while this inline reader sends find(obj, { where, limit, context }). Nothing here depends on that and I changed neither, but it is the kind of hand-assembled divergence that card is about, so it is recorded rather than left for it to rediscover.


Generated by Claude Code

… apiMethods exposure declaration (#8266)
The six object-CRUD verbs all flow through `createStdioDataBridge` and have
been gated since #8083. The ADR-0101 record resource does not use that bridge
— its reader is a separate closure built inline in `plugin.ts` that calls
`ql.find` directly and is handed to `bridgeResources` — so gating the bridge
never reached it. One object declaring `enable.apiEnabled: false` was refused
by the `get_record` tool and still readable at
`objectstack://objects/{objectName}/records/{recordId}`: same transport, same
key, same declaration, two answers.
That reader now applies the same gate, with the same action word (`get`), from
the same helper, which takes its decision from the spec's single source of
truth (`resolveEffectiveApiMethods` / `isApiOperationAllowed`) rather than
re-deriving it. `enforceApiExposure` is exported from `stdio-data-bridge.ts`
for that one consumer; `index.ts` publishes neither it nor the bridge builder.
This is a SURFACE-AREA declaration leak, not an authorization bypass: the read
passed CRUD/FLS/RLS under the key's `ExecutionContext` before and after. What
was leaking is the author's exposure declaration.
Tests drive BOTH read paths down a real `StdioServerTransport` from ONE plugin
boot against ONE declaration, so the parity claim is a comparison rather than
two independent literals, plus an ablation that moves only the declaration and
requires both paths to serve — a parity that held because both paths refused
for an unrelated reason would be the failure mode here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P7vaLs7bhBPi9m3JyzkhDj
@vercel

vercelBot commented Aug 13, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 13, 2026 1:10pm

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-actionsgithub-actionsBot 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 14:19
@os-zhuang
os-zhuang added this pull request to the merge queueAug 13, 2026
Merged via the queue into main with commit e437471Aug 13, 2026
26 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-8266-record-resource-exposure-gate branch August 13, 2026 14:44
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The stdio MCP record resource reads rows through its own getRecord seam, skipping the ADR-0049 apiEnabled / apiMethods exposure gate

2 participants

@os-zhuang@claude