Skip to content

Commit 0fb55fc

Browse files
fix(mcp): run_action and resume_run withhold a PERMISSION_DENIED's details, as the data tools do (#23529) (#23544)
Fixes #23529 Clause-②: no `run_action` and `resume_run` now withhold a `PERMISSION_DENIED` refusal's `details`, as the data tools, the record resource, the runtime dispatcher and the REST actions door already do (the ruling on #7450). Every other coded refusal on the two tools keeps its `details`, so #15942's confirmation contract is unchanged. Dispatched by the PM loop, `domain:spec` seat 4, building round after triage's regrade (comment 6177046363), claim 6177192097, session `session_0121ooBYRmS8ziFXm9ai5YHm`. ## What changed - `packages/mcp/src/mcp-http-tools.ts`, `errorResultFromThrown` only (plus the one import it reads). A thrown refusal whose declared code is the standard catalogue's `PERMISSION_DENIED` now renders through `errorResultWithCode`, the function the data tools already render through, so its `details` are withheld. The code is spelled from `StandardErrorCode` (`@objectstack/spec/api`, already a dependency), not by hand. Every other coded refusal keeps its `details`. The `run_action` and `resume_run` catches are unchanged: both already call `errorResultFromThrown`. - `packages/mcp/src/mcp-action-refusal-details.test.ts`: the pin, on the MCP HTTP face over the real Streamable HTTP transport, the way `mcp-coded-refusal.test.ts` drives it. - `.changeset/23529-mcp-action-tools-withhold-permission-details.md`: `@objectstack/mcp` patch. ## Why The security gate's `PERMISSION_DENIED` carries the caller's own positions and permission-set names in `details`. The ruling on #7450 keeps those off the wire. The dispatcher drops them, the REST actions door answers through `resolveThrownHttpError`'s closed `details` list, and since #23509 the data tools render through `errorResultWithCode`, which never reads `details`. The two action tools rendered through `errorResultFromThrown`, which spread `details` verbatim into a tool result. That tool result rides a JSON-RPC 200, so the dispatcher's withholding never saw it. Reach, read from source (reading 6176605181, re-read on this tree): `executeDeclarativeUpdateAction` writes through `callData('update', …, ec)` as the caller, and nothing between the security middleware and `errorResultFromThrown` catches the gate's write-side refusal. The subject load does not carry it: `loadActionSubjectRecord`'s bare catch drops the throw and `refuseDeniedSubjectLoad` answers `RECORD_NOT_FOUND` with no `details`. ## Route: the first choice measured, then the second - **First choice, measured and not taken.** `resolveThrownHttpError` (`@objectstack/types`) builds `details` from a closed list (non-string `code`, `issues`, validation `fields`) and never reads the thrown `details`. A probe on its built output: ```text ACTION_CONFIRMATION_REQUIRED -> code ACTION_CONFIRMATION_REQUIRED status 428 details undefined PERMISSION_DENIED -> code PERMISSION_DENIED status 403 details undefined ``` It withholds the denial, but it also drops the confirmation refusal's retry `details`, which `mcp-action-confirmation-member.test.ts` and `mcp-resume-run-tool.test.ts` pin. So it would break #15942. - **Taken: the withholding keyed on the declared code, through an existing export.** No key-name list. No second filter of `details`: the class's answer is `errorResultWithCode`'s, reused. Every `PermissionDeniedError` declares this code, because it is a class constant. Every throw in `plugin-security` that puts positions or permission sets in `details` is that class. A throw with no code never reaches the `details` read. - No other package is edited: `packages/runtime`, `packages/types` and `plugin-security` are untouched, and so is `packages/mcp/src/plugin.ts` (#23367 has it in flight). ## The pin: red on `main`, green after The bridge is a double, because `@objectstack/mcp` does not depend on `@objectstack/runtime`. It lets the caller read the subject row, and it refuses both action tools with the gate's `PermissionDeniedError` shape. The assertions: the tool error carries `code` `PERMISSION_DENIED` and status 403. Then each position and permission-set name is checked against the whole HTTP response body, not one key. A control row on each tool keeps `ACTION_CONFIRMATION_REQUIRED`'s `details` served. Run against `main`'s renderer (base `e1cefa58e3`, the pin added, no fix): ```text ❯ src/mcp-action-refusal-details.test.ts (5 tests | 2 failed) × run_action: PERMISSION_DENIED answers code, message and status, and the caller's grants appear nowhere × resume_run: PERMISSION_DENIED answers code, message and status, and the caller's grants appear nowhere + "details": { + "object": "crm_case", + "operation": "update", + "permissionSets": [ "pin23529_set_case_read_only", "pin23529_set_member_default" ], + "positions": [ "pin23529_position_case_reader", "pin23529_position_everyone" ], Tests 2 failed | 3 passed (5) ``` After the fix, the pin and the three suites that must not move, unchanged: ```text src/mcp-action-refusal-details.test.ts src/mcp-action-confirmation-member.test.ts src/mcp-coded-refusal.test.ts src/mcp-resume-run-tool.test.ts Test Files 4 passed (4) Tests 62 passed (62) ``` `mcp-action-confirmation-member.test.ts` and `mcp-coded-refusal.test.ts` (its `RECORD_NOT_FOUND` rows included) are byte-unchanged by this PR. **`resume_run` is covered by a pin, at the renderer.** It calls the same `errorResultFromThrown`, and its row fails and passes with `run_action`'s. By reading, nothing reaches it today: it makes no caller-scope write of its own, and its subject re-read ends at `RECORD_NOT_FOUND`. So its row guards the shared renderer, not a measured producer. ## Ablation `node scripts/ablation-replace.mjs`, wrap mode, under the verify lock. The mutation deletes the guard line and the pin runs on the mutated file. The subject resolves through the package's own `src/`, so no build or `dist/` leg applies. ```text ablation-replace: anchor x1 -> x0 ablation-replace: blob 232f1b5 -> c6feca3cd8d737389cf84d21191c717c7c5e149a × run_action: PERMISSION_DENIED answers code, message and status, and the caller's grants appear nowhere × resume_run: PERMISSION_DENIED answers code, message and status, and the caller's grants appear nowhere Tests 2 failed | 3 passed (5) ablation-replace: ok restored: blob == HEAD (232f1b5) and `git diff HEAD` is empty ``` The direction is the ordinary one: the two denial rows turn red and the two confirmation controls stay green. That shows the guard is keyed on the code and is not a blanket withhold. `git status` was clean after the restore. `232f1b56c0` is still this file's blob at the head. ## Tests and gates, at head `03c6a098a3` - `pnpm --filter @objectstack/mcp test`: `Test Files 39 passed (39)`, `Tests 467 passed (467)`. - `pnpm --filter @objectstack/mcp typecheck`: exit 0, and `check:test-typecheck: OK` with no new debt. The new test file compiles under `tsconfig.test.json`. - `pnpm --filter @objectstack/mcp build`: exit 0. The CJS and ESM builds both load. The CJS bundle requires `@objectstack/spec/api`, and it resolves. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 63 commands. All 63 were run, with each exit code captured before any pipe. Reconciliation: `✓ dispatch-gates --ran: 63 derived famil(ies) accounted for — 63 run, 0 NOT-MEASURED (a DERIVED zero — all 63 recorded an exit code and none of them is 3)`. - Two gates first answered `PREREQUISITE NOT MET` (exit 3): `check:lean-entry-closure` and `check:dual-build-cjs-loads`. I built their prerequisites (`turbo run build`, all but one task a cache hit) and re-ran them. Both exit 0, and those are the readings recorded. - `main` was merged once before this PR opened. The one incoming commit touched `packages/lint` only, and `@objectstack/lint` was rebuilt. - CI owns the repo-wide scans and the families that run over a whole root. Their check-runs on this PR are the evidence for them. ## Acceptance notes - **Residual, noted and not filed (no carrier).** The withholding is keyed on one code. Any other coded refusal's `details` still render verbatim on these two tools. REST passes a thrown refusal's `details` only through the closed list. No other producer that reaches these tools puts caller authorization data in `details` today. A fail-closed alternative is to serve only the confirmation refusal's `details`. It was not taken because it would also drop `resume_run`'s REST-built run artefacts on a failed resume, which narrows more than this card asks. - **Script-handler rethrow (the reading's second note).** By reading, `executeRegisteredAction` rethrows a handler's own error raw. A rethrown `PermissionDeniedError` keeps its code, so this renderer-level fix covers it with no producer change. No in-tree producer was found. - **The comment above `errorResultWithCode` is untouched.** It sits outside the claimed surface, and it still holds for that function. `errorResultFromThrown`'s own doc names the one code-keyed exception and why it is not a second copy of the policy. - **What this pin does not measure.** It does not cover the runtime layers between the gate and the bridge. Those rest on the reading and on #23509's measured fixture, where the gate's `details` reach the MCP HTTP face intact for a data verb. - **The stdio face** serves no action tools today (the reading's R3). The fix sits in the transport-agnostic renderer, so a host that bridged an action seam onto stdio would get it too. --- _Generated by [Claude Code](https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b9b6189 commit 0fb55fc

3 files changed

Lines changed: 221 additions & 2 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/mcp': patch
3+
---
4+
5+
fix(mcp): `run_action` and `resume_run` withhold a `PERMISSION_DENIED` refusal's `details`, as the data tools already do (#23529)
6+
7+
**What was wrong.** The two action tools render a thrown coded refusal through `errorResultFromThrown`, which served the refusal's `details` verbatim. The security gate's `PERMISSION_DENIED` carries the caller's own positions and permission-set names in `details`. Those are server-side diagnostics. The runtime dispatcher and the REST actions door withhold them, and the data tools and the record resource never serve a refusal's `details`. The action tools were the one place on the MCP surface that still served them, and `executeDeclarativeUpdateAction` writes as the caller, so the gate's write-side refusal reaches that renderer.
8+
9+
**What a client reads now.** A `PERMISSION_DENIED` on `run_action` or `resume_run` answers the shape the data tools answer: `isError: true`, and the text is the JSON `{ "error": { "code", "message", "status" } }` with no `details`. The code, the message and the status are unchanged.
10+
11+
Every other coded refusal on the two tools is unchanged. `ACTION_CONFIRMATION_REQUIRED` keeps the `details` that name the action and the member to set, and `resume_run`'s other refusals keep theirs. A throw with no code still answers its bare message.
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#23529] `run_action` and `resume_run` WITHHOLD a permission refusal's
5+
* `details`, as the data tools do (#23509) and as the runtime dispatcher does
6+
* (the ruling on #7450).
7+
*
8+
* ## What was open
9+
*
10+
* The security gate's `PERMISSION_DENIED` carries the caller's `positions` and
11+
* `permissionSets` in `details`. Those are server-side diagnostics: the
12+
* dispatcher drops them from the wire, the REST actions door answers through
13+
* the closed `details` list of `resolveThrownHttpError`, and the data tools
14+
* render through `errorResultWithCode`, which never reads `details`. The two
15+
* action tools rendered through `errorResultFromThrown`, which spread a thrown
16+
* refusal's `details` verbatim into a tool result. The tool result rides a
17+
* JSON-RPC 200, so the dispatcher's withholding never saw it.
18+
*
19+
* The reachable producer, read from source: a declarative update action
20+
* (`operation: 'update'`) writes as the caller. A caller who may read the row
21+
* but not update the object passes the subject load and meets the gate's
22+
* write-side refusal, and nothing between the gate and this package catches
23+
* it. A host script handler that rethrows a caller-scope refusal surfaces the
24+
* same way.
25+
*
26+
* ## What is pinned here
27+
*
28+
* - The HTTP `/mcp` face, through `handleHttpRequest` and the real Streamable
29+
* HTTP transport, as `mcp-coded-refusal.test.ts` drives it. The bridge is a
30+
* double, because this package does not depend on `@objectstack/runtime`:
31+
* it lets the caller read the subject row and refuses the declarative
32+
* update with the gate's `PermissionDeniedError` shape.
33+
* - The refusal answers `code` `PERMISSION_DENIED` and status 403, and the
34+
* caller's position and permission-set NAMES appear nowhere in the HTTP
35+
* response body: not under `details`, not anywhere else. A key-presence
36+
* check would pass a renderer that moved the names to another key.
37+
* - `resume_run` renders through the same function. No caller-scope write of
38+
* its own reaches it today (it re-reads the subject through the not-found
39+
* door), so its row pins the renderer, not a measured producer.
40+
* - The control: on the same two tools, `ACTION_CONFIRMATION_REQUIRED` still
41+
* serves its `details` (#15942). So the withholding is keyed on the
42+
* refusal's code, and the `details` channel is shown to be live.
43+
*/
44+
45+
import { describe, it, expect } from 'vitest';
46+
47+
import { AI_ACTION_CONFIRMATION_MEMBER } from '@objectstack/spec/contracts';
48+
49+
import { MCPServerRuntime } from './mcp-server-runtime.js';
50+
import type { McpActionBridge, McpDataBridge } from './mcp-http-tools.js';
51+
52+
type Bridge = McpDataBridge & McpActionBridge;
53+
54+
const OBJECT = 'crm_case';
55+
const RECORD_ID = 'case_1';
56+
/** A declarative update action: `operation: 'update'` writes the patch as the caller. */
57+
const ACTION = 'close_case';
58+
const RUN_ID = 'run_1';
59+
60+
/** Distinctive names, so a hit in the response body can only be a disclosure. */
61+
const POSITIONS = ['pin23529_position_case_reader', 'pin23529_position_everyone'];
62+
const PERMISSION_SETS = ['pin23529_set_case_read_only', 'pin23529_set_member_default'];
63+
64+
const DENIAL_MESSAGE =
65+
'You do not have permission to perform this action. Contact your administrator if you need access.';
66+
67+
/**
68+
* The security gate's write-side refusal, in the shape `PermissionDeniedError`
69+
* gives it: the localized sentence, `code`, both status spellings, the
70+
* diagnostics in `details`, and the operator's sentence as a SIBLING of
71+
* `details`.
72+
*/
73+
function writeSideDenial(): Error {
74+
const err = Object.assign(new Error(DENIAL_MESSAGE), {
75+
code: 'PERMISSION_DENIED',
76+
status: 403,
77+
statusCode: 403,
78+
details: { operation: 'update', object: OBJECT, positions: POSITIONS, permissionSets: PERMISSION_SETS },
79+
developerMessage:
80+
`[Security] Access denied: operation 'update' on object '${OBJECT}' `
81+
+ `is not permitted for positions [${POSITIONS.join(', ')}]`,
82+
});
83+
err.name = 'PermissionDeniedError';
84+
return err;
85+
}
86+
87+
/** The runtime's confirmation refusal (#15942): its `details` are the retry contract. */
88+
function confirmationRefusal(): Error {
89+
return Object.assign(
90+
new Error(`Action '${ACTION}' on '${OBJECT}' declares ai.requiresConfirmation: true — nothing was run.`),
91+
{
92+
code: 'ACTION_CONFIRMATION_REQUIRED',
93+
status: 428,
94+
details: { actionName: ACTION, objectName: OBJECT, confirmationMember: AI_ACTION_CONFIRMATION_MEMBER },
95+
},
96+
);
97+
}
98+
99+
/**
100+
* A caller who may READ the subject row and may not UPDATE the object. The
101+
* subject read answers the row; the declarative update (and the run it would
102+
* resume) is refused by the gate with `thrown`.
103+
*/
104+
function readerBridge(thrown: () => Error): Bridge {
105+
return {
106+
async listObjects() { return [{ name: OBJECT, label: 'Case', fieldCount: 1 }]; },
107+
async describeObject() { return { name: OBJECT, fields: [{ name: 'status', type: 'text' }] }; },
108+
async query() { return { object: OBJECT, records: [], total: 0 }; },
109+
async get(_object, id) { return { id, status: 'open' }; },
110+
async create() { return {}; },
111+
async update() { throw thrown(); },
112+
async remove() { return {}; },
113+
async listActions() {
114+
return [{ name: ACTION, objectName: OBJECT, type: 'script', requiresRecord: true, requiresConfirmation: false }];
115+
},
116+
async runAction() { throw thrown(); },
117+
async resumeRun() { throw thrown(); },
118+
};
119+
}
120+
121+
let nextId = 1;
122+
123+
/** One `tools/call` over the real HTTP transport; answers the raw body text and the parsed tool result. */
124+
async function callOverHttp(bridge: Bridge, name: string, args: Record<string, unknown>) {
125+
const body = { jsonrpc: '2.0', id: nextId++, method: 'tools/call', params: { name, arguments: args } };
126+
const runtime = new MCPServerRuntime({ name: 'pin', version: '0.0.0' });
127+
const res = await runtime.handleHttpRequest(
128+
new Request('http://localhost/api/v1/mcp', {
129+
method: 'POST',
130+
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
131+
body: JSON.stringify(body),
132+
}),
133+
{ bridge, parsedBody: body },
134+
);
135+
expect(res.status, `${name}: the HTTP transport itself answered ${res.status}`).toBe(200);
136+
const raw = await res.text();
137+
const json = JSON.parse(raw) as { result?: any; error?: unknown };
138+
expect(json.error, `${name}: JSON-RPC error ${JSON.stringify(json.error)}`).toBeUndefined();
139+
return { raw, result: json.result };
140+
}
141+
142+
const DOORS = [
143+
{ tool: 'run_action', args: { actionName: ACTION, objectName: OBJECT, recordId: RECORD_ID } },
144+
{ tool: 'resume_run', args: { runId: RUN_ID, values: {} } },
145+
] as const;
146+
147+
describe('run_action / resume_run over HTTP /mcp: a permission refusal withholds its details (#23529)', () => {
148+
it('control: the caller may read the subject row', async () => {
149+
const { result } = await callOverHttp(readerBridge(writeSideDenial), 'get_record', {
150+
objectName: OBJECT,
151+
recordId: RECORD_ID,
152+
});
153+
expect(result.isError).toBeFalsy();
154+
expect(JSON.parse(result.content[0].text)).toEqual({ id: RECORD_ID, status: 'open' });
155+
});
156+
157+
for (const door of DOORS) {
158+
it(`${door.tool}: PERMISSION_DENIED answers code, message and status, and the caller's grants appear nowhere`, async () => {
159+
const { raw, result } = await callOverHttp(readerBridge(writeSideDenial), door.tool, door.args);
160+
161+
expect(result.isError).toBe(true);
162+
expect(result.content).toHaveLength(1);
163+
expect(JSON.parse(result.content[0].text)).toEqual({
164+
error: { code: 'PERMISSION_DENIED', message: DENIAL_MESSAGE, status: 403 },
165+
});
166+
// Every name, against the whole HTTP response body, not one key.
167+
for (const name of [...POSITIONS, ...PERMISSION_SETS]) {
168+
expect(raw, `${door.tool}: '${name}' reached the wire`).not.toContain(name);
169+
}
170+
});
171+
172+
it(`${door.tool}: control — ACTION_CONFIRMATION_REQUIRED still serves its retry details (#15942)`, async () => {
173+
const { result } = await callOverHttp(readerBridge(confirmationRefusal), door.tool, door.args);
174+
175+
expect(result.isError).toBe(true);
176+
expect(JSON.parse(result.content[0].text)).toEqual({
177+
error: {
178+
code: 'ACTION_CONFIRMATION_REQUIRED',
179+
message: `Action '${ACTION}' on '${OBJECT}' declares ai.requiresConfirmation: true — nothing was run.`,
180+
status: 428,
181+
details: { actionName: ACTION, objectName: OBJECT, confirmationMember: AI_ACTION_CONFIRMATION_MEMBER },
182+
},
183+
});
184+
});
185+
}
186+
});

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

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@
3535
import { z } from 'zod';
3636
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3737
import type { EngineAggregateOptions } from '@objectstack/spec/data';
38+
// [#23529] The catalogue's spelling of `PERMISSION_DENIED`, the code whose
39+
// `details` `errorResultFromThrown` withholds. Imported, never hand-spelled.
40+
import { StandardErrorCode } from '@objectstack/spec/api';
3841
import {
3942
MCP_OAUTH_SCOPE_DATA_READ,
4043
MCP_OAUTH_SCOPE_DATA_WRITE,
@@ -302,16 +305,35 @@ function errorResult(message: string) {
302305
* nobody and the agent is back to guessing the member's spelling from
303306
* documentation.
304307
*
305-
* Uncoded throws fall through to `errorResult` unchanged, so this widens what a
306-
* caller can read and narrows nothing.
308+
* Uncoded throws fall through to `errorResult` unchanged.
307309
*
308310
* `run_action` and `resume_run` only. The other catches render through
309311
* {@link errorResultWithCode}, which serves the same envelope WITHOUT `details`;
310312
* that function says why.
313+
*
314+
* [#23529] ⛔ A `PERMISSION_DENIED` is the exception: it renders through
315+
* {@link errorResultWithCode}, so its `details` are WITHHELD here too. The
316+
* security gate puts the caller's `positions` and `permissionSets` in those
317+
* `details`; the ruling on #7450 keeps them off the wire, and the runtime
318+
* dispatcher and the REST actions door both drop them. These two tools are
319+
* reachable by that refusal: a declarative update action writes as the caller,
320+
* so a caller who may read the row but not update it meets the gate's
321+
* write-side refusal, and a host script handler can rethrow one. The answer is
322+
* a tool result inside a JSON-RPC 200, which the dispatcher's withholding never
323+
* sees, so it is withheld at this renderer.
324+
*
325+
* The question is asked on the DECLARED code, spelled from the spec's standard
326+
* catalogue rather than restated, and answered with the data tools' own
327+
* function rather than a second filter of `details`. Every `PermissionDeniedError`
328+
* declares this code (it is a class constant), and a throw with no code never
329+
* reaches the `details` read at all. Every other coded refusal keeps its
330+
* `details` as before, so #15942's confirmation contract and `resume_run`'s
331+
* REST-built `details` are unchanged.
311332
*/
312333
function errorResultFromThrown(err: unknown) {
313334
const envelope = thrownRefusalEnvelope(err);
314335
if (!envelope) return errorResult(messageOf(err));
336+
if (envelope.code === StandardErrorCode.enum.PERMISSION_DENIED) return errorResultWithCode(err);
315337
const details = (err as { details?: unknown }).details;
316338
return codedErrorResult({ ...envelope, ...(details !== undefined ? { details } : {}) });
317339
}

0 commit comments

Comments
 (0)