Skip to content

Commit b3bd75d

Browse files
fix(mcp): a coded sandboxed hook refusal answers the body's sentence, not the wrapper naming the hook (#31268)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X3apZSKwFDd6zvjM4gJPKAy)_ Fixes #31246 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:沙箱 hook(或 action 主体)抛出带错误码的拒绝时,MCP 的所有工具和记录资源现在只回 hook 作者写的那句话(`Locked.`),错误码和状态码不变;读法用 `/api/v1/data` 同一个函数 `sandboxBusinessMessage`,没有复制逻辑。 - 为什么改:之前 MCP 回的是沙箱调试包装 `hook 'NAME' threw: Error: Locked.`,把 hook 内部名字暴露给 agent;REST 对同一个拒绝只回那句话。 - 风险与回滚:只改 `@objectstack/mcp` 的两处读法,patch;非沙箱的带码拒绝逐字不变,沙箱里崩溃的主体也保持原样。回滚即还原这两处。 - 先看:`packages/mcp/src/mcp-http-tools.ts` 的 `thrownRefusalEnvelope`;`packages/mcp/src/mcp-server-runtime.ts` 记录资源的 catch;`packages/mcp/src/mcp-coded-refusal.test.ts` 的 #31246 块。 - 两分钟验证:`pnpm --filter @objectstack/mcp exec vitest run src/mcp-coded-refusal.test.ts -t 31246`,预期 16 个用例全部通过。 - 需要你做的:无,席位落地。 ## What changes A QuickJS hook or action body that runs `throw Object.assign(new Error('Locked.'), { code: 'RECORD_LOCKED', status: 409 })` leaves the runner as a `SandboxError` with `message` = `hook 'NAME' threw: Error: Locked.`, `innerMessage` = `Locked.`, `code` and `status` as declared (measured on the real `QuickJSScriptRunner` at `main` 169ff7e). MCP rendered `message`, so the agent read the hook's internal name; `/api/v1/data` answers `409 RECORD_LOCKED` with `Locked.` (`mapDataError` → `armSentence` → `sandboxBusinessMessage`). - `thrownRefusalEnvelope` (`packages/mcp/src/mcp-http-tools.ts`) reads `message` as `sandboxBusinessMessage(err) ?? messageOf(err)`. `sandboxBusinessMessage` is the one reader in `@objectstack/types` that the `/data` door uses; there is no second copy of its logic in `packages/mcp`. The code and status are unchanged. - The record resource's catch (`MCPServerRuntime.bridgeResources`, `packages/mcp/src/mcp-server-runtime.ts`) answered a coded refusal with its own `err.message`. It now uses the envelope's `message`, so it gives the same sentence as the tools. The code-less `{ error }` answer is unchanged. The envelope is the one reader, so every face that renders a coded refusal moves together: the 13 tools (`list_objects`, `describe_object`, `validate_expression`, the six record data tools, `delete_records`, `list_actions`, `run_action`, `resume_run`), the per-row grouping of `delete_records` (`rowRefusalOf`), the record resource, and the object resources' coded refusal (`objectResourceRefusal`). | Throw | Before (MCP) | Now (MCP) | `/api/v1/data` | |---|---|---|---| | coded sandboxed refusal (`RECORD_LOCKED`/409, inner `Locked.`) | `RECORD_LOCKED` · `hook 'demo_guard' threw: Error: Locked.` · 409 | `RECORD_LOCKED` · `Locked.` · 409 | `RECORD_LOCKED` · `Locked.` · 409 | | CONTROL: coded non-sandbox refusal, including one whose own text reads like the wrapper | its message | its message, byte for byte | (not this card) | ## Tests - `packages/mcp/src/mcp-coded-refusal.test.ts`: a new `#31246` describe on the shared `/mcp` HTTP harness. Each of the 13 tools (the `SITES` table, which is held equal to the registered tool list) answers the coded sandboxed refusal with `{ code: 'RECORD_LOCKED', message: INNER_SENTENCE, status: 409 }`. Each case also checks that this equals `mapDataError`'s answer to the same throw, and that the hook name and `threw` are absent. CONTROL: three coded non-sandbox throws (a plain sentence, a sentence that reads like the wrapper, and a `SandboxError` with an empty `innerMessage`) render byte for byte as `JSON.stringify({ error: { code, message, status } }, null, 2)`. In the record-resource describe: the coded sandboxed refusal answers `{ error: INNER_SENTENCE, code, status }`, and a CONTROL keeps a coded non-sandbox wrapper-like sentence. - `packages/mcp/src/mcp-delete-records.test.ts`: a coded sandboxed row refusal is grouped as `{ code: 'RECORD_LOCKED', status: 409, reason: INNER_SENTENCE }`, and the call goes on. <details><summary>Red first and ablations</summary> Red first on `bdefd49f` (after PR #31245 merged, before the fix): the two files answered `Tests 15 failed | 91 passed (106)`. The 13 tool rows, the resource row and the `delete_records` row failed; the CONTROLs passed. After the fix: `Tests 106 passed (106)`. Ablation 1 (`node scripts/ablation-replace.mjs`, `mcp-http-tools.ts`): `message: sandboxBusinessMessage(err) ?? messageOf(err),` replaced by `message: messageOf(err) as string,`. Output: `ok mutation landed: anchor 1 -> 0, blob 9cca1c0 -> 58b6b2bf2013`, then `Tests 15 failed | 91 passed (106)`, then `ok restored: blob == HEAD (9cca1c0) and git diff HEAD is empty`. Ablation 2 (`mcp-server-runtime.ts`): `error: envelope.message,` replaced by `error: message as string,`. Output: `ok mutation landed: anchor 1 -> 0, blob 38c17f5 -> c385c405c4f1`, then `Tests 1 failed | 105 passed (106)` (the resource row only), then `ok restored: blob == HEAD (38c17f5)`. The tests import the package's source, so no `dist/` is involved. </details> ## Acceptance notes Item 2 of the card: each listed face was measured against the REST door that serves the same verb, using five throws: V1 coded sandboxed refusal, V2 code-less sandboxed abort, V3 bare `Error`, V4 driver string code `SQLITE_BUSY`, V5 coded sandboxed **crash** (inner `TypeError: boom`, code `RECORD_LOCKED`, status 409; the real runner's shape). MCP was measured over `/mcp` on this branch with a bridge member throwing each value. REST was measured with the door's own responder: `mapDataError` for `/data`, `handleRouteError` for `/meta`, and the runtime `HttpDispatcher.handleActions` for `/actions`. | Face | Throw | MCP answers | REST answers | Reading | |---|---|---|---|---| | every face | V1 | inner sentence (this PR) | inner sentence | fixed here | | every face | V5 coded crash | `RECORD_LOCKED` · `hook 'demo_guard' threw: TypeError: boom` · 409 | `/data`, `/meta`: `500 INTERNAL_ERROR` `Internal server error`; `/actions`: 500 `INTERNAL_ERROR` | divergence, not fixed by the shared reader (`sandboxBusinessMessage` declines a crash by contract); finding | | metadata tools, `list_actions` | V3 | bare text `socket hang up` | `/meta`: `500 INTERNAL_ERROR` `Internal server error` | divergence; finding | | metadata tools, `list_actions` | V4 | `SQLITE_BUSY` · driver text | `/meta`: `500 DATABASE_ERROR` `Internal data error` | divergence; finding | | record resource | V2 | `{ error: "hook 'demo_guard' threw: Error: Not enough leave" }` | `/data`: `400 HOOK_ABORTED` `Not enough leave` | divergence (the #31218 code-less half on this face); finding | | record resource | V3 / V4 | `{ error: 'socket hang up' }` / `{ error, code: 'SQLITE_BUSY' }` | `500 INTERNAL_ERROR` / `500 DATABASE_ERROR` | divergence; same finding | | `run_action` / `resume_run` | V2 | bare wrapper text naming the body | `/actions`: `400 HOOK_ABORTED` inner sentence | divergence; finding | | `run_action` / `resume_run` | V3 | bare text, no code | `/actions`: `400 VALIDATION_ERROR` same text | divergence (code only); same finding | | `run_action` / `resume_run` | V4 | `SQLITE_BUSY` · text, no status | `/actions`: `400 VALIDATION_ERROR` · text · `declaredCode: SQLITE_BUSY` | divergence; same finding | | record data tools | V4 | `SQLITE_BUSY` · driver text, no status (coded path is read first) | `/data`: `500 DATABASE_ERROR` `Internal data error` | divergence; finding | | record data tools | V2 / V3 | `HOOK_ABORTED` 400 / `INTERNAL_ERROR` 500 | same | converged by #31218 | | `delete_records` outer catch | V2 / V3 | bare message | n/a | kept as intended, see below | Kept as intended: `delete_records`' outer catch stays on `errorResultWithCode`. It also renders this file's own code-less sentences (`delete_records could not read the records the query on 'OBJ' answered`), which `mapDataError`'s unknown-object heuristic would misread as `404 OBJECT_NOT_FOUND` (the PR #31245 note). Its fault answer still quotes the faulting row's thrown message, as `mcp-delete-records.test.ts` pins on purpose. Each divergence above is listed for the seat to file, one card per face. ## Verification Base: `bdefd49f` (`main`, with PR #31245 merged); head: `a8855476`. - `pnpm --filter @objectstack/mcp test`: `Test Files 60 passed (60)`, `Tests 806 passed (806)`, lock `VERDICT command-exit 0`. - `pnpm --filter @objectstack/mcp typecheck`: exit 0 (includes `check:test-typecheck`: 53 errors / 8 pinned signatures held). - `pnpm exec eslint --no-inline-config` on the four touched `.ts` files: exit 0 (`@objectstack/mcp` has no `lint` script). <details><summary>Derived gates (node scripts/pm/dispatch-gates.mjs --commands, 60 commands, all on a885547)</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 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:objectql-double-limit` :: 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: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`: `60 derived famil(ies) accounted for — 60 run, 0 NOT-MEASURED`. Two gates first exited 3 on a prerequisite and were re-run after it was met: `check-plugin-teardown-shape --self-test` (shallow clone; fetched its pinned fixture commit) and `check:dual-build-cjs-loads` (needed every package's dist, built by `check:type-check-debt`). Both then exited 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_01FEkQ5oWCPWH6i2Lsts9cdP Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2f369cd commit b3bd75d

5 files changed

Lines changed: 137 additions & 3 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/mcp': patch
3+
---
4+
5+
fix(mcp): a coded refusal from a sandboxed hook or action body answers the body's own sentence, not the sandbox wrapper that names the hook
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A QuickJS hook or action body that throws with a declared `code` and `status` (for example `throw Object.assign(new Error('Locked.'), { code: 'RECORD_LOCKED', status: 409 })`) reached every MCP tool and the `objectstack://objects/{name}/records/{id}` resource with the sandbox's debug wrapper as its message, `hook '<name>' threw: Error: Locked.`, so an agent saw the hook's internal name. `/api/v1/data` answers the same throw with the body's sentence alone.
11+
12+
The MCP tools and the record resource now read that sentence the way the `/data` door does (`sandboxBusinessMessage`, `@objectstack/types`): `{ "error": { "code": "RECORD_LOCKED", "message": "Locked.", "status": 409 } }`. The code and status are unchanged. A coded refusal that does not come from a sandbox keeps its own message byte for byte, and a sandboxed body that crashed keeps today's message.
13+
14+
This supersedes one sentence of the `@objectstack/mcp` note for the record data tools' code-less throw ("A thrown error that carries a `code` is answered exactly as before"): a coded refusal from a sandboxed body now answers its inner sentence; its code and status are still answered as before.

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

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,71 @@ describe('the record data tools: a code-less throw answers what the /data door a
424424
});
425425
});
426426

427+
/**
428+
* [#31246] A CODED sandboxed refusal. A QuickJS hook (or action) body that runs
429+
* `throw Object.assign(new Error(SENTENCE), { code: 'RECORD_LOCKED', status: 409 })`
430+
* leaves the runner as this `SandboxError` (measured on the real
431+
* `QuickJSScriptRunner`): `.message` is the `hook '<name>' threw: Error: …`
432+
* debug wrapper, `.innerMessage` the sentence the author addressed to the
433+
* caller, `code` and `status` as declared.
434+
*/
435+
const SANDBOX_SENTENCE = 'A finished invoice cannot be changed.';
436+
const SANDBOX_WRAPPER = `hook 'demo_invoice_guard' threw: Error: ${SANDBOX_SENTENCE}`;
437+
function codedSandboxRefusal(): Error {
438+
return Object.assign(new Error(SANDBOX_WRAPPER), {
439+
name: 'SandboxError',
440+
innerMessage: SANDBOX_SENTENCE,
441+
code: 'RECORD_LOCKED',
442+
status: 409,
443+
});
444+
}
445+
446+
/**
447+
* [#31246] Every tool renders a coded sandboxed refusal as the body's own
448+
* sentence beside its code and status. `thrownRefusalEnvelope` read `.message`,
449+
* so each answered the wrapper, the hook's internal name included, where
450+
* `/api/v1/data` answers the inner sentence. It now reads the sentence through
451+
* `sandboxBusinessMessage` (`@objectstack/types`), the read that door makes.
452+
*/
453+
describe('every tool: a coded sandboxed refusal answers the body\'s sentence, never the wrapper naming the hook (#31246)', () => {
454+
for (const site of SITES) {
455+
it(`${site.tool}: RECORD_LOCKED, the inner sentence and 409; the hook name is absent`, async () => {
456+
const thrown = codedSandboxRefusal();
457+
const result = await callTool(site, thrown);
458+
expect(result.isError).toBe(true);
459+
expect(result.content).toHaveLength(1);
460+
expect(JSON.parse(result.content[0].text)).toEqual({
461+
error: { code: 'RECORD_LOCKED', message: SANDBOX_SENTENCE, status: 409 },
462+
});
463+
// The answer the data door gives the same throw.
464+
const door = mapDataError(thrown, 'task');
465+
expect([door.body.code, door.body.error, door.status]).toEqual(['RECORD_LOCKED', SANDBOX_SENTENCE, 409]);
466+
expect(result.content[0].text).not.toContain('demo_invoice_guard');
467+
expect(result.content[0].text).not.toContain('threw');
468+
});
469+
}
470+
471+
// CONTROL: a READ of the field the sandbox populated, never a strip of the
472+
// wrapper's prose. A coded refusal that is not a sandbox's renders byte for
473+
// byte as before, even when its own sentence reads like the wrapper.
474+
it('CONTROL: a coded non-sandbox refusal renders its own message byte for byte, wrapper-like prose included', async () => {
475+
const query = SITES.find((s) => s.tool === 'query_records')!;
476+
for (const thrown of [
477+
Object.assign(new Error('The period is closed.'), { code: 'RECORD_LOCKED', status: 409 }),
478+
Object.assign(new Error(SANDBOX_WRAPPER), { code: 'RECORD_LOCKED', status: 409 }),
479+
Object.assign(new Error(SANDBOX_WRAPPER), { name: 'SandboxError', innerMessage: '', code: 'RECORD_LOCKED', status: 409 }),
480+
]) {
481+
expect(await callTool(query, thrown), thrown.message).toEqual({
482+
content: [{
483+
type: 'text',
484+
text: JSON.stringify({ error: { code: 'RECORD_LOCKED', message: thrown.message, status: 409 } }, null, 2),
485+
}],
486+
isError: true,
487+
});
488+
}
489+
});
490+
});
491+
427492
describe('the record resource: a coded refusal adds its code beside the unchanged sentence (#23509)', () => {
428493
const URI = 'objectstack://objects/task/records/t1';
429494

@@ -476,6 +541,18 @@ describe('the record resource: a coded refusal adds its code beside the unchange
476541
});
477542
});
478543

544+
it('[#31246] a coded sandboxed refusal: `error` is the inner sentence, beside its code and status', async () => {
545+
const text = await readRefused(codedSandboxRefusal());
546+
expect(JSON.parse(text)).toEqual({ error: SANDBOX_SENTENCE, code: 'RECORD_LOCKED', status: 409 });
547+
expect(text).not.toContain('demo_invoice_guard');
548+
});
549+
550+
it('[#31246] CONTROL: a coded non-sandbox refusal keeps its own sentence, wrapper-like prose included', async () => {
551+
expect(await readRefused(Object.assign(new Error(SANDBOX_WRAPPER), { code: 'RECORD_LOCKED', status: 409 }))).toBe(
552+
JSON.stringify({ error: SANDBOX_WRAPPER, code: 'RECORD_LOCKED', status: 409 }),
553+
);
554+
});
555+
479556
it('a code-less throw answers `{ error }` byte-identically', async () => {
480557
expect(await readRefused(new Error('engine exploded'))).toBe(JSON.stringify({ error: 'engine exploded' }));
481558
expect(await readRefused('a thrown string')).toBe(JSON.stringify({ error: 'a thrown string' }));

‎packages/mcp/src/mcp-delete-records.test.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,33 @@ describe('MCP delete_records: many records of one object in one confirmed call (
653653
});
654654
expect([...rows.keys()]).toEqual(['t2', 't3', 't4', 't5']);
655655
});
656+
657+
it('[#31246] a CODED sandboxed refusal is grouped under its code with the body\'s sentence, never the wrapper naming the hook', async () => {
658+
const sentence = 'This task is approved and cannot be deleted.';
659+
// The `SandboxError` the QuickJS runner raises for a body's
660+
// `throw Object.assign(new Error(sentence), { code, status })`.
661+
rows.get('t3')!.throws = Object.assign(new Error(`hook 'dlr_task_guard' threw: Error: ${sentence}`), {
662+
name: 'SandboxError',
663+
innerMessage: sentence,
664+
code: 'RECORD_LOCKED',
665+
status: 409,
666+
});
667+
668+
const result = await callTool(bridge, 'delete_records', {
669+
objectName: OBJECT,
670+
recordIds: ['t1', 't3', 't6'],
671+
[AI_ACTION_CONFIRMATION_MEMBER]: true,
672+
});
673+
674+
expect(result.isError, result.content[0]?.text).toBeFalsy();
675+
expect(parsed(result)).toMatchObject({
676+
attempted: 3,
677+
deleted: 2,
678+
refused: 1,
679+
refusals: [{ code: 'RECORD_LOCKED', status: 409, reason: sentence, count: 1, recordIds: ['t3'] }],
680+
});
681+
expect(result.content[0]!.text).not.toContain('dlr_task_guard');
682+
});
656683
});
657684

658685
it('the long-lived server the stdio transport serves refuses, previews and deletes the same way', async () => {

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

Lines changed: 15 additions & 2 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, isServerFault, logServerFault, mapDataError, resolveQueryReadLimits, staleSummaryAnswerLines, structuredCodeAnswer, type SummaryRecomputeRefusal } from '@objectstack/types';
53+
import { earnedAnswerOf, isEngineDuplicateRecordEnvelope, isServerFault, logServerFault, mapDataError, resolveQueryReadLimits, sandboxBusinessMessage, staleSummaryAnswerLines, structuredCodeAnswer, type SummaryRecomputeRefusal } from '@objectstack/types';
5454
import {
5555
MCP_OAUTH_SCOPE_DATA_READ,
5656
MCP_OAUTH_SCOPE_DATA_WRITE,
@@ -1118,14 +1118,27 @@ export interface ThrownRefusalEnvelope {
11181118
* (`developerMessage`, `object`, `field`) in their envelope's context slot;
11191119
* this envelope has none. It is `code`, `message` and `status`, and the data
11201120
* tools withhold `details` by design ({@link errorResultWithCode}).
1121+
*
1122+
* ## The caller-addressed sentence of a sandboxed refusal (#31246)
1123+
*
1124+
* A QuickJS hook or action body that throws with a declared `code` and
1125+
* `status` leaves the runner as a `SandboxError` whose `.message` is the
1126+
* `<kind> '<name>' threw: Error: …` debug wrapper, naming the hook, and whose
1127+
* `.innerMessage` is the sentence the author addressed to the caller. The
1128+
* `message` here is `sandboxBusinessMessage` (`@objectstack/types`), the read
1129+
* the `/api/v1/data` door makes for the same throw, and `.message` only when
1130+
* that read declines: not a sandbox refusal, or a body that crashed. ⛔ One
1131+
* reader, never a copy of its logic here, and never a strip of the wrapper's
1132+
* prose: a non-sandbox refusal keeps its own message byte for byte. The code
1133+
* and status are unchanged.
11211134
*/
11221135
export function thrownRefusalEnvelope(err: unknown): ThrownRefusalEnvelope | undefined {
11231136
const thrownCode = (err as { code?: unknown } | null | undefined)?.code;
11241137
if (typeof thrownCode !== 'string' || thrownCode.length === 0) return undefined;
11251138
const status = (err as { status?: unknown }).status;
11261139
return {
11271140
code: wireCodeOf(err, thrownCode),
1128-
message: messageOf(err),
1141+
message: sandboxBusinessMessage(err) ?? messageOf(err),
11291142
...(typeof status === 'number' ? { status } : {}),
11301143
};
11311144
}

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1567,6 +1567,9 @@ export class MCPServerRuntime {
15671567
// does. A throw with no code answers `{ error }` as before.
15681568
// `details` is withheld, as on the data tools; `errorResultWithCode`
15691569
// in mcp-http-tools.ts says why.
1570+
// [#31246] A coded refusal's `error` is the envelope's sentence,
1571+
// so a sandboxed hook's refusal answers the body's own sentence
1572+
// rather than the runner's wrapper naming the hook, as the tools do.
15701573
const envelope = thrownRefusalEnvelope(err);
15711574
return {
15721575
contents: [{
@@ -1575,7 +1578,7 @@ export class MCPServerRuntime {
15751578
text: JSON.stringify(
15761579
envelope
15771580
? {
1578-
error: message,
1581+
error: envelope.message,
15791582
code: envelope.code,
15801583
...(envelope.status !== undefined ? { status: envelope.status } : {}),
15811584
}

0 commit comments

Comments
 (0)