Skip to content

Commit 45540d5

Browse files
fix(mcp): the record data tools and the record resource answer a driver's own error code (SQLITE_BUSY) as /api/v1/data does, never the driver's text (#31324)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X3apZSKwFDd6zvjM4gJPKAy)_ Fixes #31273 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:MCP 的六个记录数据工具(`query_records`、`aggregate_records`、`get_record`、`create_record`、`update_record`、`delete_record`)、`delete_records` 的选择读取和记录资源,遇到驱动自带的错误码(如 `SQLITE_BUSY`)时,改按 `/api/v1/data` 的判定作答:`500 DATABASE_ERROR` `Internal data error`,驱动原文和错误码只记服务端日志一次。 - 为什么改:之前这些面先走"带码即拒绝"的路径,智能体拿到 `{ code: 'SQLITE_BUSY', message: 'SQLITE_BUSY: database is locked' }` 且没有 status;`/data` 门对同一异常判为服务端故障并隐去原文。 - 判定规则(全部是门自己的读法,无副本):抛出的码**没有声明 HTTP status**(`serverFaultProvenance` 为 `'undeclared'`,即 2026-08-27 #12509 裁定:区分依据是 status 通道,不看码的拼写),**且** `/data` 门(`mapDataError`)把它判为服务端故障,才改答门的故障。`thrownRefusalEnvelope` 本身不动,所以 `run_action` / `resume_run` 和元数据工具(#31271 / #31272)不受影响。 - 风险:该裁定的已知代价随之到了这些面上——一个只写了码、没写 status、门又不当作拒绝的抛出(例如不带 status 的 `RECORD_LOCKED`)现在也回门的 `500 INTERNAL_ERROR`,码不再外发;生产代码里在数据路径上没找到这种抛出(见 Acceptance notes)。带 status 的码(`RECORD_LOCKED`/409、`SERVICE_UNAVAILABLE`/503)、门当作拒绝的无 status 码(`PERMISSION_DENIED`,门答 403)、沙箱带码拒绝都照旧。只改 `@objectstack/mcp`,patch。回滚即还原 `dataDoorRefusalEnvelope` 的三处调用。 - 先看:`packages/mcp/src/mcp-http-tools.ts` 的 `dataDoorRefusalEnvelope`;`packages/mcp/src/mcp-coded-refusal.test.ts` 末尾的 #31273 describe 和记录资源 describe 里的 #31273 块。 - 两分钟验证:`pnpm --filter @objectstack/mcp exec vitest run src/mcp-coded-refusal.test.ts -t 31273`,预期 15 个用例全部通过。 - 需要你做的:无,席位落地。 ## What changes `dataDoorRefusalEnvelope(err, objectName)` (new, `packages/mcp/src/mcp-http-tools.ts`) is `thrownRefusalEnvelope` for a face the `/api/v1/data` door serves. It returns the coded envelope unless both of the door's own reads say the throw is an undeclared server fault: - `serverFaultProvenance(resolveThrownHttpError(err)) === 'undeclared'` (`@objectstack/types`): the producer declared no status. This is the reader the 2026-08-27 ruling on #12509 put in place to withhold a driver errno at every door ("the discriminator is the status channel, not the code's shape"). - `isServerFault(dataDoorAnswerOf(err, objectName).status)`: the door's `mapDataError` answers a 5xx. When both hold, the face renders the door's answer and logs it once through `logServerFault`, the path a code-less fault already takes. Three call sites read it: - `dataDoorErrorResult`, so all six record data tools; - `delete_records`' outer catch, for a coded throw only. A code-less throw there keeps `errorResultWithCode`, as #31246 ruled; - the record resource catch in `MCPServerRuntime.bridgeResources` (`mcp-server-runtime.ts`). `thrownRefusalEnvelope` is unchanged. No export was added to `@objectstack/types`: both readers were already public. The new function is exported from its module only, for the resource. `packages/mcp/src/index.ts` re-exports `mcp-http-tools.js` by name and does not list it. | Throw | Before (tools / resource) | Now | `/api/v1/data` (`mapDataError`) | |---|---|---|---| | `SQLITE_BUSY` `'SQLITE_BUSY: database is locked'`, no status | `{ code: 'SQLITE_BUSY', message: DRIVER_TEXT }`, no status | `{ code: 'DATABASE_ERROR', message: 'Internal data error', status: 500 }`, logged once | `500 DATABASE_ERROR` `Internal data error` | | `ECONNRESET` `'socket hang up'`, no status | `{ code: 'ECONNRESET', message: 'socket hang up' }` | `500 INTERNAL_ERROR` `Internal server error`, logged once | same | | `RECORD_LOCKED`, no status | `{ code: 'RECORD_LOCKED', message }` | `500 INTERNAL_ERROR` `Internal server error` (the ruling's stated cost) | same | | 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 `SERVICE_UNAVAILABLE` / 503 (declared) | coded, its prose | unchanged | `503 SERVICE_UNAVAILABLE` `Internal server error` (out of scope, see below) | On the resource, the same answers use its `{ error, code, status }` members. ## Tests `packages/mcp/src/mcp-coded-refusal.test.ts`: - New describe "the record data tools: a driver's own string code is the /data door's server fault (#31273)". It covers the six data tools and `delete_records`. - `SQLITE_BUSY` on each of the seven faces: exactly `{ code: 'DATABASE_ERROR', message: 'Internal data error', status: 500 }`, equal to `mapDataError`'s answer. Neither `SQLITE_BUSY` nor the driver text is in the answer. One `[5xx] 500 tools/call TOOL task — ` log line carries the driver text. - `ECONNRESET`: the door's `500 INTERNAL_ERROR`. - `RECORD_LOCKED` with no status: the door's `500 INTERNAL_ERROR`. - CONTROLs on every face: `RECORD_LOCKED` / 409 and a coded sandboxed refusal keep their coded answers, unlogged. `PERMISSION_DENIED` with no status keeps `{ code, message }`. `SERVICE_UNAVAILABLE` / 503 keeps its coded answer. - Record-resource describe, new "#31273" block: - `SQLITE_BUSY`: the exact body `{"error":"Internal data error","code":"DATABASE_ERROR","status":500}`, equal to the door's. One `[5xx] 500 resources/read objectstack://objects/task/records/t1 — ` line carries the driver text. - CONTROL: the declared 503 and the status-less `PERMISSION_DENIED` keep their coded bodies, unlogged. <details><summary>Red first and ablations</summary> Red first on `f8c3ecc4` (`main`, after PR #31300), with the new pins and no fix: `mcp-coded-refusal.test.ts` + `mcp-delete-records.test.ts` gave `Tests 10 failed | 131 passed (141)`. The failures were the seven tool `SQLITE_BUSY` rows, the resource `SQLITE_BUSY` row, `ECONNRESET` and the status-less `RECORD_LOCKED`; every CONTROL passed. With the fix: `Tests 141 passed (141)`. Ablations, each via `node scripts/ablation-replace.mjs` on the committed head. Every run printed `ok mutation landed` and then `ok restored: blob == HEAD … git diff HEAD is empty`. 1. In `dataDoorRefusalEnvelope`, the door-fault decline was replaced by `return envelope`, so the coded path always wins. Blob `0ff325744a3f -> 39ba2308ac8d`, `Tests 10 failed | 131 passed (141)`: the same ten rows as red first. 2. The provenance limb was removed. Blob `0ff325744a3f -> dbdc2756912c`, `Tests 3 failed | 138 passed (141)`: both declared-5xx CONTROLs and #23509's "a coded non-sandbox refusal renders its own message byte for byte". 3. The door-fault read was replaced by a constant 500, so every undeclared coded throw is declined. Blob `0ff325744a3f -> 22dbeb0972dc`, `Tests 4 failed | 137 passed (141)`: the status-less `PERMISSION_DENIED` CONTROLs and two older status-less pins. 4. `delete_records`' catch was put back on `errorResultWithCode`. Blob `0ff325744a3f -> b7bb78b5ff49`, `Tests 1 failed | 140 passed (141)`: the `delete_records` row. 5. The resource catch was put back on `thrownRefusalEnvelope`. Blob `ee07dd30a5c5 -> 13f4fc4cbbe2`, `Tests 1 failed | 140 passed (141)`: the resource row. The tests import the package's source, so no `dist/` is involved. </details> ## Acceptance notes - **Measured the door before choosing the rule.** `mapDataError(v, 'task')` was read on 17 thrown values. The door never reads a driver code's spelling: `SQLITE_BUSY` reaches `DATABASE_ERROR` through the message heuristic (`looksLikeInternalErrorLeak`), and `SQLITE_BUSY` with plain text reaches `INTERNAL_ERROR`. Status-less codes the door answers as faults: `SQLITE_BUSY`, `ECONNRESET`, `40P01`, `RECORD_LOCKED`, `NOT_FOUND`, and an app's own code. Status-less codes it refuses: `PERMISSION_DENIED` (403), the unique-violation codes (409 `UNIQUE_VIOLATION`), `SQLITE_CONSTRAINT_NOTNULL` (400), and a coded sandboxed abort (400). So "driver-native" here is the door's own reading: no declared status, and a 5xx at the door. - **The ruling's cost.** A code thrown with no status that the door does not refuse (for example `RECORD_LOCKED` with no `status`) loses its code on these faces, as at the door. A grep of `objectql`, `runtime`, `core`, `types` and `plugins` sources for `Object.assign(new Error(…), { code: 'X' })` with no status found one hit, `plugin-auth`'s admin user import. That is not an MCP data path. The changeset tells producers to declare the status they mean. - **`Clause-②: no`, re-declared from measurement.** The answer keys are unchanged. Only the values for an undeclared coded fault move to the door's (code, sanitised sentence, status). Nothing in `packages/spec` is touched, and no export was added to a published package's entry. - **Not touched:** `run_action` / `resume_run` (#31271, PR #31315) and the metadata tools and `list_actions` (#31272, PR #31310). `thrownRefusalEnvelope` and their catches are byte-identical. A probe outside the commit drove 10 thrown values through all 13 tools and the record resource, 140 answers, on `f8c3ecc4` and on the head. Exactly 24 answers moved: `SQLITE_BUSY`, `ECONNRESET` and the status-less `RECORD_LOCKED` on the 8 faces above. None of `list_objects`, `describe_object`, `validate_expression`, `list_actions`, `run_action` or `resume_run` moved on any value. - **Out of scope, found while measuring:** a declared 5xx code (`SERVICE_UNAVAILABLE` / 503) still answers its own prose on these faces, where the door withholds every declared 5xx's prose (`Internal server error`) unless the producer marked it `refusal: true`. A CONTROL pins the current answer so the move stays visible. The `delete_records` row fault still quotes the faulting row's thrown text, which #31246 pinned on purpose. ## Verification Base: `f8c3ecc4`; head: `c1ff354d`. - `pnpm --filter @objectstack/mcp test`: `Test Files 60 passed (60)`, `Tests 841 passed (841)`, lock `VERDICT command-exit 0`. - `pnpm --filter @objectstack/mcp typecheck`: exit 0 (`check:test-typecheck`: 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 c1ff354)</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:dual-build-cjs-loads` :: 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 `node scripts/pm/dispatch-gates.mjs --ran`: `65 derived famil(ies) accounted for — 65 run, 0 NOT-MEASURED`. `check:dual-build-cjs-loads` and `check:lean-entry-closure` first exited 3 (PREREQUISITE NOT MET: built entry points missing). Both exited 0 when re-run after `check:type-check-debt` had built every package. 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_01FEkQ5oWCPWH6i2Lsts9cdP Co-authored-by: Claude <noreply@anthropic.com>
1 parent 236af73 commit 45540d5

4 files changed

Lines changed: 219 additions & 6 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/mcp': patch
3+
---
4+
5+
fix(mcp): the record data tools and the record resource answer a driver's own error code (`SQLITE_BUSY`) as `/api/v1/data` does, a sanitised server fault, never the driver's text
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A database driver's error carries its own code on `.code` (`SQLITE_BUSY`, `ECONNRESET`, Postgres `40P01`) and no HTTP status. `query_records`, `aggregate_records`, `get_record`, `create_record`, `update_record`, `delete_record`, `delete_records`' selection read and the `objectstack://objects/{name}/records/{id}` resource read any thrown `code` as a coded refusal, so an agent got `{ "code": "SQLITE_BUSY", "message": "SQLITE_BUSY: database is locked" }` with no status. `/api/v1/data` answers the same throw `500 DATABASE_ERROR` `Internal data error` and logs the driver's text.
11+
12+
These faces now answer such a throw with the `/data` door's answer, read through that door's own classifier (`mapDataError`, `@objectstack/types`):
13+
14+
- `SQLITE_BUSY`: `{ "code": "DATABASE_ERROR", "message": "Internal data error", "status": 500 }` on the tools, `{ "error": "Internal data error", "code": "DATABASE_ERROR", "status": 500 }` on the resource;
15+
- a driver code whose text names no store (`ECONNRESET`): `500 INTERNAL_ERROR` `Internal server error`.
16+
17+
The driver's code and text are logged once on the server as a `[5xx]` line instead of being sent.
18+
19+
The rule is the door's: a thrown code that declared no HTTP status, and that the door answers as a server fault, is a fault, not a refusal. So a code thrown with no status that the door does not recognise as a refusal (for example `RECORD_LOCKED` with no `status`) now answers the door's `500 INTERNAL_ERROR` here too; declare the status you mean to keep the code. A code thrown with its status (`RECORD_LOCKED` / 409, `SERVICE_UNAVAILABLE` / 503), a code the door answers as a refusal without one (`PERMISSION_DENIED`), and a sandboxed hook's coded refusal are answered as before. `run_action`, `resume_run` and the metadata tools are not changed.

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

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,38 @@ describe('the record resource: a coded refusal adds its code beside the unchange
658658
expect(log).not.toHaveBeenCalled();
659659
});
660660
});
661+
/**
662+
* [#31273] A driver's own string code answers the door's server fault here
663+
* too, through the same predicate the record data tools ask (their block,
664+
* below, says why). The #31270 hand-off's `SQLITE_BUSY` row.
665+
*/
666+
describe('a driver\'s own string code answers the /data door\'s server fault (#31273)', () => {
667+
afterEach(() => {
668+
vi.restoreAllMocks();
669+
});
670+
671+
it('SQLITE_BUSY: 500 DATABASE_ERROR \'Internal data error\', the driver text withheld and logged once', async () => {
672+
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
673+
const thrown = sqliteBusy();
674+
const text = await readRefused(thrown);
675+
expect(text).toBe(JSON.stringify({ error: 'Internal data error', code: 'DATABASE_ERROR', status: 500 }));
676+
const door = mapDataError(thrown, 'task');
677+
expect(JSON.parse(text)).toEqual({ error: door.body.error, code: door.body.code, status: door.status });
678+
expect(log).toHaveBeenCalledTimes(1);
679+
const line = String(log.mock.calls[0]![0]);
680+
expect(line).toMatch(/^\[5xx\] 500 resources\/read objectstack:\/\/objects\/task\/records\/t1 — /);
681+
expect(line).toContain('SQLITE_BUSY: database is locked');
682+
});
683+
684+
it('CONTROL: a declared 5xx code and a code the door refuses with no status keep their coded answers, unlogged', async () => {
685+
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
686+
expect(JSON.parse(await readRefused(Object.assign(new Error('The ledger is closing.'), { code: 'SERVICE_UNAVAILABLE', status: 503 }))))
687+
.toEqual({ error: 'The ledger is closing.', code: 'SERVICE_UNAVAILABLE', status: 503 });
688+
expect(JSON.parse(await readRefused(Object.assign(new Error('Field read denied: salary'), { code: 'PERMISSION_DENIED' }))))
689+
.toEqual({ error: 'Field read denied: salary', code: 'PERMISSION_DENIED' });
690+
expect(log).not.toHaveBeenCalled();
691+
});
692+
});
661693
});
662694

663695
/**
@@ -836,3 +868,124 @@ describe('the metadata tools and list_actions: a fault answers what the /meta do
836868
}
837869
});
838870
});
871+
872+
/**
873+
* [#31273] A driver's own string code (`SQLITE_BUSY`, `ECONNRESET`, `40P01`)
874+
* rides `.code` with no HTTP status. The `/api/v1/data` door answers it as a
875+
* server fault (`mapDataError`, `@objectstack/types`): `500 DATABASE_ERROR`
876+
* `Internal data error` when its text names the store, the driver text and
877+
* errno withheld and logged once. The record data tools read the coded path
878+
* first, so an agent got `{ code: 'SQLITE_BUSY', message: 'SQLITE_BUSY:
879+
* database is locked' }` with no status.
880+
*
881+
* A coded throw is now a coded refusal on these faces unless the door answers
882+
* it as a server fault whose status the producer did not declare
883+
* (`serverFaultProvenance` `'undeclared'`, `@objectstack/types`, the ruling of
884+
* 2026-08-27 on #12509: the discriminator is the status channel, never the
885+
* code's spelling). Then the face answers the door's fault.
886+
*
887+
* Only the record data tools, `delete_records`' selection read and the record
888+
* resource (its rows are below). `run_action` / `resume_run` and the metadata
889+
* tools answer a driver code through their own doors' readers (#31271,
890+
* #31272), so this block does not pin them.
891+
*/
892+
function sqliteBusy(): Error {
893+
return Object.assign(new Error('SQLITE_BUSY: database is locked'), { code: 'SQLITE_BUSY' });
894+
}
895+
896+
describe('the record data tools: a driver\'s own string code is the /data door\'s server fault, not a coded refusal (#31273)', () => {
897+
const FACES = SITES.filter((s) => s.dataDoor || s.tool === 'delete_records');
898+
899+
afterEach(() => {
900+
vi.restoreAllMocks();
901+
});
902+
903+
/** The tool's answer, parsed, after asserting it is a tool error. */
904+
async function errorOf(site: Site, thrown: unknown): Promise<{ text: string; error: Record<string, unknown> }> {
905+
const result = await callTool(site, thrown);
906+
expect(result.isError).toBe(true);
907+
expect(result.content).toHaveLength(1);
908+
const text = String(result.content[0].text);
909+
return { text, error: JSON.parse(text).error };
910+
}
911+
912+
/** The `/data` door's answer to the same throw, in the tool's envelope spelling. */
913+
function doorAnswer(thrown: unknown): Record<string, unknown> {
914+
const door = mapDataError(thrown, 'task');
915+
return { code: door.body.code, message: door.body.error, status: door.status };
916+
}
917+
918+
it('the faces are the six record data tools and delete_records\' selection read', () => {
919+
expect(FACES.map((s) => s.tool).sort()).toEqual([
920+
'aggregate_records', 'create_record', 'delete_record', 'delete_records', 'get_record', 'query_records', 'update_record',
921+
]);
922+
});
923+
924+
for (const site of FACES) {
925+
it(`${site.tool}: SQLITE_BUSY answers 500 DATABASE_ERROR 'Internal data error', the driver text withheld and logged once`, async () => {
926+
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
927+
const thrown = sqliteBusy();
928+
const { text, error } = await errorOf(site, thrown);
929+
expect(error).toEqual({ code: 'DATABASE_ERROR', message: 'Internal data error', status: 500 });
930+
expect(error).toEqual(doorAnswer(thrown));
931+
expect(text).not.toContain('SQLITE_BUSY');
932+
expect(text).not.toContain('database is locked');
933+
expect(log).toHaveBeenCalledTimes(1);
934+
const line = String(log.mock.calls[0]![0]);
935+
expect(line).toMatch(new RegExp(`^\\[5xx\\] 500 tools/call ${site.tool} task — `));
936+
expect(line).toContain('SQLITE_BUSY: database is locked');
937+
});
938+
}
939+
940+
const update = FACES.find((s) => s.tool === 'update_record')!;
941+
942+
it('a driver errno whose text names no store (ECONNRESET) answers the door\'s 500 INTERNAL_ERROR, its code withheld', async () => {
943+
vi.spyOn(console, 'error').mockImplementation(() => {});
944+
const thrown = Object.assign(new Error('socket hang up'), { code: 'ECONNRESET' });
945+
const { text, error } = await errorOf(update, thrown);
946+
expect(error).toEqual({ code: 'INTERNAL_ERROR', message: 'Internal server error', status: 500 });
947+
expect(error).toEqual(doorAnswer(thrown));
948+
expect(text).not.toContain('ECONNRESET');
949+
});
950+
951+
// The ruling's stated cost (`serverFaultProvenance`'s docblock): a producer
952+
// that spells a code but declares no status loses that code on a 5xx, at the
953+
// door and now here. It keeps it by declaring the status it means.
954+
it('a code with no declared status that the door answers as a fault: the door\'s 500, the code not served', async () => {
955+
vi.spyOn(console, 'error').mockImplementation(() => {});
956+
const thrown = Object.assign(new Error('Locked.'), { code: 'RECORD_LOCKED' });
957+
const { error } = await errorOf(update, thrown);
958+
expect(error).toEqual({ code: 'INTERNAL_ERROR', message: 'Internal server error', status: 500 });
959+
expect(error).toEqual(doorAnswer(thrown));
960+
});
961+
962+
it('CONTROL: a declared ObjectStack code and a coded sandboxed refusal keep their coded answers on every face, unlogged', async () => {
963+
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
964+
for (const site of FACES) {
965+
expect((await errorOf(site, Object.assign(new Error('Locked.'), { code: 'RECORD_LOCKED', status: 409 }))).error, site.tool)
966+
.toEqual({ code: 'RECORD_LOCKED', message: 'Locked.', status: 409 });
967+
expect((await errorOf(site, codedSandboxRefusal())).error, site.tool)
968+
.toEqual({ code: 'RECORD_LOCKED', message: SANDBOX_SENTENCE, status: 409 });
969+
}
970+
expect(log).not.toHaveBeenCalled();
971+
});
972+
973+
it('CONTROL: a code the door answers as a refusal with no declared status (PERMISSION_DENIED, 403 at the door) keeps its coded answer', async () => {
974+
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
975+
for (const site of FACES) {
976+
expect((await errorOf(site, Object.assign(new Error('Field read denied: salary'), { code: 'PERMISSION_DENIED' }))).error, site.tool)
977+
.toEqual({ code: 'PERMISSION_DENIED', message: 'Field read denied: salary' });
978+
}
979+
expect(log).not.toHaveBeenCalled();
980+
});
981+
982+
// A 5xx the producer DECLARED is not a driver's: its code is authored. The
983+
// door withholds a declared 5xx's prose too; that is not this card's face.
984+
it('CONTROL: a code with a declared 5xx keeps its coded answer', async () => {
985+
const thrown = Object.assign(new Error('The ledger is closing.'), { code: 'SERVICE_UNAVAILABLE', status: 503 });
986+
for (const site of FACES) {
987+
expect((await errorOf(site, thrown)).error, site.tool)
988+
.toEqual({ code: 'SERVICE_UNAVAILABLE', message: 'The ledger is closing.', status: 503 });
989+
}
990+
});
991+
});

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

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ import { StandardErrorCode, type ErrorCode } from '@objectstack/spec/api';
5050
// [#23701] The one key every door that translates the engine's unique-violation
5151
// envelope asks (the registered code AND the class name, never message text),
5252
// and the one classification whose arm states the wire code for it.
53-
import { earnedAnswerOf, isEngineDuplicateRecordEnvelope, isSandboxCrash, isServerFault, logServerFault, mapDataError, resolveQueryReadLimits, sandboxBusinessMessage, staleSummaryAnswerLines, structuredCodeAnswer, type SummaryRecomputeRefusal } from '@objectstack/types';
53+
import { earnedAnswerOf, isEngineDuplicateRecordEnvelope, isSandboxCrash, isServerFault, logServerFault, mapDataError, resolveQueryReadLimits, resolveThrownHttpError, sandboxBusinessMessage, serverFaultProvenance, staleSummaryAnswerLines, structuredCodeAnswer, type SummaryRecomputeRefusal } from '@objectstack/types';
5454
import {
5555
MCP_OAUTH_SCOPE_DATA_READ,
5656
MCP_OAUTH_SCOPE_DATA_WRITE,
@@ -547,6 +547,37 @@ export function dataDoorAnswerOf(err: unknown, objectName?: string): DataDoorAns
547547
return { ...(code !== undefined ? { code } : {}), message, status: door.status };
548548
}
549549

550+
/**
551+
* [#31273] {@link thrownRefusalEnvelope} for a face the `/api/v1/data` door
552+
* serves: the coded refusal, unless the door answers the throw as a server
553+
* fault whose status the producer never declared. That throw is a driver's own
554+
* errno on `.code` (`SQLITE_BUSY`, `ECONNRESET`, `40P01`), not a refusal, and
555+
* the face answers the door's fault instead: `500 DATABASE_ERROR` `Internal
556+
* data error` for `SQLITE_BUSY`, the errno and driver text withheld and logged.
557+
* It used to reach the agent as `{ code: 'SQLITE_BUSY', message: <driver
558+
* text> }` with no status.
559+
*
560+
* Both halves are the door's own reads, never a copy: {@link dataDoorAnswerOf}
561+
* (`mapDataError`) says "server fault", and `serverFaultProvenance`
562+
* (`@objectstack/types`) says "undeclared". The second is the 2026-08-27
563+
* ruling on #12509 that withholds a demoted errno at every door: the
564+
* discriminator is the status channel, never the code's spelling, so a
565+
* producer that spells a code and declares no status loses it on a 5xx, here
566+
* as at the door. A code the door refuses (`PERMISSION_DENIED` with no status
567+
* answers 403 there) and a code with a declared status keep the coded answer.
568+
*
569+
* Read by the six record data tools ({@link dataDoorErrorResult}),
570+
* `delete_records`' selection read and the record resource
571+
* (`MCPServerRuntime.bridgeResources`). ⛔ Not by `run_action` / `resume_run`
572+
* or the metadata tools, whose doors are not `/data`.
573+
*/
574+
export function dataDoorRefusalEnvelope(err: unknown, objectName: string): ThrownRefusalEnvelope | undefined {
575+
const envelope = thrownRefusalEnvelope(err);
576+
if (envelope === undefined) return undefined;
577+
if (serverFaultProvenance(resolveThrownHttpError(err)) !== 'undeclared') return envelope;
578+
return isServerFault(dataDoorAnswerOf(err, objectName).status) ? undefined : envelope;
579+
}
580+
550581
/**
551582
* [#31218] The record data tools' tool error: a CODED throw renders exactly as
552583
* {@link errorResultWithCode} renders it, and a throw with NO code renders the
@@ -573,15 +604,16 @@ export function dataDoorAnswerOf(err: unknown, objectName?: string): DataDoorAns
573604
*
574605
* ⛔ The coded path is read FIRST and is unchanged: a thrown `code` keeps
575606
* {@link thrownRefusalEnvelope}'s rendering byte for byte, its unique-violation
576-
* spelling included.
607+
* spelling included. [#31273] One exception, a driver's own code: see
608+
* {@link dataDoorRefusalEnvelope}.
577609
*
578610
* Only these six tools. `delete_records` groups its rows through the same door
579611
* ({@link rowRefusalOf}); its outer catch also renders this file's own
580612
* code-less sentences, which the door's message heuristics would misread, so
581613
* it keeps {@link errorResultWithCode}.
582614
*/
583-
function dataDoorErrorResult(err: unknown, tool: DataDoorTool, objectName: string) {
584-
const envelope = thrownRefusalEnvelope(err);
615+
function dataDoorErrorResult(err: unknown, tool: DataDoorTool | typeof DELETE_RECORDS_TOOL, objectName: string) {
616+
const envelope = dataDoorRefusalEnvelope(err, objectName);
585617
if (envelope) return codedErrorResult({ ...envelope });
586618
const door = dataDoorAnswerOf(err, objectName);
587619
if (isServerFault(door.status)) {
@@ -2340,6 +2372,12 @@ export function registerObjectTools(
23402372
}
23412373
return textResult(outcome);
23422374
} catch (err) {
2375+
// [#31273] A driver's own code from the selection read answers the
2376+
// door's fault, as the record data tools answer it. Every other
2377+
// throw keeps this catch's rendering (#31218 says why).
2378+
if (thrownRefusalEnvelope(err) !== undefined && dataDoorRefusalEnvelope(err, objectName) === undefined) {
2379+
return dataDoorErrorResult(err, DELETE_RECORDS_TOOL, objectName);
2380+
}
23432381
return errorResultWithCode(err);
23442382
}
23452383
},

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import type {
1414
ToolRegistry,
1515
ToolExecutionResult,
1616
} from './types.js';
17-
import { wireBridgeTools, grantsAuthoringTools, thrownRefusalEnvelope, dataDoorAnswerOf } from './mcp-http-tools.js';
17+
import { wireBridgeTools, grantsAuthoringTools, thrownRefusalEnvelope, dataDoorAnswerOf, dataDoorRefusalEnvelope } from './mcp-http-tools.js';
1818
import { isServerFault, logServerFault } from '@objectstack/types';
1919
import type {
2020
McpDataBridge,
@@ -1577,8 +1577,11 @@ 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`).
15801583
const uri = `objectstack://objects/${objectName}/records/${recordId}`;
1581-
const envelope = thrownRefusalEnvelope(err);
1584+
const envelope = dataDoorRefusalEnvelope(err, objectName);
15821585
let body: { error: string; code?: string; status?: number };
15831586
if (envelope) {
15841587
body = {

0 commit comments

Comments
 (0)