You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 45540d5
Browse filesBrowse the repository at this point in the historyBrowse 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>
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.
0 commit comments