Repository navigation
fix(mcp): a coded refusal reaches the client with its code on the data tools, list_actions and the record resource - #23526
Conversation
…ls and the record resource
The data tools' and list_actions' catches returned errorResult(messageOf(err)),
and the record resource wrote { error: message }, so a coded refusal reached an
MCP client as its sentence alone. They now render through errorResultFromThrown
(tools) and thrownRefusalEnvelope (resource); a code-less throw renders as before.
Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm
Co-authored-by: Claude <noreply@anthropic.com>
… resource, both faces Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
…rd resource Measured on /mcp: a PERMISSION_DENIED from the security gate carries the caller's positions and permissionSets in details, which the runtime dispatcher drops from the wire. These surfaces now serve code, message and status only; run_action and resume_run keep errorResultFromThrown unchanged. Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check8 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 12 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d03ea8629aa55858a7c0b14e5b805e2d4cc35c91 && git checkout d03ea8629aa55858a7c0b14e5b805e2d4cc35c91
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0be162c75dc68d62820d7871f24e4b0af925f0f5 6c70ec74ab5693747d62593da298032c657cea26 && git checkout -B drift-repro 0be162c75dc68d62820d7871f24e4b0af925f0f5 && git merge --no-ff 6c70ec74ab5693747d62593da298032c657cea26
node scripts/docs-audit/affected-docs.mjs --json 0be162c75dc68d62820d7871f24e4b0af925f0f5 |
席位核对
Verdict: ACCEPT. Not governed (
|
Fixes #23509
Clause-②: yes
What changes
The MCP data tools rendered a thrown refusal with
errorResult(messageOf(err)):list_objects,describe_object,validate_expression,query_records,aggregate_records,get_record,create_record,update_recordanddelete_record. So didlist_actions. The record resourceobjectstack://objects/{objectName}/records/{recordId}wrote{ error: message }.So when a producer threw the ADR-0112 envelope, an MCP client received the message and nothing else. The code is the machine-readable half the client acts on, and it was lost for every coded refusal on these surfaces:
MFA_REQUIRED. Ruling C′ on auth-gate parity: a request carrying an API key beside a session cookie held by ADR-0069 is refused at REST but admitted by the five raw-host route families — one cross-seam answer is owed (session versus user) #23287, item 1: "a held user's API key is refused at every seam with the same code";RECORD_NOT_FOUND;PERMISSION_DENIED;INVALID_FIELD;packages/mcp/src/mcp-http-tools.tserrorResultWithCode(err). It renders{ error: { code, message, status } }, the shaperun_actionalready answers with (chosen for the action tools in MCPrun_actionnever enforcesai.requiresConfirmation— it is surfaced inlist_actionsonly, while@objectstack/specguidance tells authors it stops execution #15942), but withoutdetails. The section below explains why.run_actionandresume_runkeeperrorResultFromThrown. Their text is byte-identical: same members, same key order.thrownRefusalEnvelope(err). It is a named export of the module and is not re-exported from the package index.packages/mcp/src/mcp-server-runtime.tscode, andstatuswhen the refusal carries one, beside the unchangederrorsentence.Unchanged
coderenders exactly as today on both surfaces.FROM → TO: what an MCP client reads
list_actionsisError: true; the text is the messageisError: true; the text is the JSON{ "error": { "code", "message", "status" } }.statusappears only when the refusal carries one.{ "error": MESSAGE }{ "error": MESSAGE, "code", "status" }.statusappears only when the refusal carries one.erroris the same string as before.Why
detailsis withheldI measured it at the real
/mcpdoor, on the showcase stack with security enabled. APERMISSION_DENIEDthrown by the security gate (plugin-securitysecurity-plugin.ts, the denial withpositions/permissionSets) reaches the tool catch carrying the caller'spositions(org_member,everyone) andpermissionSets(showcase_member_default,member_default) indetails.The runtime dispatcher drops those
detailsfrom the wire. Its catch inhttp-dispatcher.tsreads "The denial'sdetailsdoes NOT reach the wire" and records the ruling on #7450.That policy lives in
@objectstack/runtimeand@objectstack/plugin-security, and neither is a dependency of this package. So these surfaces serve the members every boundary serves:code,messageandstatus. The card's done line asks for exactly that: "with itscode(andstatuswhere the envelope carries one)".The first revision of this branch rendered
details. The real-door probe caught the disclosure, and the head commit removes it. Ten pins plus an ablation now hold the withholding (below).Why the record resource keeps a flat shape
The tools take
errorResultFromThrown's nesting. The resource takes the same members but not the nesting: it answers{ error: MESSAGE, code, status }, not{ error: { code, message, status } }. Three reasons:objectstack://objects/{objectName}answers{ error, code, status }on a miss or an outage (mcp-server-runtime:443 把 metadata outage 答成 'Agent "X" not found' —— fail-closed 但误述,#5840 定性后的 cli 侧跟进 #6055), andobjectstack://objectsanswers the same pair on a degraded listing (IMetadataService.list() presents a known-partial answer as a complete one — the #5840 shape on the plural read #6504).typeof body.error === 'string'. One is the normalizer inplugin.record-resource-exposure.test.ts; the other is the member case instdio-data-bridge.stored-metadata-body.test.ts. A nestederrorobject would turn their refusal branch into "this is a row". That would be a narrowing on a surface this PR declares as a widening.code, and thestatuswhere the envelope has one") is met either way.Faces
MFA_REQUIREDis pinned on both, over a realStdioServerTransport./mcpserves the tools only.handleHttpRequestregisters no resources: the record resource exists only on the long-lived server (plugin.ts:738).POST /api/v1/mcp, showcase stack, a member caller), on the final head:get_recordandupdate_recordon another owner's row:RECORD_NOT_FOUND/ 404;delete_record:PERMISSION_DENIED/ 403, with nodetails;create_recordwith an unknown field andquery_recordsfiltering on one:INVALID_FIELD/ 400.isError: truewith{ error: { code, message, status } }.Tests re-judged (none loosened, none deleted)
stdio-api-key-account-posture.pin.test.ts(#23508's real-transport pin)MFA_REQUIRED/ 403, codedtools/call query_recordsnow asserts{ error: { code: 'MFA_REQUIRED', message, status: 403 } }; it asserted the text equal to the message. Extended withresources/readon the record over the same wire:{ error: message, code: 'MFA_REQUIRED', status: 403 }.plugin.record-resource-exposure.test.ts, theapiEnabled: falseand the whitelist-excludes-getcasesOBJECT_API_DISABLED/ 404,OBJECT_API_METHOD_NOT_ALLOWED/ 405, codedstdio-data-bridge.relation-condition-target-exposure.test.ts, thequery_recordstool caseOBJECT_API_DISABLED/ 404, codedmcp-server-runtime.http.test.ts(RLS: not permitted,Field read denied)Error, no codemcp-action-tools.test.ts(list_actions:metadata unavailable)Error, no codemcp-action-confirmation-member.test.ts(handler exploded)Error, no codemcp-validate-expression.test.ts(not found),mcp-resume-run-tool.test.ts(runId is required), everysystem objectcaseerrorResultliteral, never a throwstdio-data-bridge.stored-metadata-body.test.ts, the member casetypeof body.error === 'string'Pins added:
packages/mcp/src/mcp-coded-refusal.test.ts(42 cases)tools/list, so a tool added later needs a row.recordNotFoundErrorrenders as{ error: { code: 'RECORD_NOT_FOUND', message, status: 404 } }withisError.Errorrenders as its bare message, byte-identical.run_actionandresume_run, the security gate'sPERMISSION_DENIEDrenders as code, message and status, and thedetailsare withheld.{ statusCode, message }with nocode; it renders as the bare message, so nothing is invented.code, or a thrown string, is not a code.statusis omitted.MFA_REQUIREDrenders as{ error, code, status }.PERMISSION_DENIEDrenders as code and status, withdetailswithheld.statusgets nostatus.{ error }byte-identically.Ablation (one-time; nothing committed)
All three legs ran on the final code. Each went through
scripts/ablation-replace.mjs: the anchor hit once, the blob changed, and the restore was proven by blob == HEAD and an emptygit diff HEAD. The subject resolves through relativesrc/imports, not a dependency'sdist/, so no rebuild leg applies. Every leg turned red, the expected direction.query_records' catch reverted toerrorResult(messageOf(err)). Blobb7a491d6641b → 6c8a41093f41. 4 red:query_recordsRECORD_NOT_FOUNDrow;query_recordsPERMISSION_DENIEDrow;MFA_REQUIREDpin.The code-less row stayed green, as it should. Restored to
b7a491d6641b.errorResultWithCodemade to carrydetails. Blobb7a491d6641b → f80698dd3e44. 10 red: everyWITHHOLDS its detailsrow. Nothing else went red. Restored.The record resource's envelope read disabled. Blob
3ef6ce641eb8 → 65c81c69b058. 6 red:MFA_REQUIREDresource read;getRecordseam, skipping the ADR-0049apiEnabled/apiMethodsexposure gate #8266 parity cases.The code-less resource row stayed green. Restored.
mcp-http-tools.tsis byte-identical between the head of legs 1 and 2 and the final head (b7a491d6641b). The merge ofmaintouched nopackages/mcpfile.Verification
All at
6c70ec74ab.pnpm --filter @objectstack/mcp test: 38 files, 462 tests passed,VERDICT command-exit 0. That is 420 before this PR plus 42 new; the three coded pins that asserted the message alone were moved, not deleted.pnpm --filter @objectstack/mcp typecheck:tsc --noEmitpasses, andcheck:test-typecheck: OK. The 53 errors it reports are the ones held in the shrink-only debt ledger, and none is in a touched file. All four touched test files are in thetsconfig.test.jsonprogram, counted with--listFiles.pnpm --filter '@objectstack/mcp^...' buildexited 0. After the merge ofmain,pnpm turbo run build --filter='@objectstack/dogfood^...'ran 65/65 tasks.packages/qa/dogfoodshowcase-mcp-http-identity,flow-door-elevated-start,showcase-mcp-self-connectionandshowcase-declarative-mcp, plus the temporary real-door probe. 5 files, 39 tests passed.packages/cli/test/package-install-local-handlers.integration.test.tsreads onlyrun_actionand a successfullist_actions, paths this PR does not change. It is integration tier and declared to CI.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 63 families from the change set. I ran all 63 locally on this head and each exited 0. The--ranreconciliation, fedcommand :: exit codelines, reads: "63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN", a zero derived from the recorded codes.check-adr-0087-registration: this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen),No empty-frontmatter changeset introduced by this diff,check-nul-bytes: OK,doc authoring guard: … clean,check-test-source-alias OK,check:dual-build-cjs-loads — 110 published require entry point(s) across 68 package(s) loadandcheck-lean-entry-closure: 2 published condition(s) measured.dist/. They were re-run and are exit 0 on this head.pnpm lintfirst, and the rest of the farm run in CI.Acceptance notes
Two findings go to the seat in this PR's report and are not filed from here:
/mcp, the bridge's exposure refusal carries no code.callData's gate on the addressed object (packages/runtime/src/action-execution.ts:304) throws{ statusCode, message }with nocode, andbuildMcpBridgecallscallDatafor every/mcpdata verb.get_record/query_recordson an object declaringapiEnabled: falsestill renders the message alone on/mcp. That case is pinned as code-less here: nothing is invented.enforceApiExposure) and REST (rest-server.ts:510) answerOBJECT_API_DISABLED, and so does the samecallData's relation-target refusal.packages/runtime, not in this consumer.run_action/resume_runrenderdetailsverbatim througherrorResultFromThrown(MCPrun_actionnever enforcesai.requiresConfirmation— it is surfaced inlist_actionsonly, while@objectstack/specguidance tells authors it stops execution #15942's contract, unchanged here).resume_run's errors are built through the REST error builder.run_actionloads its subject record throughcallDataas the caller. If a security-gatePERMISSION_DENIEDescapes it, itspositions/permissionSetswould reach the MCP client, while the dispatcher withholds them.Observations, not filed:
registerToolFromDefinitioninmcp-server-runtime.ts) also renders a throw as its message alone. No coded producer was measured on that path.code/message/statusare not rendered on these surfaces: the stdio 405'sallowedOperations, and the runtime refusal'sallowed,objectandparam. REST's 405 carriesallowed.detailscould help an AI self-correct, for example a validation error's field list. Serving them would need the per-class disclosure policy this package does not hold. Nothing pulls on it today.Generated by Claude Code