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 e6a0b72
Browse filesBrowse the repository at this point in the historyBrowse 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>
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.
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()=>{
0 commit comments