Skip to content

fix(mcp): a coded refusal reaches the client with its code on the data tools, list_actions and the record resource - #23526

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-23509-mcp-coded-refusal
Oct 14, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-23509-mcp-coded-refusal

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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_record and delete_record. So did list_actions. The record resource objectstack://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:

packages/mcp/src/mcp-http-tools.ts

packages/mcp/src/mcp-server-runtime.ts

  • The record resource's catch adds code, and status when the refusal carries one, beside the unchanged error sentence.

Unchanged

  • A throw with no non-empty string code renders exactly as today on both surfaces.
  • No code is minted. No refusal's message changes.

FROM → TO: what an MCP client reads

surface refusal FROM TO
the 9 data tools and list_actions coded isError: true; the text is the message isError: true; the text is the JSON { "error": { "code", "message", "status" } }. status appears only when the refusal carries one.
record resource (stdio) coded { "error": MESSAGE } { "error": MESSAGE, "code", "status" }. status appears only when the refusal carries one. error is the same string as before.
either no code the message unchanged, byte-identical

Why details is withheld

I measured it at the real /mcp door, on the showcase stack with security enabled. A PERMISSION_DENIED thrown by the security gate (plugin-security security-plugin.ts, the denial with positions / permissionSets) reaches the tool catch carrying the caller's positions (org_member, everyone) and permissionSets (showcase_member_default, member_default) in details.

The runtime dispatcher drops those details from the wire. Its catch in http-dispatcher.ts reads "The denial's details does NOT reach the wire" and records the ruling on #7450.

That policy lives in @objectstack/runtime and @objectstack/plugin-security, and neither is a dependency of this package. So these surfaces serve the members every boundary serves: code, message and status. The card's done line asks for exactly that: "with its code (and status where 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:

Faces

  • stdio serves the tools and the record resource. MFA_REQUIRED is pinned on both, over a real StdioServerTransport.
  • HTTP /mcp serves the tools only. handleHttpRequest registers no resources: the record resource exists only on the long-lived server (plugin.ts:738).
    • A held key is refused at the dispatcher before the MCP server sees it. That is the dispatch's mechanism note, and I did not re-measure it. So the HTTP pins use refusals that do reach the tool catch.
    • Measured at the real door (POST /api/v1/mcp, showcase stack, a member caller), on the final head:
      • get_record and update_record on another owner's row: RECORD_NOT_FOUND / 404;
      • delete_record: PERMISSION_DENIED / 403, with no details;
      • create_record with an unknown field and query_records filtering on one: INVALID_FIELD / 400.
    • Each answered isError: true with { error: { code, message, status } }.
    • The probe was a temporary file, deleted after the run and never committed.

Tests re-judged (none loosened, none deleted)

test refusal it pins verdict
stdio-api-key-account-posture.pin.test.ts (#23508's real-transport pin) MFA_REQUIRED / 403, coded moved to the coded rendering. tools/call query_records now asserts { error: { code: 'MFA_REQUIRED', message, status: 403 } }; it asserted the text equal to the message. Extended with resources/read on the record over the same wire: { error: message, code: 'MFA_REQUIRED', status: 403 }.
plugin.record-resource-exposure.test.ts, the apiEnabled: false and the whitelist-excludes-get cases OBJECT_API_DISABLED / 404, OBJECT_API_METHOD_NOT_ALLOWED / 405, coded moved. The normalizer reads the code and status from both wires. The cases still assert the same sentence on both paths, and now also the code and status.
stdio-data-bridge.relation-condition-target-exposure.test.ts, the query_records tool case OBJECT_API_DISABLED / 404, coded moved. It stayed green as a substring inside the JSON, so it was re-judged rather than left as is. It now parses the envelope and asserts the code, the status and both message fragments.
mcp-server-runtime.http.test.ts (RLS: not permitted, Field read denied) plain Error, no code code-less, unchanged
mcp-action-tools.test.ts (list_actions: metadata unavailable) plain Error, no code code-less, unchanged
mcp-action-confirmation-member.test.ts (handler exploded) plain Error, no code code-less, unchanged
mcp-validate-expression.test.ts (not found), mcp-resume-run-tool.test.ts (runId is required), every system object case an errorResult literal, never a throw not a thrown refusal, unchanged
stdio-data-bridge.stored-metadata-body.test.ts, the member case reads typeof body.error === 'string' holds under the flat shape, unchanged

Pins added: packages/mcp/src/mcp-coded-refusal.test.ts (42 cases)

  • HTTP face, every registered tool. The pins run through the real Streamable HTTP transport, one row per tool, each driven by its own bridge member. The table is held equal to tools/list, so a tool added later needs a row.
    • The real recordNotFoundError renders as { error: { code: 'RECORD_NOT_FOUND', message, status: 404 } } with isError.
    • A code-less Error renders as its bare message, byte-identical.
    • On the 10 rows other than run_action and resume_run, the security gate's PERMISSION_DENIED renders as code, message and status, and the details are withheld.
  • Boundaries.
    • The HTTP bridge's exposure gate throws { statusCode, message } with no code; it renders as the bare message, so nothing is invented.
    • An empty or numeric code, or a thrown string, is not a code.
    • A non-numeric status is omitted.
  • Record resource.
    • MFA_REQUIRED renders as { error, code, status }.
    • PERMISSION_DENIED renders as code and status, with details withheld.
    • A refusal with no status gets no status.
    • A code-less throw answers { 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 empty git diff HEAD. The subject resolves through relative src/ imports, not a dependency's dist/, so no rebuild leg applies. Every leg turned red, the expected direction.

  1. query_records' catch reverted to errorResult(messageOf(err)). Blob b7a491d6641b → 6c8a41093f41. 4 red:

    • the query_records RECORD_NOT_FOUND row;
    • the query_records PERMISSION_DENIED row;
    • the non-numeric-status row;
    • the stdio MFA_REQUIRED pin.

    The code-less row stayed green, as it should. Restored to b7a491d6641b.

  2. errorResultWithCode made to carry details. Blob b7a491d6641b → f80698dd3e44. 10 red: every WITHHOLDS its details row. Nothing else went red. Restored.

  3. The record resource's envelope read disabled. Blob 3ef6ce641eb8 → 65c81c69b058. 6 red:

    The code-less resource row stayed green. Restored.

mcp-http-tools.ts is byte-identical between the head of legs 1 and 2 and the final head (b7a491d6641b). The merge of main touched no packages/mcp file.

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 --noEmit passes, and check: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 the tsconfig.test.json program, counted with --listFiles.
  • Dependency closure: pnpm --filter '@objectstack/mcp^...' build exited 0. After the merge of main, pnpm turbo run build --filter='@objectstack/dogfood^...' ran 65/65 tasks.
  • Consumer tests that read MCP tool output, run on the final head: packages/qa/dogfood showcase-mcp-http-identity, flow-door-elevated-start, showcase-mcp-self-connection and showcase-declarative-mcp, plus the temporary real-door probe. 5 files, 39 tests passed.
  • packages/cli/test/package-install-local-handlers.integration.test.ts reads only run_action and a successful list_actions, paths this PR does not change. It is integration tier and declared to CI.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 63 families from the change set. I ran all 63 locally on this head and each exited 0. The --ran reconciliation, fed command :: exit code lines, reads: "63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN", a zero derived from the recorded codes.
    • Verdict lines include 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) load and check-lean-entry-closure: 2 published condition(s) measured.
    • In an earlier round on a superseded head, the last two answered exit 3, PREREQUISITE NOT MET, because a concurrent build was rewriting dist/. They were re-run and are exit 0 on this head.
  • The repo-wide scans, with pnpm lint first, 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:

  1. On /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 no code, and buildMcpBridge calls callData for every /mcp data verb.
    • So after this PR, get_record / query_records on an object declaring apiEnabled: false still renders the message alone on /mcp. That case is pinned as code-less here: nothing is invented.
    • The stdio face (enforceApiExposure) and REST (rest-server.ts:510) answer OBJECT_API_DISABLED, and so does the same callData's relation-target refusal.
    • The fix belongs at that producer, in packages/runtime, not in this consumer.
  2. run_action / resume_run render details verbatim through errorResultFromThrown (MCP run_action never enforces ai.requiresConfirmation — it is surfaced in list_actions only, while @objectstack/spec guidance tells authors it stops execution #15942's contract, unchanged here).
    • resume_run's errors are built through the REST error builder.
    • run_action loads its subject record through callData as the caller. If a security-gate PERMISSION_DENIED escapes it, its positions / permissionSets would reach the MCP client, while the dispatcher withholds them.
    • I have not measured this path.

Observations, not filed:

  • The AI tool-registry bridge's catch (registerToolFromDefinition in mcp-server-runtime.ts) also renders a throw as its message alone. No coded producer was measured on that path.
  • Members beside code / message / status are not rendered on these surfaces: the stdio 405's allowedOperations, and the runtime refusal's allowed, object and param. REST's 405 carries allowed.
  • A refusal's details could 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

…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>
…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>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

8 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
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 12 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 0be162c75dc68d62820d7871f24e4b0af925f0f5 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from d03ea8629aa55858a7c0b14e5b805e2d4cc35c91 — the merge of head 6c70ec74ab5693747d62593da298032c657cea26 into base 0be162c75dc68d62820d7871f24e4b0af925f0f5, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

domain:spec seat 4 (#18917) · sunhaolin · session session_0121ooBYRmS8ziFXm9ai5YHm · claim 6173443950 on #23509 · reviewed head 6c70ec74ab5693747d62593da298032c657cea26 · 2026-10-14T18:43Z

Verdict: ACCEPT. Not governed (check-governed-merges --pr 23526); 7 files, +482 / −50. This record is also the review at TIER_DEFAULT that the Clause-②: yes declaration owes. The face is what an MCP client reads on a refusal; no schema or rules text moves.

  1. Shape.
    • Draft PR. The first line is Fixes #23509, and Clause-②: yes sits at line start.
    • The changeset is @objectstack/mcp minor, Clause-②: yes (widening), with a FROM → TO table per surface and a reading guide for clients.
  2. Scope against the dispatch (triage 6172454097).
    • The 10 catches (9 data tools plus list_actions) render a thrown coded refusal through one reader, thrownRefusalEnvelope, as { error: { code, message, status } }. A throw with no code renders byte-identically.
    • The stdio record resource adds code and status beside its unchanged error sentence.
    • run_action and resume_run are unchanged.
    • No code is minted and no message changes.
  3. Gates and evidence.
    • Head check-runs: 34 success, 6 skipped (the full-battery or opt-in set), 0 failures. mergeable_state is clean.
    • @objectstack/mcp: 38 files and 462 tests pass, 42 of them new. The implementation alone turned exactly the 3 message-only coded pins red; those pins were re-pinned, not loosened.
    • stdio pins MFA_REQUIRED on query_records and on the record resource over a real StdioServerTransport. HTTP /mcp pins RECORD_NOT_FOUND, PERMISSION_DENIED and INVALID_FIELD, each measured at the real door.
    • Three ablations each went red on their predicted rows, and each restore was proven: the catch reverted, details carried, and the resource envelope disabled.
    • Local reconciliation: 63 derived, 63 run, 0 NOT MEASURED.
  4. Accepted deviations.
  5. Out-of-scope dispositions.
  6. Landing clauses.
    • (a) Every check is green or skipped by design: met.
    • (b) mergeable_state is clean at enqueue: met.
    • No CONTRACT_REVIEW_TIER review is owed: the widening's review is this record, at TIER_DEFAULT.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants