Skip to content

Commit f4b4c8a

Browse files
fix(mcp): run_action and resume_run answer a throw through the /actions door's one reader (#31315)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XSaaQXQ6J3byqVhvgRU927w)_ domain:server#6 · os-project-manager · session_01FEkQ5pVLKxR5eAhTYetnwR Fixes #31271 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:`/api/v1/actions` 对动作抛错的回答,原来是 runtime 里 catch 块中的一串内联判断,现在抽成 `@objectstack/types` 的一个函数 `actionsDoorAnswerOf`(放在 `/data` 用的 `mapDataError` 旁边);`/actions` 改调它,MCP 的 `run_action` / `resume_run` 也改调它。 - 为什么改:MCP 这两个工具对沙箱动作体不带 code 的 `throw` 直接把带动作体名字的包装文本回给 agent,而 `/actions` 回 `400 HOOK_ABORTED` 加内层那句话;裸 Error、`SQLITE_BUSY`、沙箱崩溃也各不相同。现在两边读同一个函数,不会再分叉。 - 风险与回滚:`/actions` 的回答逐字节不变(runtime 全量 555 个测试文件全绿,现有 pins 一条未改);MCP 这两个工具的错误回答按上表变化;抛出的 `details` 只随拒绝(状态码低于 500)带出,故障(500 及以上)不再带。回滚即还原本 PR。 - 先看:`packages/types/src/actions-door-answer.ts`(级联本体,注释随迁),`packages/runtime/src/domains/actions.ts` 的 catch 尾部,`packages/mcp/src/mcp-http-tools.ts` 的 `errorResultFromThrown`。 - 两分钟验证:`pnpm --filter @objectstack/mcp exec vitest run src/mcp-coded-refusal.test.ts -t 31271`,预期 17 条全绿。 - 需要你做的:无,席位落地(落地前要一次 TIER_DEFAULT 契约复审,因为 `@objectstack/types` 多了一个公开导出)。 ## What changes - **`@objectstack/types`: new `actionsDoorAnswerOf(err, objectName?)` and its `ActionsDoorAnswer` type** (`src/actions-door-answer.ts`, exported from the index beside `data-error-classification`). It is the `/api/v1/actions` door's answer to a thrown value: the arms its catch spelled inline, moved in the same order with the reasons and history each one carried (the sandbox wrapper kept off the wire through `sandboxBusinessMessage`; a sandboxed crash is the `500` fault; a declared status is served through `resolveThrownHttpError` at a `500` fallback; a driver NOT NULL refusal answers `driverNotNullRefusalAnswer`'s arm; an unexpected fault is the sanitised `500`; everything else is the `400` rejection). Arms 3 and 6 answer as the dispatcher answers a thrown value: the closed code, the engine unique class at its wire spelling with the REST arm's context, the demoted `declaredCode`, a declared `userMessage`, and a leaky 5xx sentence withheld. It returns the operator lines the door prints (the full wrapper, the fault line). - **`@objectstack/runtime` `domains/actions.ts`**: the catch keeps its earned-answer arm (declarative update) and answers everything else through `actionsDoorAnswerOf`, printing its operator lines with the same `[action OBJECT/ACTION]` prefix and building the body with `apiErrorResponse`, the builder the dispatcher's `error` / `errorFromThrown` exits use. Refactor only: no answer moves. - **`@objectstack/mcp` `errorResultFromThrown`** (`run_action`, `resume_run`) answers through the same function: `{ error: { code, message, status, declaredCode?, userMessage?, details? } }`. The `PERMISSION_DENIED` withhold is kept. #15942's thrown `details` now ride only on a refusal (status below 500), never on a fault, which answers only the door's sanitised envelope. The context the door adds (`fields`, `issues`, the unique arm's `object` / `field`) is not served, as on every other MCP tool. | `run_action` / `resume_run` caught | Before (main 7c523aa) | After (= `/actions`) | | --- | --- | --- | | code-less sandboxed body abort | bare wrapper text naming the body | `400 HOOK_ABORTED`, the inner sentence | | bare `Error('socket hang up')` | bare text, no code | `400 VALIDATION_ERROR`, `socket hang up` | | `SQLITE_BUSY` driver error | `{ code: SQLITE_BUSY, message }`, no status | `400 VALIDATION_ERROR`, `declaredCode: SQLITE_BUSY` | | sandboxed crash, no code | bare wrapper with `TypeError: boom` | `500 INTERNAL_ERROR`, `Internal server error` | | sandboxed crash declaring `RECORD_LOCKED` / 409 | same as above | same `500` fault | | app's own unregistered code `LEAVE_EXHAUSTED` / 409 | `code: LEAVE_EXHAUSTED`, 409 | `409 RESOURCE_CONFLICT`, `declaredCode: LEAVE_EXHAUSTED` | | a fault (503, or a sandboxed crash) carrying thrown `details` | bare text, no `details` | no `details`, the door's envelope only | | CONTROL: coded sandboxed refusal `RECORD_LOCKED` / 409 | `RECORD_LOCKED`, inner sentence, 409 | unchanged | | CONTROL: `ACTION_CONFIRMATION_REQUIRED` / 428 with `details` | code, 428, `details` | unchanged | ## Tests - New `packages/types/src/actions-door-answer.test.ts`: 10 rows, one per arm plus controls (abort, bare Error, driver string code, sandboxed crash with and without a code, in-process crash, coded refusal, declared status with an unregistered code, leaky declared 5xx, `userMessage`, NOT NULL arm). - `packages/mcp/src/mcp-coded-refusal.test.ts`: new #31271 block, 17 rows (the 5 cases on each tool, each asserting the literal answer AND equality with `actionsDoorAnswerOf` on a fresh copy of the throw, no body name, no `TypeError`, the operator log count; the unregistered `LEAVE_EXHAUSTED` / 409 row on each tool; the fault-with-`details` row on each tool, which also keeps a 428 refusal's `details`; the CONTROL on each tool). Two existing rows move with the behaviour: the #23509 code-less row for the two action tools now pins the door's `400` rejection, and the #31218 bare-message CONTROL excludes them. `mcp-action-confirmation-member.test.ts`: the uncoded-failure row now pins the door's answer. - Runtime `/actions` pins: unedited, all green. - Fallout, `packages/cli/test/package-install-local-handlers.integration.test.ts`: the ghost-action `run_action` pin read the old bare text with `String(payload)`. It now reads the envelope: `VALIDATION_ERROR`, `400`, and a message that still says `No handler registered`. ## Acceptance notes - **The `/actions`-vs-`/data` difference on a driver string code stays.** `/actions` answers `SQLITE_BUSY` `400 VALIDATION_ERROR` with `declaredCode`; `/api/v1/data` answers `500 DATABASE_ERROR`. These tools now answer what `/actions` answers, per the seat ruling; which REST door is right is not decided here. - **One composition left beside the reader.** The dispatcher's `errorFromThrown` / `error` (`http-dispatcher.ts`, outside this claim's surface) still compose the same `@objectstack/types` pieces for every other dispatcher domain. The `/actions` door no longer goes through them; its bodies are held equal by its existing pins (runtime suite green unedited). - **`details` on the action tools.** Thrown `details` only, and only on a refusal (status below 500); a fault serves the door's sanitised envelope without them. The door's added context is not served on MCP, as `errorResultWithCode` explains for every tool. - **A ghost action (declared, no handler).** MCP `run_action` now answers it `400 VALIDATION_ERROR` with `No handler registered…`, because the bridge's `invokeBusinessAction` throws an uncoded error. `POST /api/v1/actions` answers the same action `404` from its routing-miss branch before any handler runs. That producer-side difference is outside this PR. - **Earned answers.** The declarative-update earned-answer arm stays at the door (it needs the action definition); MCP's `landedWriteResultOrError` already reads it before this renderer. - **Superseded changeset line.** The pending `31218` changeset says `run_action` and `resume_run` are unchanged by that card; this PR's changeset supersedes that sentence and does not edit it. ## Verification All on head `7d083983` (base `7c523aa1`); the first round ran on `73bb0469`. <details><summary>Red first, fix, suites</summary> - Premise on `7c523aa1`, over `/mcp` (scratch probe, not committed): `run_action` and `resume_run` answered `"action 'demo_body' threw: Error: Not enough leave"`, `"socket hang up"`, `{ code: SQLITE_BUSY, message }` with no status, and the wrapper with `TypeError: boom` for a crash with or without a code; the coded refusal CONTROL answered `RECORD_LOCKED` / `Locked.` / 409. - Red first: the new pins on the base MCP source, `mcp-coded-refusal.test.ts`: Tests 12 failed | 94 passed (106) (10 new rows, the 2 moved code-less rows; the CONTROLs green). With the fix: 106 passed. - `pnpm --filter @objectstack/mcp test` on `7d083983`: Test Files 60 passed (60), Tests 839 passed (839). - `packages/cli/test/package-install-local-handlers.integration.test.ts` on `7d083983`: Test Files 1 passed (1), Tests 17 passed (17). - `pnpm --filter @objectstack/types test`: Test Files 37 passed (37), Tests 870 passed (870). - `pnpm --filter @objectstack/runtime test` (types aliased to source; runtime source identical since `8b4876fb`): Test Files 555 passed (555), Tests 8939 passed, 19 skipped; lock VERDICT command-exit 0. No `/actions` pin edited. - `typecheck` for `@objectstack/types`, `@objectstack/runtime`, `@objectstack/mcp`: exit 0 (runtime test layer 27 files / 186 errors / 66 pinned signatures held; mcp 6 / 53 / 8 held). - `eslint --no-inline-config` on all 8 touched `.ts` files across two runs (the 7 of the first run, then the 3 this hop touched): exit 0. </details> <details><summary>Ablations (ablation-replace, restored to HEAD each time)</summary> - `mcp-http-tools.ts`: the PERMISSION_DENIED line rewritten to answer `errorResult(messageOf(err))` for every throw (bypassing the reader's answer). Mutation landed (anchor 1 to 0, blob 33371b9 to 2b20f1be220e); `mcp-coded-refusal.test.ts` + `mcp-action-confirmation-member.test.ts`: Tests 21 failed | 91 passed (112). Restored: blob == HEAD, `git diff HEAD` empty. - `actions-door-answer.ts`: the sandboxed-crash arm disabled (a condition that is never true, `String("ABL").length` less than 0). Types `actions-door-answer.test.ts`: 1 failed | 9 passed (the crash row); runtime `actions-undeclared-hook-abort-code.test.ts` + `nested-hook-refusal-is-a-rejection.test.ts` (types aliased to source): 1 failed | 11 passed, the `/actions` crash pin, which shows the door answers through the reader. Restored: blob == HEAD, `git diff HEAD` empty. Both suites import source; no dist involved. - Details guard (`mcp-http-tools.ts`): the `status < 500` condition made always true (`|| String("ABL").length > 0`), so thrown `details` ride on every answer. Mutation landed (anchor 1 to 0, blob 91fd3c0 to 5d4314c7940f). `mcp-coded-refusal.test.ts -t 31271`: Tests 2 failed | 15 passed (the fault-with-`details` row on each tool). Restored: blob == HEAD. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `7d083983`: 67 commands (the cli test file adds `check:cli-test-child-env`), each run with stdin from `/dev/null`, all exit 0 on the first run. `dispatch-gates --ran`: 67 derived families accounted for, 67 run, 0 NOT-MEASURED. The cli integration suite is not a derived family; the one edited file was run on its own (above). Not run locally (CI): `test:repo`, dogfood shards, type-check lanes. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5pVLKxR5eAhTYetnwR)_ <!-- signed: domain:server#6 · os-project-manager · session_01FEkQ5p3T5YWhLjurajudnQ --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c2bc76e commit f4b4c8a

9 files changed

Lines changed: 732 additions & 239 deletions
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/types': patch
3+
'@objectstack/runtime': patch
4+
'@objectstack/mcp': patch
5+
---
6+
7+
fix(mcp): `run_action` and `resume_run` answer a thrown error the way `POST /api/v1/actions` does, through that door's one reader
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: none
11+
12+
MCP `run_action` and `resume_run` answered an error thrown while running an action differently from `POST /api/v1/actions/:object/:action`, which runs the same action:
13+
14+
| The action threw | MCP answered | `/actions` answers, and MCP now answers |
15+
| --- | --- | --- |
16+
| A sandboxed body's own `throw new Error('Not enough leave')` | the runner's wrapper as bare text, naming the body: `action 'NAME' threw: Error: Not enough leave` | `400`, `HOOK_ABORTED`, `Not enough leave` |
17+
| A plain `Error` | its bare text, no code | `400`, `VALIDATION_ERROR`, its own sentence |
18+
| A driver error with a string code (`SQLITE_BUSY`) | `{ code: 'SQLITE_BUSY', message }`, no status | `400`, `VALIDATION_ERROR`, `declaredCode: 'SQLITE_BUSY'` |
19+
| A sandboxed body that crashed (`TypeError`), with or without a declared code | the wrapper with the native error text | `500`, `INTERNAL_ERROR`, `Internal server error` |
20+
21+
The tool error is `{ "error": { "code", "message", "status" } }` with `isError`, plus `declaredCode` and `userMessage` when the door serves them. The full wrapper and the fault line are logged on the server, as the door logs them. A refusal with a registered code (a body that throws `{ code: 'RECORD_LOCKED', status: 409 }`) keeps its code, its own sentence and its status. A refusal with an app's own unregistered code (`{ code: 'LEAVE_EXHAUSTED', status: 409 }`) now crosses as `/actions` serves it: the closed code its status derives (`RESOURCE_CONFLICT`), with `declaredCode: 'LEAVE_EXHAUSTED'` beside it; MCP used to answer `code: 'LEAVE_EXHAUSTED'`. A thrown `details` (the confirmation gate's) still rides on a refusal and is no longer served on a fault (status 500 and above), which answers only the door's sanitised envelope; the `PERMISSION_DENIED` withhold is unchanged. This supersedes the line in the `31218` changeset that says `run_action` and `resume_run` are unchanged.
22+
23+
A driver string code answers what `/actions` answers. `/api/v1/data` answers the same throw `500 DATABASE_ERROR`; that difference between the two REST doors is not changed here.
24+
25+
**New export (`@objectstack/types`): `actionsDoorAnswerOf(err, objectName?)` and its `ActionsDoorAnswer` type**, the `/api/v1/actions` door's answer to a thrown value, beside `mapDataError` (the `/api/v1/data` door's). The `/actions` door (`@objectstack/runtime`) now answers through it; its responses are unchanged byte for byte.

‎packages/cli/test/package-install-local-handlers.integration.test.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -388,7 +388,16 @@ describe('#21321: an install-local package runs its script actions and body hook
388388
it('list_actions does not advertise a declared action run_action cannot run', () => {
389389
const p = phases[name]!;
390390
expect(p.ghostRun.isError, 'precondition: ghost_task has no handler anywhere').toBe(true);
391-
expect(String(p.ghostRun.payload)).toMatch(/No handler registered/);
391+
// [#31271] `run_action` answers a throw through the `/actions` door's
392+
// reader (`actionsDoorAnswerOf`), so the bridge's uncoded "No handler
393+
// registered" throw arrives as that door's 400 rejection envelope, its
394+
// sentence intact. (`POST /api/v1/actions` answers the same ghost action
395+
// from its own routing-miss branch, a 404, before any throw.)
396+
expect(p.ghostRun.payload?.error, JSON.stringify(p.ghostRun.payload)).toMatchObject({
397+
code: 'VALIDATION_ERROR',
398+
status: 400,
399+
message: expect.stringMatching(/No handler registered/),
400+
});
392401
expect(p.listed, 'advertised an action the run door refuses').not.toContain('ghost_task');
393402
});
394403
});

‎packages/mcp/src/mcp-action-confirmation-member.test.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,10 @@ describe('run_action carries the confirmation member through the real MCP door (
203203
expect(bridge.calls).toHaveLength(0);
204204
});
205205

206-
it('leaves an UNCODED bridge failure as a plain message (the envelope widens, it narrows nothing)', async () => {
206+
// [#31271] An uncoded bridge failure answers what `POST /api/v1/actions`
207+
// answers it (`actionsDoorAnswerOf`): that door's 400 rejection, with the
208+
// failure's own sentence and no `details`.
209+
it('answers an UNCODED bridge failure as the /actions door does: 400 VALIDATION_ERROR, its own sentence', async () => {
207210
const failing = {
208211
...bridge,
209212
async runAction() {
@@ -214,6 +217,8 @@ describe('run_action carries the confirmation member through the real MCP door (
214217
const res = await runtime.handleHttpRequest(mcpRequest(body), { bridge: failing, parsedBody: body });
215218
const json: any = await res.json();
216219
expect(json.result.isError).toBe(true);
217-
expect(json.result.content[0].text).toBe('handler exploded');
220+
expect(JSON.parse(json.result.content[0].text)).toEqual({
221+
error: { code: 'VALIDATION_ERROR', message: 'handler exploded', status: 400 },
222+
});
218223
});
219224
});

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

Lines changed: 145 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
4949
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
5050
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
5151
import { recordNotFoundError } from '@objectstack/core';
52-
import { mapDataError } from '@objectstack/types';
52+
import { actionsDoorAnswerOf, mapDataError } from '@objectstack/types';
5353
import type { IMetadataService } from '@objectstack/spec/contracts';
5454

5555
import { MCPServerRuntime } from './mcp-server-runtime.js';
@@ -214,6 +214,18 @@ describe('MCP tools over HTTP /mcp: a coded refusal carries its code (#23509)',
214214
error: { code: 'INTERNAL_ERROR', message: 'Internal server error', status: 500 },
215215
});
216216
});
217+
} else if (site.actionDoor) {
218+
// [#31271] `run_action` / `resume_run` answer a code-less throw as the
219+
// `/api/v1/actions` door does (`actionsDoorAnswerOf`): a plain `Error`
220+
// is that door's rejection, `400 VALIDATION_ERROR` with its own
221+
// sentence. The cases live in the #31271 block below.
222+
it(`${site.tool}: a code-less throw from ${String(site.member)} answers the /actions door's 400 rejection`, async () => {
223+
const result = await callTool(site, new Error('engine exploded'));
224+
expect(result.isError).toBe(true);
225+
expect(JSON.parse(result.content[0].text)).toEqual({
226+
error: { code: 'VALIDATION_ERROR', message: 'engine exploded', status: 400 },
227+
});
228+
});
217229
} else {
218230
it(`${site.tool}: a code-less throw from ${String(site.member)} answers its bare message, as before`, async () => {
219231
const result = await callTool(site, new Error('engine exploded'));
@@ -428,8 +440,8 @@ describe('the record data tools: a code-less throw answers what the /data door a
428440
expect(log).not.toHaveBeenCalled();
429441
});
430442

431-
it('CONTROL: the tools that are not record data tools still answer a code-less throw with its bare message', async () => {
432-
for (const site of SITES.filter((s) => !s.dataDoor)) {
443+
it('CONTROL: the tools that are neither record data tools nor action tools still answer a code-less throw with its bare message', async () => {
444+
for (const site of SITES.filter((s) => !s.dataDoor && !s.actionDoor)) {
433445
const result = await callTool(site, sandboxAbort('Not enough annual leave remaining'));
434446
expect(result, site.tool).toEqual({
435447
content: [{ type: 'text', text: "hook 'demoleave_leave_request_guard' threw: Error: Not enough annual leave remaining" }],
@@ -757,6 +769,136 @@ describe('a coded sandboxed CRASH is a fault on every face, never a refusal at i
757769
});
758770
});
759771

772+
/**
773+
* [#31271] `run_action` / `resume_run` answer a throw as the `/api/v1/actions`
774+
* door answers it: through that door's own reader, `actionsDoorAnswerOf`
775+
* (`@objectstack/types`), never a copy. Each row asserts the literal answer
776+
* AND its equality with the reader's on a fresh copy of the same throw, so the
777+
* pin cannot pass by the two agreeing on something nobody wrote down.
778+
*
779+
* These tools used to answer a code-less sandboxed body abort with the
780+
* runner's `KIND 'NAME' threw: …` wrapper naming the body, a bare `Error` with
781+
* its bare text and no code, a driver string code as `{ code: SQLITE_BUSY }`
782+
* with no status, and a sandboxed crash (coded or not) with the wrapper and
783+
* the native error text.
784+
*
785+
* ⚠️ `SQLITE_BUSY` answers what `/actions` answers, `400 VALIDATION_ERROR` with
786+
* `declaredCode`; `/api/v1/data` answers the same throw `500 DATABASE_ERROR`.
787+
* That REST-side difference is not this card's.
788+
*/
789+
describe('run_action / resume_run: a throw answers what the /actions door answers (#31271)', () => {
790+
const ACTION_SITES = SITES.filter((s) => s.actionDoor);
791+
const BODY_WRAPPER = "action 'demo_leave_body' threw: Error: Not enough annual leave remaining";
792+
const CRASH_WRAPPER = "action 'demo_leave_body' threw: TypeError: boom";
793+
794+
const CASES: ReadonlyArray<{ name: string; thrown: () => unknown; answer: Record<string, unknown>; logs: number }> = [
795+
{
796+
name: 'a code-less sandboxed body abort: 400 HOOK_ABORTED with the inner sentence',
797+
thrown: () => Object.assign(new Error(BODY_WRAPPER), { name: 'SandboxError', innerMessage: 'Not enough annual leave remaining' }),
798+
answer: { code: 'HOOK_ABORTED', message: 'Not enough annual leave remaining', status: 400 },
799+
logs: 1,
800+
},
801+
{
802+
name: 'a bare Error: the door\'s 400 rejection, VALIDATION_ERROR with its own sentence',
803+
thrown: () => new Error('socket hang up'),
804+
answer: { code: 'VALIDATION_ERROR', message: 'socket hang up', status: 400 },
805+
logs: 0,
806+
},
807+
{
808+
name: 'a driver string code: 400 VALIDATION_ERROR, SQLITE_BUSY demoted to declaredCode',
809+
thrown: () => Object.assign(new Error('SQLITE_BUSY: database is locked'), { code: 'SQLITE_BUSY' }),
810+
answer: { code: 'VALIDATION_ERROR', message: 'SQLITE_BUSY: database is locked', status: 400, declaredCode: 'SQLITE_BUSY' },
811+
logs: 0,
812+
},
813+
{
814+
name: 'a sandboxed CRASH with no code: 500 INTERNAL_ERROR, the generic sentence',
815+
thrown: () => Object.assign(new Error(CRASH_WRAPPER), { name: 'SandboxError', innerMessage: 'TypeError: boom' }),
816+
answer: { code: 'INTERNAL_ERROR', message: 'Internal server error', status: 500 },
817+
logs: 1,
818+
},
819+
{
820+
name: 'a sandboxed CRASH that declared a code and status: the same fault; neither is served',
821+
thrown: () => Object.assign(new Error(CRASH_WRAPPER), {
822+
name: 'SandboxError', innerMessage: 'TypeError: boom', code: 'RECORD_LOCKED', status: 409,
823+
}),
824+
answer: { code: 'INTERNAL_ERROR', message: 'Internal server error', status: 500 },
825+
logs: 1,
826+
},
827+
];
828+
829+
afterEach(() => {
830+
vi.restoreAllMocks();
831+
});
832+
833+
it('the two action tools are the rows this block drives', () => {
834+
expect(ACTION_SITES.map((s) => s.tool).sort()).toEqual(['resume_run', 'run_action']);
835+
});
836+
837+
for (const site of ACTION_SITES) {
838+
for (const c of CASES) {
839+
it(`${site.tool}: ${c.name}`, async () => {
840+
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
841+
const result = await callTool(site, c.thrown());
842+
expect(result.isError).toBe(true);
843+
expect(result.content).toHaveLength(1);
844+
const text = String(result.content[0].text);
845+
const { error } = JSON.parse(text);
846+
expect(error).toEqual(c.answer);
847+
// The door's reader, on a fresh copy of the same throw.
848+
const door = actionsDoorAnswerOf(c.thrown());
849+
expect(error).toEqual({
850+
code: door.code,
851+
message: door.message,
852+
status: door.status,
853+
...(door.declaredCode !== undefined ? { declaredCode: door.declaredCode } : {}),
854+
});
855+
expect(text).not.toContain('demo_leave_body');
856+
expect(text).not.toContain('TypeError');
857+
// The wrapper and the fault line reach the operator, as the door prints them.
858+
expect(log).toHaveBeenCalledTimes(c.logs);
859+
});
860+
}
861+
862+
it(`${site.tool}: an app's own unregistered code crosses as the status-derived closed code, its spelling in declaredCode`, async () => {
863+
vi.spyOn(console, 'error').mockImplementation(() => {});
864+
const thrown = () => Object.assign(new Error('No leave days left this year.'), { code: 'LEAVE_EXHAUSTED', status: 409 });
865+
const { error } = JSON.parse((await callTool(site, thrown())).content[0].text);
866+
expect(error).toEqual({
867+
code: 'RESOURCE_CONFLICT', message: 'No leave days left this year.', status: 409, declaredCode: 'LEAVE_EXHAUSTED',
868+
});
869+
const door = actionsDoorAnswerOf(thrown());
870+
expect([door.code, door.declaredCode, door.status]).toEqual([error.code, error.declaredCode, error.status]);
871+
});
872+
873+
it(`${site.tool}: a fault carries no thrown details; a refusal still does`, async () => {
874+
vi.spyOn(console, 'error').mockImplementation(() => {});
875+
const details = { host: 'db-primary.internal', pool: 'writer' };
876+
for (const fault of [
877+
Object.assign(new Error('pool exhausted'), { status: 503, details }),
878+
Object.assign(new Error(CRASH_WRAPPER), { name: 'SandboxError', innerMessage: 'TypeError: boom', details }),
879+
]) {
880+
const result = await callTool(site, fault);
881+
const { error } = JSON.parse(result.content[0].text);
882+
expect(error.status).toBeGreaterThanOrEqual(500);
883+
expect(error).not.toHaveProperty('details');
884+
expect(result.content[0].text).not.toContain('db-primary.internal');
885+
}
886+
const refusal = Object.assign(new Error('Confirm first.'), { code: 'ACTION_CONFIRMATION_REQUIRED', status: 428, details });
887+
expect(JSON.parse((await callTool(site, refusal)).content[0].text).error).toEqual({
888+
code: 'ACTION_CONFIRMATION_REQUIRED', message: 'Confirm first.', status: 428, details,
889+
});
890+
});
891+
892+
it(`${site.tool}: CONTROL: a coded sandboxed refusal keeps the #31246 answer (code, inner sentence, status)`, async () => {
893+
vi.spyOn(console, 'error').mockImplementation(() => {});
894+
const result = await callTool(site, codedSandboxRefusal());
895+
expect(JSON.parse(result.content[0].text)).toEqual({
896+
error: { code: 'RECORD_LOCKED', message: SANDBOX_SENTENCE, status: 409 },
897+
});
898+
});
899+
}
900+
});
901+
760902
/**
761903
* [#31272] The metadata tools (`list_objects`, `describe_object`,
762904
* `validate_expression`) and `list_actions` answer a FAULT as the `/meta` door

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

Lines changed: 49 additions & 11 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, resolveThrownHttpError, sandboxBusinessMessage, serverFaultProvenance, staleSummaryAnswerLines, structuredCodeAnswer, type SummaryRecomputeRefusal } from '@objectstack/types';
53+
import { actionsDoorAnswerOf, 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,
@@ -441,8 +441,6 @@ function errorResult(message: string) {
441441
* nobody and the agent is back to guessing the member's spelling from
442442
* documentation.
443443
*
444-
* Uncoded throws fall through to `errorResult` unchanged.
445-
*
446444
* `run_action` and `resume_run` only. The other catches render through
447445
* {@link errorResultWithCode}, which serves the same envelope WITHOUT `details`;
448446
* that function says why.
@@ -465,12 +463,41 @@ function errorResult(message: string) {
465463
* reaches the `details` read at all. Every other coded refusal keeps its
466464
* `details` as before, so #15942's confirmation contract and `resume_run`'s
467465
* REST-built `details` are unchanged.
466+
*
467+
* [#31271] The answer is the `/api/v1/actions` door's, read off that door's own
468+
* reader, `actionsDoorAnswerOf` (`@objectstack/types`), never a copy of its
469+
* arms: a code-less sandboxed body abort answers `400 HOOK_ABORTED` with the
470+
* body's sentence (it answered the runner's `KIND 'NAME' threw: …` wrapper
471+
* naming the body), a plain `Error` the door's `400 VALIDATION_ERROR`
472+
* rejection, a sandboxed or in-process crash the door's `500` fault with no
473+
* body name and no native text, and an unregistered code is demoted to
474+
* `declaredCode` beside the closed `code`, as the door serves it. The lines
475+
* the door prints for the operator (the full wrapper, the fault line) are
476+
* printed here under `request`. A thrown `details` rides on a refusal (a
477+
* status below 500) as before, and never on a fault, whose answer is the
478+
* door's sanitised one; the context the door adds (`fields`, `issues`, the
479+
* unique arm's `object` / `field`) is not served, as on every other tool
480+
* ({@link errorResultWithCode} says why).
481+
*
482+
* ⚠️ A driver string code (`SQLITE_BUSY`) answers what `/actions` answers,
483+
* `400 VALIDATION_ERROR` with `declaredCode`, where `/api/v1/data` answers
484+
* `500 DATABASE_ERROR`. That difference is between the two REST doors.
468485
*/
469-
function errorResultFromThrown(err: unknown) {
470-
const envelope = thrownRefusalEnvelope(err);
471-
if (!envelope) return errorResult(messageOf(err));
472-
if (envelope.code === StandardErrorCode.enum.PERMISSION_DENIED) return errorResultWithCode(err);
473-
const details = (err as { details?: unknown }).details;
486+
function errorResultFromThrown(err: unknown, request: string, objectName?: string) {
487+
const answer = actionsDoorAnswerOf(err, objectName);
488+
for (const line of answer.operatorLines) console.error(`[MCP] ${request}: ${line}`);
489+
const envelope = {
490+
code: answer.code,
491+
message: answer.message,
492+
status: answer.status,
493+
...(answer.declaredCode !== undefined ? { declaredCode: answer.declaredCode } : {}),
494+
...(answer.userMessage !== undefined ? { userMessage: answer.userMessage } : {}),
495+
};
496+
if (answer.code === StandardErrorCode.enum.PERMISSION_DENIED) return codedErrorResult(envelope);
497+
// A fault (5xx) carries nothing but the door's sanitised answer: a thrown
498+
// `details` rides only on a refusal, as `/actions` serves no producer
499+
// context on a fault.
500+
const details = answer.status < 500 ? (err as { details?: unknown } | null | undefined)?.details : undefined;
474501
return codedErrorResult({ ...envelope, ...(details !== undefined ? { details } : {}) });
475502
}
476503

@@ -695,7 +722,14 @@ function landedWriteResultOrError(
695722
return textResult(earned);
696723
}
697724

698-
function codedErrorResult(error: { code?: string; message: string; status?: number; details?: unknown }) {
725+
function codedErrorResult(error: {
726+
code?: string;
727+
message: string;
728+
status?: number;
729+
declaredCode?: string;
730+
userMessage?: string;
731+
details?: unknown;
732+
}) {
699733
return {
700734
content: [{ type: 'text' as const, text: jsonText({ error }) }],
701735
isError: true as const,
@@ -2571,7 +2605,11 @@ export function registerActionTools(
25712605
});
25722606
return textResult(result);
25732607
} catch (err) {
2574-
return landedWriteResultOrError(err, objectName, errorResultFromThrown);
2608+
return landedWriteResultOrError(
2609+
err,
2610+
objectName,
2611+
(thrown) => errorResultFromThrown(thrown, `run_action ${actionName}`, objectName),
2612+
);
25752613
}
25762614
},
25772615
);
@@ -2665,7 +2703,7 @@ export function registerActionTools(
26652703
});
26662704
return textResult(result);
26672705
} catch (err) {
2668-
return errorResultFromThrown(err);
2706+
return errorResultFromThrown(err, `resume_run ${runId}`);
26692707
}
26702708
},
26712709
);

0 commit comments

Comments
 (0)