Skip to content

Commit 03533d2

Browse files
fix(mcp): record data faces withhold a declared 5xx's sentence as /data does (#31371)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X1tF2sadne3zXMJh3i9wcbN)_ Fixes #31341 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:MCP 的六个记录数据工具(`query_records`、`aggregate_records`、`get_record`、`create_record`、`update_record`、`delete_record`)、`delete_records` 的选择读取和记录资源,遇到声明了 5xx 状态码的带码异常(例如 `SERVICE_UNAVAILABLE` / 503)时,改按 `/api/v1/data` 的回答作答:码和状态码保留,生产者原句隐去成 `Internal server error`,除非异常自己标了 `refusal: true`;原句只在服务端记一次日志。 - 为什么改:之前这些面先走"带码即拒绝"的路径,把生产者自己的那句话原样交给智能体;`/data` 门对同一异常会隐去原句。 - 风险与回滚:只改 `@objectstack/mcp` 一处判定(`dataDoorRefusalEnvelope` 里一个条件),patch。声明了 4xx 的拒绝照旧逐字节不变(#31246 的 CONTROL 钉着);未注册的码配 5xx 会像门一样落成该状态的封闭码(门把原码放在 `declaredCode`,MCP 各面都不发这个成员)。回滚即还原本 PR。 - 先看:`packages/mcp/src/mcp-http-tools.ts` 的 `dataDoorRefusalEnvelope`;`packages/mcp/src/mcp-coded-refusal.test.ts` 里两个 #31341 块。 - 两分钟验证:`pnpm --filter @objectstack/mcp exec vitest run src/mcp-coded-refusal.test.ts -t 31341`,预期 11 个用例全部通过。 - 需要你做的:无,席位落地。 ## What changes `dataDoorRefusalEnvelope` (`packages/mcp/src/mcp-http-tools.ts`, #31273) decides whether a coded throw on a `/data` face renders as a coded refusal or as the door's fault answer. It used to ask the door only when the throw declared no status (`serverFaultProvenance` `'undeclared'`). It now asks the door for every throw whose status is a 5xx or undeclared (`serverFaultProvenance` answers `'declared'` or `'undeclared'`), and keeps the coded answer only for a declared refusal status below 500 (`serverFaultProvenance` answers `undefined`). The predicate change is one token: `!== 'undeclared'` became `=== undefined`. When the door (`mapDataError`, read through `dataDoorAnswerOf`) answers a 5xx, the face renders the door's `code`, sentence and `status` and logs the thrown text once through `logServerFault`, the path #31273 already built. No copy of the withhold rule: `mapDataError` applies `declaredRefusalMessage` itself. No new export from `@objectstack/types`; every reader was already imported. All eight faces already read this one function (the six tools through `dataDoorErrorResult`, `delete_records`' outer catch, the record resource catch in `mcp-server-runtime.ts`). Only comments change in `mcp-server-runtime.ts`. | Throw | Before (tools / resource) | Now | `/api/v1/data` (`mapDataError`) | |---|---|---|---| | `SERVICE_UNAVAILABLE` / 503, `'The ledger is closing.'` | code, 503, the producer's sentence | `SERVICE_UNAVAILABLE`, 503, `Internal server error`; the sentence logged once | same | | same, with `refusal: true` | code, 503, the sentence | unchanged | `503 SERVICE_UNAVAILABLE`, the sentence | | unregistered `VENDOR_DOWN` / 503 | `VENDOR_DOWN`, 503, the sentence | `SERVICE_UNAVAILABLE`, 503, `Internal server error` | same, plus `declaredCode: VENDOR_DOWN` | | CONTROL `RECORD_LOCKED` / 409 | coded | unchanged | `409 RECORD_LOCKED` | | CONTROL coded sandboxed refusal (`RECORD_LOCKED` / 409) | coded, the body's sentence | unchanged | `409 RECORD_LOCKED` | | CONTROL `PERMISSION_DENIED`, no status | `{ code, message }` | unchanged | `403 PERMISSION_DENIED` | | CONTROL `SQLITE_BUSY`, no status (#31273) | the door's `500 DATABASE_ERROR` | unchanged | same | On the resource, the same answers use its `{ error, code, status }` members. ## Tests `packages/mcp/src/mcp-coded-refusal.test.ts`: - Record data tools block (#31273's): #31273's CONTROL "a code with a declared 5xx keeps its coded answer" **moved**, as the order said it would. It is replaced by: - one row per face (7: the six tools and `delete_records`): exactly `{ code: 'SERVICE_UNAVAILABLE', message: 'Internal server error', status: 503 }`, equal to `mapDataError`'s answer, the sentence absent from the text, and exactly one `[5xx] 503 tools/call TOOL task — ` operator line (on `console.error` or `console.warn`) carrying the sentence; - `refusal: true` on every face: the sentence kept, equal to the door's answer; - the unregistered `VENDOR_DOWN` / 503 row: the door's closed code, sentence withheld. - Record resource, new #31341 block: the declared 503 body `{"error":"Internal server error","code":"SERVICE_UNAVAILABLE","status":503}`, equal to the door's, one `[5xx] 503 resources/read objectstack://objects/task/records/t1 — ` line with the sentence; the `refusal: true` body keeps the sentence. #31273's resource CONTROL kept only its status-less `PERMISSION_DENIED` half; the declared-503 half moved here. - CONTROLs unchanged and green: `RECORD_LOCKED` / 409 and the coded sandboxed refusal on every face (#31273 block) and on the resource (#31270 block), `PERMISSION_DENIED` with no status, and #31246's "a coded non-sandbox refusal renders its own message byte for byte". The operator line is read from `console.error` and `console.warn` together because `logServerFault` reports a declared `SERVICE_UNAVAILABLE` as a capability absence: one `warn` per route per process, as at the door. <details><summary>Red first and ablations</summary> Red first on `d0512c2f` (`main`), the new pins in place and no fix: `mcp-coded-refusal.test.ts` gave `Tests 9 failed | 127 passed (136)`. The failures: the resource declared-503 row, the seven per-face declared-503 rows and the unregistered-code row. The two `refusal: true` rows passed on base, as expected (they pin the sentence kept). With the fix: `136 passed`. Ablations, each via `node scripts/ablation-replace.mjs` on the committed head `5c91f46f`; each printed `ok mutation landed` and then `ok restored: blob == HEAD (ebb31a2) and git diff HEAD is empty`: 1. The predicate put back to `!== 'undeclared'` (the #31273 line). Blob `ebb31a2794c5 -> c9344e7ebdf1`, `Tests 9 failed | 127 passed (136)`: the same nine rows as red first. 2. The declared-refusal guard deleted, so every door 5xx is declined. Blob `ebb31a2794c5 -> c5b7abf4be16`, `Tests 1 failed | 135 passed (136)`: #31246's CONTROL "a coded non-sandbox refusal renders its own message byte for byte". Its third throw (a `SandboxError` with an empty `innerMessage` declaring `RECORD_LOCKED` / 409) is answered `500 INTERNAL_ERROR` by the door, so the guard is what keeps a declared 4xx out of this card. The tests import the package's source, so no `dist/` is involved. </details> ## Acceptance notes - **The door, measured before the change** (`mapDataError(v, 'task')` on the built `@objectstack/types`): declared `SERVICE_UNAVAILABLE` / 503 answers `503 { error: 'Internal server error', code: 'SERVICE_UNAVAILABLE' }`; with `refusal: true`, the sentence; unregistered `VENDOR_DOWN` / 503 answers `SERVICE_UNAVAILABLE` plus `declaredCode: 'VENDOR_DOWN'`; declared `INTERNAL_ERROR` / 500 answers `Internal server error`. - **An unregistered code with a declared 5xx answers the closed code.** The door names the producer's spelling only in `declaredCode`, which no MCP data or metadata face serves (`dataDoorAnswerOf` reads `code`, `error` and status only, as #31272's metadata faces do). Today that spelling reached the agent as `code`. Serving `declaredCode` here would add a member to these faces' tool error; it is not done. A producer keeps its own code on the wire by registering it. - **A declared refusal status below 500 is not reclassified**, even where the door answers 500 for it (a `SandboxError` with an empty `innerMessage`, which the door reads as the sandbox's own fault). That is a 4xx declaration, outside this card's 5xx scope; #31246's CONTROL keeps pinning the current answer. - **`Clause-②: no`, re-declared from measurement.** No export added or changed: `dataDoorRefusalEnvelope` is not on `packages/mcp/src/index.ts`, and the import line of `@objectstack/types` names the same symbols. The tool error keeps its keys (`code`, `message`, `status`); only the values for a declared 5xx move to the door's. - **Not touched:** `run_action` / `resume_run` (#31271, PR #31315) and the metadata tools and `list_actions` (#31272). They never call `dataDoorRefusalEnvelope`; `thrownRefusalEnvelope`, `errorResultFromThrown` and `metaDoorErrorResult` are byte-identical. PR #31315 is still open on the same two files; whichever lands second merges `origin/main` once. ## Verification Base `d0512c2f`; head `5c91f46f`. - `pnpm --filter @objectstack/mcp test` (vitest run, `--maxWorkers=2`): `Test Files 60 passed (60)`, `Tests 865 passed (865)`. - `pnpm --filter @objectstack/mcp typecheck`: exit 0 (`check:test-typecheck`: 6 files / 53 errors / 8 pinned signatures held). - `eslint --no-inline-config` on the three touched `.ts` files: exit 0. `@objectstack/mcp` has no `lint` script. <details><summary>Derived gates (node scripts/pm/dispatch-gates.mjs --commands, 65 commands, all on 5c91f46)</summary> - `node scripts/check-adr-0087-registration.mjs --base origin/main` :: exit 0 - `node scripts/check-adr-0087-registration.mjs --self-test` :: exit 0 - `node scripts/check-changeset-no-major.mjs --base origin/main` :: exit 0 - `node scripts/check-closing-keyword-parity.mjs` :: exit 0 - `node scripts/check-closing-keyword-parity.mjs --self-test` :: exit 0 - `node scripts/check-comment-mask-adoption.mjs` :: exit 0 - `node scripts/check-comment-mask-adoption.mjs --self-test` :: exit 0 - `node scripts/check-comment-mask-corpus.mjs` :: exit 0 - `node scripts/check-dts-emitted.mjs --self-test` :: exit 0 - `node scripts/check-empty-changeset.mjs --base origin/main` :: exit 0 - `node scripts/check-empty-changeset.mjs --self-test` :: exit 0 - `node scripts/check-issue-citations.mjs` :: exit 0 - `node scripts/check-plugin-teardown-shape.mjs` :: exit 0 - `node scripts/check-plugin-teardown-shape.mjs --self-test` :: exit 0 - `node scripts/ci/assert-turbo-remote-cache.mjs --self-test` :: exit 0 - `node scripts/ci/plan-test-core-shards.mjs --self-test` :: exit 0 - `node scripts/ci/prune-turbo-cache.mjs --self-test` :: exit 0 - `node scripts/ci/snapshot-next.mjs --self-test` :: exit 0 - `node scripts/docs-audit/check-affected-docs.mjs` :: exit 0 - `node scripts/docs-audit/check-drift-comment.mjs` :: exit 0 - `node scripts/pm/release-rehearsal-clone.mjs --self-test` :: exit 0 - `node scripts/release-pending-publish.mjs --self-test` :: exit 0 - `pnpm --filter @objectstack/spec run check:duration-unit-keys` :: exit 0 - `pnpm --filter @objectstack/spec run check:error-code-provenance` :: exit 0 - `pnpm check:adr-symbol-anchors` :: exit 0 - `pnpm check:changeset-gate-self-tests` :: exit 0 - `pnpm check:ci-filter-parity` :: exit 0 - `pnpm check:closed-ceremony-text` :: exit 0 - `pnpm check:cross-package-test-inputs` :: exit 0 - `pnpm check:dispatcher-error-vocabulary` :: exit 0 - `pnpm check:doc-authoring` :: exit 0 - `pnpm check:driver-memory-census` :: exit 0 - `pnpm check:dts-closure` :: exit 0 - `pnpm check:engine-double-contract` :: exit 0 - `pnpm check:error-status-conformance` :: exit 0 - `pnpm check:gitlink-declared` :: exit 0 - `pnpm check:issue-citations` :: exit 0 - `pnpm check:keyed-text-bounds` :: exit 0 - `pnpm check:lean-entry-closure` :: exit 0 - `pnpm check:logger-receiver-detach` :: exit 0 - `pnpm check:nul-bytes` :: exit 0 - `pnpm check:object-def-param-keys` :: exit 0 - `pnpm check:objectql-double-limit` :: exit 0 - `pnpm check:optional-error-sink` :: exit 0 - `pnpm check:org-identifier` :: exit 0 - `pnpm check:page-declaration-shape` :: exit 0 - `pnpm check:platform-object-tenancy-census` :: exit 0 - `pnpm check:published-files` :: exit 0 - `pnpm check:query-options-erasure` :: exit 0 - `pnpm check:refd-timer-probe` :: exit 0 - `pnpm check:registry-log-declared` :: exit 0 - `pnpm check:rest-log-spy-declared` :: exit 0 - `pnpm check:settings-bind-window` :: exit 0 - `pnpm check:slot-lookup` :: exit 0 - `pnpm check:sourcemap-no-sources-content` :: exit 0 - `pnpm check:system-context-census` :: exit 0 - `pnpm check:test-source-alias` :: exit 0 - `pnpm check:tier-file-adoption` :: exit 0 - `pnpm check:type-check-coverage` :: exit 0 - `pnpm check:type-check-debt` :: exit 0 - `pnpm check:undeclared-dep-imports` :: exit 0 - `pnpm check:vouch-for-reference-callers` :: exit 0 - `pnpm check:watch-hint-literal` :: exit 0 - `pnpm check:where-matcher` :: exit 0 - `pnpm check:dual-build-cjs-loads` :: exit 0 `node scripts/pm/dispatch-gates.mjs --ran`: `65 derived famil(ies) accounted for — 65 run, 0 NOT-MEASURED (a DERIVED zero — all 65 recorded an exit code and none of them is 3)`. `check:lean-entry-closure` first exited 3 (PREREQUISITE NOT MET: `@objectstack/objectql` dist missing) and exited 0 when re-run after `check:type-check-debt` had built every package; `check:dual-build-cjs-loads` was run last for the same reason and exited 0. `check-plugin-teardown-shape --self-test` ran after `git fetch --depth=1` of its pinned fixture commit. Each command ran with stdin from `/dev/null`, exit captured before any pipe. `eslint --no-inline-config packages/mcp`: exit 0. Not run locally (CI's): `test:repo`, the dogfood shards, the type-check lanes. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (Not touched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (eslint on the touched files.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Not touched.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5mdwfFCbsFQ62JJ8P5 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1f37085 commit 03533d2

4 files changed

Lines changed: 145 additions & 34 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@objectstack/mcp': patch
3+
---
4+
5+
Clause-②: no
6+
Metadata-protocol: none
7+
8+
The MCP record data tools (`query_records`, `aggregate_records`, `get_record`, `create_record`, `update_record`, `delete_record`, `delete_records`' selection read) and the `objectstack://objects/{name}/records/{id}` resource now answer a coded throw that declares a 5xx the way `/api/v1/data` does. The code and status are kept and the producer's sentence is withheld (`Internal server error`), unless the throw marks itself `refusal: true`; the thrown text is logged once on the server. They used to hand the agent the producer's own sentence. An unregistered code with a declared 5xx answers the status's closed code, as at the door, which names the producer's spelling only in `declaredCode`, a member no MCP face serves. A declared refusal status (below 500) keeps its coded answer unchanged.

‎packages/mcp/src/mcp-coded-refusal.test.ts‎

Lines changed: 93 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -693,15 +693,49 @@ describe('the record resource: a coded refusal adds its code beside the unchange
693693
expect(line).toContain('SQLITE_BUSY: database is locked');
694694
});
695695

696-
it('CONTROL: a declared 5xx code and a code the door refuses with no status keep their coded answers, unlogged', async () => {
696+
it('CONTROL: a code the door refuses with no status keeps its coded answer, unlogged', async () => {
697697
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
698-
expect(JSON.parse(await readRefused(Object.assign(new Error('The ledger is closing.'), { code: 'SERVICE_UNAVAILABLE', status: 503 }))))
699-
.toEqual({ error: 'The ledger is closing.', code: 'SERVICE_UNAVAILABLE', status: 503 });
700698
expect(JSON.parse(await readRefused(Object.assign(new Error('Field read denied: salary'), { code: 'PERMISSION_DENIED' }))))
701699
.toEqual({ error: 'Field read denied: salary', code: 'PERMISSION_DENIED' });
702700
expect(log).not.toHaveBeenCalled();
703701
});
704702
});
703+
704+
/**
705+
* [#31341] A coded throw that DECLARES a 5xx answers the door's answer here
706+
* too, through the same predicate the record data tools ask (their block,
707+
* below, says why). This row was #31273's CONTROL, which pinned the
708+
* producer's sentence served.
709+
*/
710+
describe('a declared 5xx answers the /data door\'s answer, its sentence withheld unless it is a refusal (#31341)', () => {
711+
afterEach(() => {
712+
vi.restoreAllMocks();
713+
});
714+
715+
it('SERVICE_UNAVAILABLE / 503: code and status kept, the sentence withheld and logged once', async () => {
716+
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
717+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
718+
const thrown = ledgerClosing();
719+
const text = await readRefused(thrown);
720+
expect(text).toBe(JSON.stringify({ error: 'Internal server error', code: 'SERVICE_UNAVAILABLE', status: 503 }));
721+
const door = mapDataError(thrown, 'task');
722+
expect(JSON.parse(text)).toEqual({ error: door.body.error, code: door.body.code, status: door.status });
723+
const lines = [...error.mock.calls, ...warn.mock.calls].map((call) => String(call[0]));
724+
expect(lines).toHaveLength(1);
725+
expect(lines[0]).toMatch(/^\[5xx\] 503 resources\/read objectstack:\/\/objects\/task\/records\/t1 — /);
726+
expect(lines[0]).toContain('The ledger is closing.');
727+
});
728+
729+
it('a declared 503 that marks itself `refusal: true` keeps its sentence, as the door serves it', async () => {
730+
vi.spyOn(console, 'error').mockImplementation(() => {});
731+
vi.spyOn(console, 'warn').mockImplementation(() => {});
732+
const thrown = ledgerClosing({ refusal: true });
733+
const text = await readRefused(thrown);
734+
expect(text).toBe(JSON.stringify({ error: 'The ledger is closing.', code: 'SERVICE_UNAVAILABLE', status: 503 }));
735+
const door = mapDataError(thrown, 'task');
736+
expect(JSON.parse(text)).toEqual({ error: door.body.error, code: door.body.code, status: door.status });
737+
});
738+
});
705739
});
706740

707741
/**
@@ -1026,6 +1060,11 @@ describe('the metadata tools and list_actions: a fault answers what the /meta do
10261060
* 2026-08-27 on #12509: the discriminator is the status channel, never the
10271061
* code's spelling). Then the face answers the door's fault.
10281062
*
1063+
* [#31341] A coded throw that DECLARES a 5xx is a server fault at the door
1064+
* too, so the faces now answer it the same way: the door's code and status,
1065+
* its sentence withheld unless the throw marks itself `refusal: true`. Its
1066+
* rows close this block; they replaced #31273's CONTROL for a declared 5xx.
1067+
*
10291068
* Only the record data tools, `delete_records`' selection read and the record
10301069
* resource (its rows are below). `run_action` / `resume_run` and the metadata
10311070
* tools answer a driver code through their own doors' readers (#31271,
@@ -1035,6 +1074,11 @@ function sqliteBusy(): Error {
10351074
return Object.assign(new Error('SQLITE_BUSY: database is locked'), { code: 'SQLITE_BUSY' });
10361075
}
10371076

1077+
/** [#31341] A producer's coded throw that declares a 5xx, with its own sentence. */
1078+
function ledgerClosing(extra: Record<string, unknown> = {}): Error {
1079+
return Object.assign(new Error('The ledger is closing.'), { code: 'SERVICE_UNAVAILABLE', status: 503 }, extra);
1080+
}
1081+
10381082
describe('the record data tools: a driver\'s own string code is the /data door\'s server fault, not a coded refusal (#31273)', () => {
10391083
const FACES = SITES.filter((s) => s.dataDoor || s.tool === 'delete_records');
10401084

@@ -1121,13 +1165,53 @@ describe('the record data tools: a driver\'s own string code is the /data door\'
11211165
expect(log).not.toHaveBeenCalled();
11221166
});
11231167

1124-
// A 5xx the producer DECLARED is not a driver's: its code is authored. The
1125-
// door withholds a declared 5xx's prose too; that is not this card's face.
1126-
it('CONTROL: a code with a declared 5xx keeps its coded answer', async () => {
1127-
const thrown = Object.assign(new Error('The ledger is closing.'), { code: 'SERVICE_UNAVAILABLE', status: 503 });
1168+
/**
1169+
* [#31341] A 5xx the producer DECLARED is a server fault at the door too:
1170+
* `mapDataError` keeps its code and status and withholds its sentence
1171+
* (`Internal server error`) unless the throw marks itself `refusal: true`
1172+
* (`declaredRefusalMessage`, `@objectstack/types`). These faces read the
1173+
* coded path first, so they served the producer's sentence verbatim. This
1174+
* row was #31273's CONTROL, which pinned that sentence served; it moved.
1175+
*/
1176+
for (const site of FACES) {
1177+
it(`${site.tool}: a declared 5xx keeps its code and status, its sentence withheld and logged once (#31341)`, async () => {
1178+
const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {});
1179+
const warnLog = vi.spyOn(console, 'warn').mockImplementation(() => {});
1180+
const thrown = ledgerClosing();
1181+
const { text, error } = await errorOf(site, thrown);
1182+
expect(error).toEqual({ code: 'SERVICE_UNAVAILABLE', message: 'Internal server error', status: 503 });
1183+
expect(error).toEqual(doorAnswer(thrown));
1184+
expect(text).not.toContain('The ledger is closing.');
1185+
const lines = [...errorLog.mock.calls, ...warnLog.mock.calls].map((call) => String(call[0]));
1186+
expect(lines).toHaveLength(1);
1187+
expect(lines[0]).toMatch(new RegExp(`^\\[5xx\\] 503 tools/call ${site.tool} task — `));
1188+
expect(lines[0]).toContain('The ledger is closing.');
1189+
});
1190+
}
1191+
1192+
it('a declared 5xx that marks itself `refusal: true` keeps its sentence on every face, as the door serves it (#31341)', async () => {
1193+
vi.spyOn(console, 'error').mockImplementation(() => {});
1194+
vi.spyOn(console, 'warn').mockImplementation(() => {});
1195+
const thrown = ledgerClosing({ refusal: true });
11281196
for (const site of FACES) {
1129-
expect((await errorOf(site, thrown)).error, site.tool)
1130-
.toEqual({ code: 'SERVICE_UNAVAILABLE', message: 'The ledger is closing.', status: 503 });
1197+
const { error } = await errorOf(site, thrown);
1198+
expect(error, site.tool).toEqual({ code: 'SERVICE_UNAVAILABLE', message: 'The ledger is closing.', status: 503 });
1199+
expect(error, site.tool).toEqual(doorAnswer(thrown));
11311200
}
11321201
});
1202+
1203+
// [#31341] The door demotes an unregistered code with a declared 5xx to the
1204+
// status's closed code and names the producer's spelling in `declaredCode`,
1205+
// a member these faces do not serve (as the metadata tools do not, #31272).
1206+
// So the face answers the closed code. A producer keeps its own code on the
1207+
// wire by registering it or by throwing a 4xx refusal.
1208+
it('an unregistered code with a declared 5xx answers the door\'s closed code, its sentence withheld (#31341)', async () => {
1209+
vi.spyOn(console, 'error').mockImplementation(() => {});
1210+
vi.spyOn(console, 'warn').mockImplementation(() => {});
1211+
const thrown = Object.assign(new Error('The vendor is down.'), { code: 'VENDOR_DOWN', status: 503 });
1212+
const { text, error } = await errorOf(update, thrown);
1213+
expect(error).toEqual({ code: 'SERVICE_UNAVAILABLE', message: 'Internal server error', status: 503 });
1214+
expect(error).toEqual(doorAnswer(thrown));
1215+
expect(text).not.toContain('The vendor is down.');
1216+
});
11331217
});

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

Lines changed: 40 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -575,23 +575,39 @@ export function dataDoorAnswerOf(err: unknown, objectName?: string): DataDoorAns
575575
}
576576

577577
/**
578-
* [#31273] {@link thrownRefusalEnvelope} for a face the `/api/v1/data` door
579-
* serves: the coded refusal, unless the door answers the throw as a server
580-
* fault whose status the producer never declared. That throw is a driver's own
581-
* errno on `.code` (`SQLITE_BUSY`, `ECONNRESET`, `40P01`), not a refusal, and
582-
* the face answers the door's fault instead: `500 DATABASE_ERROR` `Internal
583-
* data error` for `SQLITE_BUSY`, the errno and driver text withheld and logged.
584-
* It used to reach the agent as `{ code: 'SQLITE_BUSY', message: <driver
585-
* text> }` with no status.
586-
*
587-
* Both halves are the door's own reads, never a copy: {@link dataDoorAnswerOf}
588-
* (`mapDataError`) says "server fault", and `serverFaultProvenance`
589-
* (`@objectstack/types`) says "undeclared". The second is the 2026-08-27
590-
* ruling on #12509 that withholds a demoted errno at every door: the
591-
* discriminator is the status channel, never the code's spelling, so a
592-
* producer that spells a code and declares no status loses it on a 5xx, here
593-
* as at the door. A code the door refuses (`PERMISSION_DENIED` with no status
594-
* answers 403 there) and a code with a declared status keep the coded answer.
578+
* [#31273, #31341] {@link thrownRefusalEnvelope} for a face the `/api/v1/data`
579+
* door serves: the coded refusal, unless the door answers the throw as a
580+
* server fault. Then the face answers the door's fault instead
581+
* ({@link dataDoorAnswerOf}), logged once, which is {@link rowRefusalOf}'s
582+
* line drawn with the same reader.
583+
*
584+
* Two kinds of coded throw are faults at the door:
585+
*
586+
* - a driver's own errno on `.code` (`SQLITE_BUSY`, `ECONNRESET`, `40P01`),
587+
* with no declared status (#31273). `SQLITE_BUSY` answers `500
588+
* DATABASE_ERROR` `Internal data error`, the errno and driver text withheld.
589+
* It used to reach the agent as `{ code: 'SQLITE_BUSY', message: <driver
590+
* text> }` with no status. The ruling of 2026-08-27 on #12509 says why the
591+
* code goes: the discriminator is the status channel, never the code's
592+
* spelling, so a producer that spells a code and declares no status loses
593+
* it on a 5xx, here as at the door;
594+
* - a code with a DECLARED 5xx (#31341), such as `{ code:
595+
* 'SERVICE_UNAVAILABLE', status: 503 }`. The door keeps the code and status
596+
* and withholds the producer's sentence (`Internal server error`) unless
597+
* the throw marks itself `refusal: true` (`declaredRefusalMessage`,
598+
* `@objectstack/types`). It used to reach the agent with its own sentence.
599+
* An unregistered code is demoted there to the status's closed code, with
600+
* the producer's spelling in a `declaredCode` member that no MCP face
601+
* serves; the face answers the closed code.
602+
*
603+
* ⛔ One reader, never a copy of the withhold rule: the door's answer is
604+
* `mapDataError`'s, through {@link dataDoorAnswerOf}, and the fault line is
605+
* the doors' own `isServerFault`. The question is asked only of a throw whose
606+
* status is a 5xx or undeclared (`serverFaultProvenance`, `@objectstack/types`,
607+
* answers `'declared'` or `'undeclared'`): a producer that DECLARED a refusal
608+
* status (below 500) keeps its coded answer byte for byte, as #31246 pins it,
609+
* and so does a code the door refuses (`PERMISSION_DENIED` with no status
610+
* answers 403 there).
595611
*
596612
* Read by the six record data tools ({@link dataDoorErrorResult}),
597613
* `delete_records`' selection read and the record resource
@@ -601,7 +617,7 @@ export function dataDoorAnswerOf(err: unknown, objectName?: string): DataDoorAns
601617
export function dataDoorRefusalEnvelope(err: unknown, objectName: string): ThrownRefusalEnvelope | undefined {
602618
const envelope = thrownRefusalEnvelope(err);
603619
if (envelope === undefined) return undefined;
604-
if (serverFaultProvenance(resolveThrownHttpError(err)) !== 'undeclared') return envelope;
620+
if (serverFaultProvenance(resolveThrownHttpError(err)) === undefined) return envelope;
605621
return isServerFault(dataDoorAnswerOf(err, objectName).status) ? undefined : envelope;
606622
}
607623

@@ -631,7 +647,8 @@ export function dataDoorRefusalEnvelope(err: unknown, objectName: string): Throw
631647
*
632648
* ⛔ The coded path is read FIRST and is unchanged: a thrown `code` keeps
633649
* {@link thrownRefusalEnvelope}'s rendering byte for byte, its unique-violation
634-
* spelling included. [#31273] One exception, a driver's own code: see
650+
* spelling included. [#31273, #31341] Except where the door answers the coded
651+
* throw as a server fault (a driver's own code, a declared 5xx): see
635652
* {@link dataDoorRefusalEnvelope}.
636653
*
637654
* Only these six tools. `delete_records` groups its rows through the same door
@@ -2406,9 +2423,10 @@ export function registerObjectTools(
24062423
}
24072424
return textResult(outcome);
24082425
} catch (err) {
2409-
// [#31273] A driver's own code from the selection read answers the
2410-
// door's fault, as the record data tools answer it. Every other
2411-
// throw keeps this catch's rendering (#31218 says why).
2426+
// [#31273, #31341] A coded throw from the selection read that the
2427+
// door answers as a fault (a driver's own code, a declared 5xx)
2428+
// answers the door's fault, as the record data tools answer it.
2429+
// Every other throw keeps this catch's rendering (#31218 says why).
24122430
if (thrownRefusalEnvelope(err) !== undefined && dataDoorRefusalEnvelope(err, objectName) === undefined) {
24132431
return dataDoorErrorResult(err, DELETE_RECORDS_TOOL, objectName);
24142432
}

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1577,9 +1577,10 @@ export class MCPServerRuntime {
15771577
// sentence; a fault (a bare `Error`, a crash) is the sanitised 500,
15781578
// its thrown text withheld from the client and logged once, as the
15791579
// door logs it.
1580-
// [#31273] …and so does a driver's own code (`SQLITE_BUSY`), which
1581-
// the door answers as a fault, not a refusal
1582-
// (`dataDoorRefusalEnvelope`).
1580+
// [#31273, #31341] …and so does a coded throw the door answers as a
1581+
// fault, not a refusal (`dataDoorRefusalEnvelope`): a driver's own
1582+
// code (`SQLITE_BUSY`), or a declared 5xx, whose code and status
1583+
// are kept and whose sentence is withheld unless `refusal: true`.
15831584
const uri = `objectstack://objects/${objectName}/records/${recordId}`;
15841585
const envelope = dataDoorRefusalEnvelope(err, objectName);
15851586
let body: { error: string; code?: string; status?: number };

0 commit comments

Comments
 (0)