Skip to content

Commit e6a0b72

Browse files
fix(mcp): record data faces serve the /data door's declaredCode and userMessage (#31405)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X1tF2sadne3zXMJh3i9wcbN)_ Fixes #31373 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:MCP 的六个记录数据工具、`delete_records` 的选择读取和记录资源,在按 `/api/v1/data` 门的回答作答时,把门给出的 `declaredCode` 和 `userMessage` 也一并带上(排在 `status` 之后,门没给就不出现),和 `run_action` / `resume_run` 的写法一致。 - 为什么改:#31341 之后,应用自己未注册的码配 5xx(例如 `VENDOR_DOWN` / 503)在这些面上只剩封闭码 `SERVICE_UNAVAILABLE`,`/data` 门却还在 `declaredCode` 里给出 `VENDOR_DOWN`;生产者标注的 `userMessage` 也被丢掉。智能体拿到的比 REST 门少。 - 风险与回滚:只改 `@objectstack/mcp`,patch。两个成员都从门的回答体里读,不另写降级规则。声明了 4xx 以下状态的带码拒绝不走门的回答,逐字节不变;元数据工具和 `list_actions` 的回答不变(新增 CONTROL 钉住)。回滚即还原本 PR。 - 先看:`packages/mcp/src/mcp-http-tools.ts` 的 `dataDoorAnswerOf` 和 `metaDoorErrorResult` 的返回行;`mcp-server-runtime.ts` 记录资源的 catch。 - 两分钟验证:`pnpm --filter @objectstack/mcp exec vitest run src/mcp-coded-refusal.test.ts -t 31373`,预期 14 个用例全部通过。 - 需要你做的:无,席位落地。 ## What changes `dataDoorAnswerOf` (`packages/mcp/src/mcp-http-tools.ts`) reads the `/api/v1/data` door's answer (`mapDataError`). It now also reads the door body's `declaredCode` and `userMessage`, each only when the door serves a non-empty string. No second demotion rule: the values are the door's. - The six record data tools (`dataDoorErrorResult`) and `delete_records`' selection read render the door answer as before, so the two members now ride after `status`: `{ error: { code, message, status, declaredCode?, userMessage? } }`. That is `run_action` / `resume_run`'s rendering (#31271, `errorResultFromThrown`): same names, same order, omitted when absent. - The record resource (`mcp-server-runtime.ts`, `bridgeResources`) adds them after `status` in its `{ error, code, status }` body. - `metaDoorErrorResult` (metadata tools, `list_actions`) shares the reader, so it now renders `code`, `message` and `status` explicitly. Its answer is unchanged, and a new CONTROL pins that. No export added; `dataDoorAnswerOf` is not on `packages/mcp/src/index.ts`, and the `@objectstack/types` import line is unchanged. ### Measured on `main` (`03533d2c`): which throws reach the door-answer path with these members `mapDataError(v, 'task')` on the built `@objectstack/types`, and the path the faces take (door answer vs the coded envelope, per `dataDoorRefusalEnvelope`): | Throw | Path | `/data` answer | |---|---|---| | `VENDOR_DOWN` / 503 | door | 503 `SERVICE_UNAVAILABLE`, `declaredCode: VENDOR_DOWN` | | `VENDOR_DOWN` / 503, `refusal: true` | door | same, plus the sentence | | `MY_FAULT` / 500 | door | 500 `INTERNAL_ERROR`, `declaredCode: MY_FAULT` | | any door-path throw with `userMessage` (coded 5xx, `SQLITE_BUSY`, bare `Error`, code-less `{ status: 409 }`, code-less sandboxed abort) | door | the door's answer plus `userMessage` | | `SERVICE_UNAVAILABLE` / 503, `SQLITE_BUSY`, `VENDOR_DOWN` with no status, bare `Error`, code-less statuses, sandboxed abort or crash | door | no `declaredCode` | | `LEAVE_EXHAUSTED` / 409 (declared below 500) | envelope | 409 `RESOURCE_CONFLICT`, `declaredCode: LEAVE_EXHAUSTED` (out of scope, see below) | | `PERMISSION_DENIED` (no status) with `userMessage` | envelope | 403, plus `userMessage` (out of scope, see below) | So `declaredCode` reaches these faces for an unregistered code with a declared 5xx, and `userMessage` for any door-path throw that carries one. A code-less throw never gets a `declaredCode` from the door (there is no code to demote). ## Tests `packages/mcp/src/mcp-coded-refusal.test.ts`: - Record data tools (#31273 block): #31341's `VENDOR_DOWN` / 503 row on `update_record` **moved** and became one row per face (7): exactly `{ code: 'SERVICE_UNAVAILABLE', message: 'Internal server error', status: 503, declaredCode: 'VENDOR_DOWN' }`, key order pinned, equal to the door's answer, the sentence absent, one `[5xx] 503 tools/call TOOL vendor_task — ` operator line with the sentence. The rows read the object `vendor_task` so their line is the first `SERVICE_UNAVAILABLE` capability-absence report on its route (`logServerFault` reports that family once per route per process, as at the door). - `VENDOR_DOWN` / 503 with `userMessage` on every face: both members, in `run_action`'s order, equal to the door's. - A code-less fault and a code-less sandboxed abort with `userMessage` on the six tools: the door's `500 INTERNAL_ERROR` and `400 HOOK_ABORTED`, each with `userMessage`. `delete_records`' catch keeps its own rendering for a code-less throw (#31246), so it is not in this row. - CONTROL: `SERVICE_UNAVAILABLE` / 503 carries no `declaredCode` and no `userMessage` on any face. - Record resource, new #31373 block: the `VENDOR_DOWN` body with `declaredCode`, logged once on a fresh record URI; `userMessage` after `declaredCode`, and after `status` on a code-less fault; CONTROL for `SERVICE_UNAVAILABLE` / 503. - Metadata tools (#31272 block): CONTROL, `VENDOR_DOWN` / 503 with `userMessage` keeps `{ code: 'SERVICE_UNAVAILABLE', message: 'Internal server error', status: 503 }` on all four. - Unchanged and green: `RECORD_LOCKED` / 409 and the coded sandboxed refusal on every face and on the resource, #31246's byte-for-byte CONTROL. <details><summary>Red first and ablations</summary> Red first on `03533d2c` (`main`), the new pins in place and no fix: `mcp-coded-refusal.test.ts` gave `Tests 11 failed | 154 passed (165)`: the 7 per-face `VENDOR_DOWN` rows, the two `userMessage` tool rows, and the two resource rows. Every CONTROL passed. The metadata CONTROL, added after, passes on `main` and with the fix (`166 passed`). Ablations via `node scripts/ablation-replace.mjs` on the committed head; each printed `ok mutation landed` and then `ok restored: blob == HEAD (31cfe90) and git diff HEAD is empty`: 1. The `declaredCode` member deleted from `dataDoorAnswerOf`. Blob `31cfe9000bac -> 9d6f5ee51eae`, `Tests 10 failed | 156 passed (166)`: the 7 per-face rows, the tool `userMessage` row (it asserts both members), and the two resource rows. 2. The `userMessage` member deleted. Blob `31cfe9000bac -> f5974c669fdb`, `Tests 3 failed | 163 passed (166)`: the two tool `userMessage` rows and the resource `userMessage` row. 3. `metaDoorErrorResult` put back to `codedErrorResult(door)`. Blob `31cfe9000bac -> 06f1b65913eb`, `Tests 1 failed | 165 passed (166)`: the metadata CONTROL. A first attempt at ablations 1 and 2 used an empty `--replacement`, which the tool refused (`replacement count moved 0 -> 0`) and restored; they were re-run with `--delete`. The tests import the package's source, so no `dist/` is involved. </details> ## Acceptance notes - **Out of scope, measured:** a coded throw with a declared status below 500 keeps its coded envelope here (#31246), so an unregistered `LEAVE_EXHAUSTED` / 409 answers `code: LEAVE_EXHAUSTED` on these faces where `/data` answers `RESOURCE_CONFLICT` with `declaredCode: LEAVE_EXHAUSTED`. A status-less code the door refuses (`PERMISSION_DENIED`) also renders the envelope, which drops a `userMessage` the door serves. Neither is changed, per the order. - **`delete_records`' catch** renders a code-less throw as its bare message (#31218, #31246), so a code-less `userMessage` does not reach that face; only its coded door-path throws do. - **The metadata tools** read the same reader with no object. They keep their answer; whether `/meta` serves these members is not measured here. - **`Clause-②: no`, re-declared from measurement.** No export added or changed. The MCP tool error already declares `declaredCode` and `userMessage` (`codedErrorResult`), and `run_action` serves them; these faces now serve them where the door does. - **Serial constraint:** #31330 (action path) may touch `mcp-coded-refusal.test.ts`; whichever lands second merges `origin/main` once. ## Verification Base `03533d2c`; head `2fbd1084`. - `pnpm --filter @objectstack/mcp test` (vitest run, `--maxWorkers=2`): `Test Files 60 passed (60)`, `Tests 895 passed (895)`. - `pnpm --filter @objectstack/mcp typecheck`: exit 0 (`check:test-typecheck`: 6 files / 53 errors / 8 pinned signatures held). - `eslint --no-inline-config packages/mcp`: exit 0. `@objectstack/mcp` has no `lint` script. <details><summary>Derived gates (node scripts/pm/dispatch-gates.mjs --commands, 65 commands, all on 2fbd108)</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: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:lean-entry-closure` :: 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)`. Every command exited 0 on its first run; `check:lean-entry-closure` and `check:dual-build-cjs-loads` were run last, after `check:type-check-debt` had built every package, and `check-plugin-teardown-shape --self-test` after `git fetch --depth=1` of its pinned fixture commit. Each command ran with stdin from `/dev/null`, exit captured before any pipe. 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 package.) - [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 d443658 commit e6a0b72

4 files changed

Lines changed: 225 additions & 20 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 serve the `/api/v1/data` door's `declaredCode` and `userMessage` whenever they answer a throw with that door's answer. An app's own unregistered code with a declared 5xx, such as `{ code: 'VENDOR_DOWN', status: 503 }`, answers `SERVICE_UNAVAILABLE` / 503 with `declaredCode: 'VENDOR_DOWN'`, as `/data` does; since the previous release it answered the closed code alone. A producer's `userMessage` rides the answer after `status`, as `run_action` and `resume_run` render it. Each member is omitted when the door serves none. A coded throw with a declared status below 500, and the metadata tools and `list_actions`, answer as before.

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

Lines changed: 176 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -520,7 +520,7 @@ describe('the record resource: a coded refusal adds its code beside the unchange
520520
const URI = 'objectstack://objects/task/records/t1';
521521

522522
/** Read the record resource of a server whose reader throws `thrown`; answers the raw body text. */
523-
async function readRefused(thrown: unknown): Promise<string> {
523+
async function readRefused(thrown: unknown, uri: string = URI): Promise<string> {
524524
const runtime = new MCPServerRuntime({ name: 'pin', version: '0.0.0' });
525525
const metadata = {
526526
listObjects: vi.fn(async () => []),
@@ -534,9 +534,9 @@ describe('the record resource: a coded refusal adds its code beside the unchange
534534
const client = new Client({ name: 'pin', version: '0.0.0' });
535535
await client.connect(clientSide);
536536
try {
537-
const read = await client.readResource({ uri: URI });
537+
const read = await client.readResource({ uri });
538538
expect(read.contents).toHaveLength(1);
539-
expect(read.contents[0]!.uri).toBe(URI);
539+
expect(read.contents[0]!.uri).toBe(uri);
540540
return String((read.contents[0] as { text?: unknown }).text);
541541
} finally {
542542
await client.close();
@@ -736,6 +736,68 @@ describe('the record resource: a coded refusal adds its code beside the unchange
736736
expect(JSON.parse(text)).toEqual({ error: door.body.error, code: door.body.code, status: door.status });
737737
});
738738
});
739+
740+
/**
741+
* [#31373] The door's `declaredCode` and `userMessage` ride the resource's
742+
* door answer too, after `status`, each omitted when the door serves none.
743+
* The `VENDOR_DOWN` row reads a fresh record URI so its operator line is the
744+
* first capability-absence report on that route.
745+
*/
746+
describe('the door\'s declaredCode and userMessage ride the door answer (#31373)', () => {
747+
const VENDOR_URI = 'objectstack://objects/vendor_task/records/t1';
748+
749+
afterEach(() => {
750+
vi.restoreAllMocks();
751+
});
752+
753+
/** The door's answer to `thrown`, in this resource's member order. */
754+
function doorBody(thrown: unknown): Record<string, unknown> {
755+
const door = mapDataError(thrown, 'task');
756+
return {
757+
error: door.body.error,
758+
...(typeof door.body.code === 'string' ? { code: door.body.code } : {}),
759+
status: door.status,
760+
...(door.body.declaredCode !== undefined ? { declaredCode: door.body.declaredCode } : {}),
761+
...(door.body.userMessage !== undefined ? { userMessage: door.body.userMessage } : {}),
762+
};
763+
}
764+
765+
it('an unregistered code with a declared 5xx: the closed code and its declaredCode, the sentence withheld and logged once', async () => {
766+
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
767+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
768+
const thrown = vendorDown();
769+
const text = await readRefused(thrown, VENDOR_URI);
770+
expect(text).toBe(JSON.stringify({ error: 'Internal server error', code: 'SERVICE_UNAVAILABLE', status: 503, declaredCode: 'VENDOR_DOWN' }));
771+
expect(JSON.parse(text)).toEqual(doorBody(thrown));
772+
const lines = [...error.mock.calls, ...warn.mock.calls].map((call) => String(call[0]));
773+
expect(lines).toHaveLength(1);
774+
expect(lines[0]).toMatch(/^\[5xx\] 503 resources\/read objectstack:\/\/objects\/vendor_task\/records\/t1 — /);
775+
expect(lines[0]).toContain('The vendor is down.');
776+
});
777+
778+
it('a userMessage rides after declaredCode, and on a code-less fault after status', async () => {
779+
vi.spyOn(console, 'error').mockImplementation(() => {});
780+
vi.spyOn(console, 'warn').mockImplementation(() => {});
781+
const coded = vendorDown({ userMessage: 'Payments are paused for a minute.' });
782+
expect(await readRefused(coded)).toBe(JSON.stringify({
783+
error: 'Internal server error', code: 'SERVICE_UNAVAILABLE', status: 503, declaredCode: 'VENDOR_DOWN', userMessage: 'Payments are paused for a minute.',
784+
}));
785+
expect(JSON.parse(await readRefused(coded))).toEqual(doorBody(coded));
786+
const fault = Object.assign(new Error('socket hang up'), { userMessage: 'Please retry in a moment.' });
787+
expect(await readRefused(fault)).toBe(JSON.stringify({
788+
error: 'Internal server error', code: 'INTERNAL_ERROR', status: 500, userMessage: 'Please retry in a moment.',
789+
}));
790+
expect(JSON.parse(await readRefused(fault))).toEqual(doorBody(fault));
791+
});
792+
793+
it('CONTROL: a registered code with a declared 5xx carries no declaredCode', async () => {
794+
vi.spyOn(console, 'error').mockImplementation(() => {});
795+
vi.spyOn(console, 'warn').mockImplementation(() => {});
796+
expect(await readRefused(ledgerClosing())).toBe(
797+
JSON.stringify({ error: 'Internal server error', code: 'SERVICE_UNAVAILABLE', status: 503 }),
798+
);
799+
});
800+
});
739801
});
740802

741803
/**
@@ -1042,6 +1104,21 @@ describe('the metadata tools and list_actions: a fault answers what the /meta do
10421104
expect(error, site.tool).toEqual(metaDoorAnswer(thrown));
10431105
}
10441106
});
1107+
1108+
// [#31373] The record data faces now serve the `/data` door's `declaredCode`
1109+
// and `userMessage` off the reader these tools share. This card does not
1110+
// move these tools' answer: the closed code, sentence and status only.
1111+
it('CONTROL: an unregistered code with a declared 5xx and a userMessage keeps these tools\' answer (#31373)', async () => {
1112+
vi.spyOn(console, 'error').mockImplementation(() => {});
1113+
vi.spyOn(console, 'warn').mockImplementation(() => {});
1114+
const thrown = Object.assign(new Error('The vendor is down.'), {
1115+
code: 'VENDOR_DOWN', status: 503, userMessage: 'Payments are paused for a minute.',
1116+
});
1117+
for (const site of META_SITES) {
1118+
const { error } = await errorOf(site, thrown);
1119+
expect(error, site.tool).toEqual({ code: 'SERVICE_UNAVAILABLE', message: 'Internal server error', status: 503 });
1120+
}
1121+
});
10451122
});
10461123

10471124
/**
@@ -1078,6 +1155,11 @@ function ledgerClosing(extra: Record<string, unknown> = {}): Error {
10781155
return Object.assign(new Error('The ledger is closing.'), { code: 'SERVICE_UNAVAILABLE', status: 503 }, extra);
10791156
}
10801157

1158+
/** [#31373] An app's own, unregistered code with a declared 5xx, with its own sentence. */
1159+
function vendorDown(extra: Record<string, unknown> = {}): Error {
1160+
return Object.assign(new Error('The vendor is down.'), { code: 'VENDOR_DOWN', status: 503 }, extra);
1161+
}
1162+
10811163
describe('the record data tools: a driver\'s own string code is the /data door\'s server fault, not a coded refusal (#31273)', () => {
10821164
const FACES = SITES.filter((s) => s.dataDoor || s.tool === 'delete_records');
10831165

@@ -1199,18 +1281,98 @@ describe('the record data tools: a driver\'s own string code is the /data door\'
11991281
}
12001282
});
12011283

1202-
// [#31341] The door demotes an unregistered code with a declared 5xx to the
1203-
// status's closed code and names the producer's spelling in `declaredCode`,
1204-
// a member these faces do not serve (as the metadata tools do not, #31272).
1205-
// So the face answers the closed code. A producer keeps its own code on the
1206-
// wire by registering it or by throwing a 4xx refusal.
1207-
it('an unregistered code with a declared 5xx answers the door\'s closed code, its sentence withheld (#31341)', async () => {
1284+
/**
1285+
* [#31373] The door demotes an unregistered code with a declared 5xx to the
1286+
* status's closed code and keeps the producer's spelling in `declaredCode`;
1287+
* it rides a producer's `userMessage` onto whatever it answers. These faces
1288+
* render the door's answer, so they serve both members, read off the same
1289+
* answer (`dataDoorAnswerOf`), in `run_action`'s order (#31271): `code`,
1290+
* `message`, `status`, `declaredCode`, `userMessage`, each omitted when the
1291+
* door serves none. Since #31341 the `VENDOR_DOWN` row answered the closed
1292+
* code alone; it moved. The rows read a fresh object, `vendor_task`, so
1293+
* the operator line is the first `SERVICE_UNAVAILABLE` capability-absence
1294+
* report on its route (`logServerFault` reports that family once per route
1295+
* per process, as at the door).
1296+
*/
1297+
const onVendorTask = (site: Site): Site => ({ ...site, args: { ...site.args, objectName: 'vendor_task' } });
1298+
1299+
/** The `/data` door's answer to `thrown`, with the members it serves beside the code. */
1300+
function doorWire(thrown: unknown): Record<string, unknown> {
1301+
const door = mapDataError(thrown, 'task');
1302+
return {
1303+
...doorAnswer(thrown),
1304+
...(door.body.declaredCode !== undefined ? { declaredCode: door.body.declaredCode } : {}),
1305+
...(door.body.userMessage !== undefined ? { userMessage: door.body.userMessage } : {}),
1306+
};
1307+
}
1308+
1309+
for (const site of FACES) {
1310+
it(`${site.tool}: an unregistered code with a declared 5xx answers the closed code and its declaredCode, the sentence withheld and logged once (#31373)`, async () => {
1311+
const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {});
1312+
const warnLog = vi.spyOn(console, 'warn').mockImplementation(() => {});
1313+
const thrown = vendorDown();
1314+
const { text, error } = await errorOf(onVendorTask(site), thrown);
1315+
expect(error).toEqual({ code: 'SERVICE_UNAVAILABLE', message: 'Internal server error', status: 503, declaredCode: 'VENDOR_DOWN' });
1316+
expect(Object.keys(error)).toEqual(['code', 'message', 'status', 'declaredCode']);
1317+
expect(error).toEqual(doorWire(thrown));
1318+
expect(text).not.toContain('The vendor is down.');
1319+
const lines = [...errorLog.mock.calls, ...warnLog.mock.calls].map((call) => String(call[0]));
1320+
expect(lines).toHaveLength(1);
1321+
expect(lines[0]).toMatch(new RegExp(`^\\[5xx\\] 503 tools/call ${site.tool} vendor_task — `));
1322+
expect(lines[0]).toContain('The vendor is down.');
1323+
});
1324+
}
1325+
1326+
it('a declared 5xx carrying a userMessage serves it after declaredCode on every face, as the door does (#31373)', async () => {
12081327
vi.spyOn(console, 'error').mockImplementation(() => {});
12091328
vi.spyOn(console, 'warn').mockImplementation(() => {});
1210-
const thrown = Object.assign(new Error('The vendor is down.'), { code: 'VENDOR_DOWN', status: 503 });
1211-
const { text, error } = await errorOf(update, thrown);
1212-
expect(error).toEqual({ code: 'SERVICE_UNAVAILABLE', message: 'Internal server error', status: 503 });
1213-
expect(error).toEqual(doorAnswer(thrown));
1214-
expect(text).not.toContain('The vendor is down.');
1329+
const thrown = vendorDown({ userMessage: 'Payments are paused for a minute.' });
1330+
for (const site of FACES) {
1331+
const { error } = await errorOf(site, thrown);
1332+
expect(error, site.tool).toEqual({
1333+
code: 'SERVICE_UNAVAILABLE',
1334+
message: 'Internal server error',
1335+
status: 503,
1336+
declaredCode: 'VENDOR_DOWN',
1337+
userMessage: 'Payments are paused for a minute.',
1338+
});
1339+
expect(Object.keys(error), site.tool).toEqual(['code', 'message', 'status', 'declaredCode', 'userMessage']);
1340+
expect(error, site.tool).toEqual(doorWire(thrown));
1341+
}
1342+
});
1343+
1344+
// A code-less throw reaches the door's answer on the six tools (#31218);
1345+
// `delete_records`' catch keeps its own rendering for one (#31246).
1346+
it('a code-less throw carrying a userMessage serves it on the six record data tools, as the door does (#31373)', async () => {
1347+
vi.spyOn(console, 'error').mockImplementation(() => {});
1348+
const fault = Object.assign(new Error('socket hang up'), { userMessage: 'Please retry in a moment.' });
1349+
const abort = Object.assign(new Error("hook 'demo_leave_guard' threw: Error: Not enough leave"), {
1350+
name: 'SandboxError',
1351+
innerMessage: 'Not enough leave',
1352+
userMessage: 'Your leave balance is too low.',
1353+
});
1354+
for (const site of FACES.filter((s) => s.tool !== 'delete_records')) {
1355+
const faulted = (await errorOf(site, fault)).error;
1356+
expect(faulted, site.tool).toEqual({
1357+
code: 'INTERNAL_ERROR', message: 'Internal server error', status: 500, userMessage: 'Please retry in a moment.',
1358+
});
1359+
expect(faulted, site.tool).toEqual(doorWire(fault));
1360+
const aborted = (await errorOf(site, abort)).error;
1361+
expect(aborted, site.tool).toEqual({
1362+
code: 'HOOK_ABORTED', message: 'Not enough leave', status: 400, userMessage: 'Your leave balance is too low.',
1363+
});
1364+
expect(aborted, site.tool).toEqual(doorWire(abort));
1365+
}
1366+
});
1367+
1368+
it('CONTROL: a registered code with a declared 5xx carries no declaredCode on any face (#31373)', async () => {
1369+
vi.spyOn(console, 'error').mockImplementation(() => {});
1370+
vi.spyOn(console, 'warn').mockImplementation(() => {});
1371+
for (const site of FACES) {
1372+
const { error } = await errorOf(site, ledgerClosing());
1373+
expect(error, site.tool).toEqual({ code: 'SERVICE_UNAVAILABLE', message: 'Internal server error', status: 503 });
1374+
expect('declaredCode' in error, site.tool).toBe(false);
1375+
expect('userMessage' in error, site.tool).toBe(false);
1376+
}
12151377
});
12161378
});

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

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,10 @@ interface DataDoorAnswer {
548548
code?: string;
549549
message: string;
550550
status: number;
551+
/** [#31373] The producer's unregistered spelling the door demoted, when it serves one. */
552+
declaredCode?: string;
553+
/** [#31373] The producer's marked end-user sentence the door rides onto its answer, when it serves one. */
554+
userMessage?: string;
551555
}
552556

553557
/**
@@ -563,14 +567,30 @@ interface DataDoorAnswer {
563567
* other context keys the door adds are not read, as `details` is not
564568
* ({@link errorResultWithCode}).
565569
*
570+
* [#31373] `declaredCode` and `userMessage` are read as the door serves them,
571+
* each omitted when it serves none: an unregistered code the door demoted to
572+
* its closed code (`{ code: 'VENDOR_DOWN', status: 503 }` answers
573+
* `SERVICE_UNAVAILABLE` with `declaredCode: 'VENDOR_DOWN'`), and a producer's
574+
* marked end-user sentence, which the door rides onto any answer, a fault's
575+
* included. ⛔ Read off the door's body, never a second demotion rule. The
576+
* record data faces render them in `run_action`'s order ({@link
577+
* errorResultFromThrown}); the metadata tools do not ({@link metaDoorErrorResult}).
578+
*
566579
* [#31270] Exported for the record resource (`MCPServerRuntime.bridgeResources`),
567580
* which answers a code-less throw with the same read; not on the package entry.
568581
*/
569582
export function dataDoorAnswerOf(err: unknown, objectName?: string): DataDoorAnswer {
570583
const door = mapDataError(err, objectName);
571584
const code = typeof door.body.code === 'string' && door.body.code !== '' ? door.body.code : undefined;
572585
const message = typeof door.body.error === 'string' && door.body.error !== '' ? door.body.error : messageOf(err);
573-
return { ...(code !== undefined ? { code } : {}), message, status: door.status };
586+
const { declaredCode, userMessage } = door.body;
587+
return {
588+
...(code !== undefined ? { code } : {}),
589+
message,
590+
status: door.status,
591+
...(typeof declaredCode === 'string' && declaredCode !== '' ? { declaredCode } : {}),
592+
...(typeof userMessage === 'string' && userMessage !== '' ? { userMessage } : {}),
593+
};
574594
}
575595

576596
/**
@@ -596,8 +616,8 @@ export function dataDoorAnswerOf(err: unknown, objectName?: string): DataDoorAns
596616
* the throw marks itself `refusal: true` (`declaredRefusalMessage`,
597617
* `@objectstack/types`). It used to reach the agent with its own sentence.
598618
* An unregistered code is demoted there to the status's closed code, with
599-
* the producer's spelling in a `declaredCode` member that no MCP face
600-
* serves; the face answers the closed code.
619+
* the producer's spelling in `declaredCode`; the face answers both (#31373,
620+
* {@link dataDoorAnswerOf}).
601621
*
602622
* ⛔ One reader, never a copy of the withhold rule: the door's answer is
603623
* `mapDataError`'s, through {@link dataDoorAnswerOf}, and the fault line is
@@ -644,6 +664,11 @@ export function dataDoorRefusalEnvelope(err: unknown, objectName: string): Throw
644664
* status, and no code, since the door invents none. A declared 5xx's prose is
645665
* withheld there too.
646666
*
667+
* [#31373] The door's `declaredCode` and `userMessage` ride after `status`
668+
* when it serves them, as `run_action` renders `/actions`' ({@link
669+
* errorResultFromThrown}): `{ error: { code, message, status, declaredCode?,
670+
* userMessage? } }`.
671+
*
647672
* ⛔ The coded path is read FIRST and is unchanged: a thrown `code` keeps
648673
* {@link thrownRefusalEnvelope}'s rendering byte for byte, its unique-violation
649674
* spelling included. [#31273, #31341] Except where the door answers the coded
@@ -713,7 +738,9 @@ function metaDoorErrorResult(err: unknown, tool: MetaDoorTool, objectName?: stri
713738
...(door.code !== undefined ? { code: door.code } : {}),
714739
request: { method: 'tools/call', path: objectName !== undefined ? `${tool} ${objectName}` : tool },
715740
});
716-
return codedErrorResult(door);
741+
// [#31373] The `/data` door's `declaredCode` / `userMessage` are the record
742+
// data faces' members; this answer is unchanged by them.
743+
return codedErrorResult({ ...(door.code !== undefined ? { code: door.code } : {}), message: door.message, status: door.status });
717744
}
718745

719746
/**

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1583,7 +1583,7 @@ export class MCPServerRuntime {
15831583
// are kept and whose sentence is withheld unless `refusal: true`.
15841584
const uri = `objectstack://objects/${objectName}/records/${recordId}`;
15851585
const envelope = dataDoorRefusalEnvelope(err, objectName);
1586-
let body: { error: string; code?: string; status?: number };
1586+
let body: { error: string; code?: string; status?: number; declaredCode?: string; userMessage?: string };
15871587
if (envelope) {
15881588
body = {
15891589
error: envelope.message,
@@ -1600,7 +1600,15 @@ export class MCPServerRuntime {
16001600
request: { method: 'resources/read', path: uri },
16011601
});
16021602
}
1603-
body = { error: door.message, ...(door.code !== undefined ? { code: door.code } : {}), status: door.status };
1603+
// [#31373] …and the door's `declaredCode` / `userMessage`, after
1604+
// `status`, when it serves them, as the record data tools do.
1605+
body = {
1606+
error: door.message,
1607+
...(door.code !== undefined ? { code: door.code } : {}),
1608+
status: door.status,
1609+
...(door.declaredCode !== undefined ? { declaredCode: door.declaredCode } : {}),
1610+
...(door.userMessage !== undefined ? { userMessage: door.userMessage } : {}),
1611+
};
16041612
}
16051613
return {
16061614
contents: [{

0 commit comments

Comments
 (0)