Skip to content

The stdio MCP bridge mints its own not-found error — no RECORD_NOT_FOUND code, no 404 — where every sibling seam throws the shared recordNotFoundError #8422

Description

@qq9340100

Found while building #8194's consumer-seam invariant, which enumerates every seam that performs a by-id write on a caller-supplied id and answers a receipt. Four seams exist; three reach the repo's ONE not-found envelope and one does not.

What the seam scan measured

seam [local] packages/mcp/src/stdio-data-bridge.ts:337 update() — by-id update
seam [local] packages/mcp/src/stdio-data-bridge.ts:347 remove() — by-id delete
seam [shared] packages/metadata-protocol/src/protocol.ts:7698 updateData()
seam [shared] packages/metadata-protocol/src/protocol.ts:7712 deleteData()
seam [shared] packages/runtime/src/action-execution.ts:205 callData() — update
seam [shared] packages/runtime/src/action-execution.ts:241 callData() — delete

All six refuse before answering, so existence coherence itself is intact — this is not a silent-success defect. The divergence is the ENVELOPE. packages/mcp/src/stdio-data-bridge.ts:130:

functionrecordNotFound(object: string,id: string): Error{returnnewError(`Record "${id}" not found in "${object}"`);}

versus packages/core/src/utils/record-not-found.ts, which sets code = 'RECORD_NOT_FOUND' and status = 404 on the error it returns.

Why that is worth a card

record-not-found.ts's own header is the argument, and it was written against exactly this shape twice already: the factory was "extracted so the READ and the two WRITE paths cannot disagree about it" (#4435), then EXPORTED because callData's fallback "had reinvented this fact three incompatible ways" (#5138), then moved down to @objectstack/core so a third producer could reach it (#7867). Its sentence "re-spelling the envelope there would have been a second not-found envelope" applies here unchanged — this is the fourth spelling.

Concretely, the two MCP transports answer a missing id differently. The HTTP bridge routes its data verbs through callData, which throws recordNotFoundError (code + 404); the stdio bridge throws a bare Error, so a stdio caller gets a message with no machine-readable code and nothing that maps to 404. That is the same two-transports-one-operation divergence class as #8083 (stdio skipped the ADR-0049 exposure gate HTTP applied) and #8266 (the stdio record resource reads through its own seam) — both on this same file, neither covering the error envelope.

The local factory's comment explains why it throws rather than returns (registerObjectTools turns a throw into a tool error), which is right and is not the question here. The question is only which error object it throws.

Suggested fix

Import recordNotFoundError from @objectstack/core and delete the local mint. Worth checking whether @objectstack/mcp already depends on @objectstack/core (metadata-protocol re-exports it too, per packages/metadata-protocol/src/index.ts).

Why this is filed rather than fixed in #8194's PR

#8194's declared file surface is scripts/check-engine-double-contract.mjs only; packages/mcp was explicitly out of scope, so the gate reports the two rows as local rather than reddening on a defect its own PR did not introduce. That gate has a SHARED_ONLY-shaped tightening noted in its header: once this card lands, requiring the shared envelope becomes a one-line change and the seam list is already complete in both directions.

Dedup: searched open issues for the file path, for the envelope factory by name, and for the not-found/MCP divergence wording. The nearest hits are #8266 and #8083, both stdio-vs-HTTP seam divergences on this file, neither about the error envelope.

Refs #8194, #8083, #8266, #7867, #5138, #4435, packages/core/src/utils/record-not-found.ts.


Generated by Claude Code

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions