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 |
buildMcpBridge — packages/runtime/src/domains/mcp.ts |
createStdioDataBridge — packages/mcp/src/stdio-data-bridge.ts |
| data path |
callData → protocol 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
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
McpDataBridgeinterface (#8034 makes that structural). What each tool then executes still differs, because the two hosts implement the bridge over different seams:/api/v1/mcp)OS_MCP_STDIO_ENABLED)buildMcpBridge—packages/runtime/src/domains/mcp.tscreateStdioDataBridge—packages/mcp/src/stdio-data-bridge.tscallData→protocolservice, falling back to the ObjectQL enginecheckApiExposureat the top ofcallData)readonlystrip, existence probes, spec-shaped receipts,expand/selectprotocolservice is registeredcallDatacannot simply be reused by the stdio host: its signature is bound toHttpProtocolContext(the request, its resolved kernel, its per-environment data driver), and a long-lived stdio session has no request — it has one identity resolved fromOS_MCP_STDIO_API_KEY.Why it matters
An author who declares
enable.apiEnabled: false(or narrowsapiMethods) 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.tsdocuments 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'sExecutionContexton 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 overql.findwithout 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:
callDataoffHttpProtocolContextonto a narrow "resolve a service by name, in this scope" interface, and let the stdio host construct that. Largest change; removes the fork outright.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 ofstart(), since registering a tool is what declares thetoolscapability and the SDK refuses capability registration after a transport attaches.resolveEffectiveApiMethods/isApiOperationAllowed(the single source of truthcheckApiExposurealready 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.tsso the next reader of that file is pointed here rather than re-deriving it.Generated by Claude Code