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