Skip to content

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

Description

@hotlong

Found while implementing #8034 (registering the object tools on the stdio MCP transport). Filed rather than fixed there: #8034's scope is "the tools are not registered at all", and closing this properly is a design change in a second package.

What differs

Both MCP transports now register the SAME tools from the same McpDataBridge interface (#8034 makes that structural). What each tool then executes still differs, because the two hosts implement the bridge over different seams:

HTTP (/api/v1/mcp) stdio (OS_MCP_STDIO_ENABLED)
bridge builder buildMcpBridgepackages/runtime/src/domains/mcp.ts createStdioDataBridgepackages/mcp/src/stdio-data-bridge.ts
data path callDataprotocol service, falling back to the ObjectQL engine the ObjectQL engine only
ADR-0049 object exposure gate applied (checkApiExposure at the top of callData) not applied
protocol-layer ingress readonly strip, existence probes, spec-shaped receipts, expand / select applied when the protocol service is registered not applied

callData cannot simply be reused by the stdio host: its signature is bound to HttpProtocolContext (the request, its resolved kernel, its per-environment data driver), and a long-lived stdio session has no request — it has one identity resolved from OS_MCP_STDIO_API_KEY.

Why it matters

An author who declares enable.apiEnabled: false (or narrows apiMethods) on an object is telling the platform not to expose that object's data operations over the API. That declaration is honoured on the MCP HTTP surface and ignored on the MCP stdio surface — same product, same tool names, same key, different answer.

This is not an authorization bypass, and should not be triaged as one. packages/runtime/src/api-exposure.ts documents the gate as a SURFACE-AREA control rather than the authorization boundary, and its own fail-open decision rests on that: every call still passes the ObjectQL security middleware (CRUD / FLS / RLS) regardless of the outcome. On stdio those middlewares run too — the bridge calls the engine with the key's ExecutionContext on every verb. What leaks is the author's exposure declaration, not the data guard.

Scope note: the gap predates #8034 in a narrower form. The ADR-0101 record resource (objectstack://objects/{objectName}/records/{recordId}) has read rows over ql.find without the exposure gate since #7645. #8034 widens the same seam from one read path to the object-CRUD tool set.

Suggested shape (not prescriptive)

One transport-neutral data seam both MCP hosts call, so there is no second implementation to drift:

  • A — lift callData off HttpProtocolContext onto a narrow "resolve a service by name, in this scope" interface, and let the stdio host construct that. Largest change; removes the fork outright.
  • B — have the runtime supply the bridge to the long-lived server (the MCP plugin already triggers mcp:ready), with the stdio identity passed in as a per-call principal provider so ADR-0101 D1 revocation still takes effect on the next call. Needs the tool wiring to move ahead of start(), since registering a tool is what declares the tools capability and the SDK refuses capability registration after a transport attaches.
  • C — apply just the exposure gate on the stdio bridge by calling the spec's own resolveEffectiveApiMethods / isApiOperationAllowed (the single source of truth checkApiExposure already delegates to). Smallest; closes the declaration leak but leaves the rest of the table divergent.

A parity test in the shape of #8034's transport parity: one bridge, one tool surface — one declaration, both transports, same verdict — is what would keep whichever option lands from regressing.

Source

Extracted from the #8034 implementation. The divergence is recorded in the module docblock of packages/mcp/src/stdio-data-bridge.ts so the next reader of that file is pointed here rather than re-deriving it.


Generated by Claude Code

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions