Skip to content

Commit b1cfa3d

Browse files
fix(mcp): a coded refusal reaches the client with its code on the data tools, list_actions and the record resource (#23526)
Fixes #23509 Clause-②: yes ## What changes The MCP data tools rendered a thrown refusal with `errorResult(messageOf(err))`: `list_objects`, `describe_object`, `validate_expression`, `query_records`, `aggregate_records`, `get_record`, `create_record`, `update_record` and `delete_record`. So did `list_actions`. The record resource `objectstack://objects/{objectName}/records/{recordId}` wrote `{ error: message }`. So when a producer threw the ADR-0112 envelope, an MCP client received the message and nothing else. The code is the machine-readable half the client acts on, and it was lost for every coded refusal on these surfaces: - a held owner's key, `MFA_REQUIRED`. Ruling C′ on #23287, item 1: "a held user's API key is refused at every seam with the same code"; - `RECORD_NOT_FOUND`; - `PERMISSION_DENIED`; - `INVALID_FIELD`; - the exposure refusals. **`packages/mcp/src/mcp-http-tools.ts`** - The 10 catches now return `errorResultWithCode(err)`. It renders `{ error: { code, message, status } }`, the shape `run_action` already answers with (chosen for the action tools in #15942), but **without `details`**. The section below explains why. - `run_action` and `resume_run` keep `errorResultFromThrown`. Their text is byte-identical: same members, same key order. - Both renderers read the throw through one function, `thrownRefusalEnvelope(err)`. It is a named export of the module and is not re-exported from the package index. **`packages/mcp/src/mcp-server-runtime.ts`** - The record resource's catch adds `code`, and `status` when the refusal carries one, beside the unchanged `error` sentence. **Unchanged** - A throw with no non-empty string `code` renders exactly as today on both surfaces. - No code is minted. No refusal's message changes. ### FROM → TO: what an MCP client reads | surface | refusal | FROM | TO | |:--|:--|:--|:--| | the 9 data tools and `list_actions` | coded | `isError: true`; the text is the message | `isError: true`; the text is the JSON `{ "error": { "code", "message", "status" } }`. `status` appears only when the refusal carries one. | | record resource (stdio) | coded | `{ "error": MESSAGE }` | `{ "error": MESSAGE, "code", "status" }`. `status` appears only when the refusal carries one. `error` is the same string as before. | | either | no code | the message | unchanged, byte-identical | ### Why `details` is withheld I measured it at the real `/mcp` door, on the showcase stack with security enabled. A `PERMISSION_DENIED` thrown by the security gate (`plugin-security` `security-plugin.ts`, the denial with `positions` / `permissionSets`) reaches the tool catch carrying the caller's `positions` (`org_member`, `everyone`) and `permissionSets` (`showcase_member_default`, `member_default`) in `details`. The runtime dispatcher drops those `details` from the wire. Its catch in `http-dispatcher.ts` reads "The denial's `details` does NOT reach the wire" and records the ruling on #7450. That policy lives in `@objectstack/runtime` and `@objectstack/plugin-security`, and neither is a dependency of this package. So these surfaces serve the members every boundary serves: `code`, `message` and `status`. The card's done line asks for exactly that: "with its `code` (and `status` where the envelope carries one)". The first revision of this branch rendered `details`. The real-door probe caught the disclosure, and the head commit removes it. Ten pins plus an ablation now hold the withholding (below). ### Why the record resource keeps a flat shape The tools take `errorResultFromThrown`'s nesting. The resource takes the same members but not the nesting: it answers `{ error: MESSAGE, code, status }`, not `{ error: { code, message, status } }`. Three reasons: - The two sibling resources already answer in this flat vocabulary. `objectstack://objects/{objectName}` answers `{ error, code, status }` on a miss or an outage (#6055), and `objectstack://objects` answers the same pair on a degraded listing (#6504). - Two existing readers in this package tell a refusal from a row by `typeof body.error === 'string'`. One is the normalizer in `plugin.record-resource-exposure.test.ts`; the other is the member case in `stdio-data-bridge.stored-metadata-body.test.ts`. A nested `error` object would turn their refusal branch into "this is a row". That would be a narrowing on a surface this PR declares as a widening. - The dispatch scope for the resource ("carries the `code`, and the `status` where the envelope has one") is met either way. ## Faces - **stdio** serves the tools and the record resource. `MFA_REQUIRED` is pinned on both, over a real `StdioServerTransport`. - **HTTP `/mcp`** serves the tools only. `handleHttpRequest` registers no resources: the record resource exists only on the long-lived server (`plugin.ts:738`). - A held key is refused at the dispatcher before the MCP server sees it. That is the dispatch's mechanism note, and I did not re-measure it. So the HTTP pins use refusals that do reach the tool catch. - Measured at the real door (`POST /api/v1/mcp`, showcase stack, a member caller), on the final head: - `get_record` and `update_record` on another owner's row: `RECORD_NOT_FOUND` / 404; - `delete_record`: `PERMISSION_DENIED` / 403, with no `details`; - `create_record` with an unknown field and `query_records` filtering on one: `INVALID_FIELD` / 400. - Each answered `isError: true` with `{ error: { code, message, status } }`. - The probe was a temporary file, deleted after the run and never committed. ## Tests re-judged (none loosened, none deleted) | test | refusal it pins | verdict | |:--|:--|:--| | `stdio-api-key-account-posture.pin.test.ts` (#23508's real-transport pin) | `MFA_REQUIRED` / 403, coded | **moved to the coded rendering.** `tools/call query_records` now asserts `{ error: { code: 'MFA_REQUIRED', message, status: 403 } }`; it asserted the text equal to the message. Extended with `resources/read` on the record over the same wire: `{ error: message, code: 'MFA_REQUIRED', status: 403 }`. | | `plugin.record-resource-exposure.test.ts`, the `apiEnabled: false` and the whitelist-excludes-`get` cases | `OBJECT_API_DISABLED` / 404, `OBJECT_API_METHOD_NOT_ALLOWED` / 405, coded | **moved.** The normalizer reads the code and status from both wires. The cases still assert the same sentence on both paths, and now also the code and status. | | `stdio-data-bridge.relation-condition-target-exposure.test.ts`, the `query_records` tool case | `OBJECT_API_DISABLED` / 404, coded | **moved.** It stayed green as a substring inside the JSON, so it was re-judged rather than left as is. It now parses the envelope and asserts the code, the status and both message fragments. | | `mcp-server-runtime.http.test.ts` (`RLS: not permitted`, `Field read denied`) | plain `Error`, no code | **code-less**, unchanged | | `mcp-action-tools.test.ts` (`list_actions`: `metadata unavailable`) | plain `Error`, no code | **code-less**, unchanged | | `mcp-action-confirmation-member.test.ts` (`handler exploded`) | plain `Error`, no code | **code-less**, unchanged | | `mcp-validate-expression.test.ts` (`not found`), `mcp-resume-run-tool.test.ts` (`runId is required`), every `system object` case | an `errorResult` literal, never a throw | not a thrown refusal, unchanged | | `stdio-data-bridge.stored-metadata-body.test.ts`, the member case | reads `typeof body.error === 'string'` | holds under the flat shape, unchanged | ## Pins added: `packages/mcp/src/mcp-coded-refusal.test.ts` (42 cases) - **HTTP face, every registered tool.** The pins run through the real Streamable HTTP transport, one row per tool, each driven by its own bridge member. The table is held equal to `tools/list`, so a tool added later needs a row. - The real `recordNotFoundError` renders as `{ error: { code: 'RECORD_NOT_FOUND', message, status: 404 } }` with `isError`. - A code-less `Error` renders as its bare message, byte-identical. - On the 10 rows other than `run_action` and `resume_run`, the security gate's `PERMISSION_DENIED` renders as code, message and status, and the `details` are withheld. - **Boundaries.** - The HTTP bridge's exposure gate throws `{ statusCode, message }` with no `code`; it renders as the bare message, so nothing is invented. - An empty or numeric `code`, or a thrown string, is not a code. - A non-numeric `status` is omitted. - **Record resource.** - `MFA_REQUIRED` renders as `{ error, code, status }`. - `PERMISSION_DENIED` renders as code and status, with `details` withheld. - A refusal with no `status` gets no `status`. - A code-less throw answers `{ error }` byte-identically. ## Ablation (one-time; nothing committed) All three legs ran on the final code. Each went through `scripts/ablation-replace.mjs`: the anchor hit once, the blob changed, and the restore was proven by blob == HEAD and an empty `git diff HEAD`. The subject resolves through relative `src/` imports, not a dependency's `dist/`, so no rebuild leg applies. Every leg turned red, the expected direction. 1. **`query_records`' catch reverted to `errorResult(messageOf(err))`.** Blob `b7a491d6641b → 6c8a41093f41`. **4 red:** - the `query_records` `RECORD_NOT_FOUND` row; - the `query_records` `PERMISSION_DENIED` row; - the non-numeric-status row; - the stdio `MFA_REQUIRED` pin. The code-less row stayed green, as it should. Restored to `b7a491d6641b`. 2. **`errorResultWithCode` made to carry `details`.** Blob `b7a491d6641b → f80698dd3e44`. **10 red:** every `WITHHOLDS its details` row. Nothing else went red. Restored. 3. **The record resource's envelope read disabled.** Blob `3ef6ce641eb8 → 65c81c69b058`. **6 red:** - the stdio `MFA_REQUIRED` resource read; - the three coded resource rows; - both #8266 parity cases. The code-less resource row stayed green. Restored. `mcp-http-tools.ts` is byte-identical between the head of legs 1 and 2 and the final head (`b7a491d6641b`). The merge of `main` touched no `packages/mcp` file. ## Verification All at `6c70ec74ab`. - `pnpm --filter @objectstack/mcp test`: **38 files, 462 tests passed**, `VERDICT command-exit 0`. That is 420 before this PR plus 42 new; the three coded pins that asserted the message alone were moved, not deleted. - `pnpm --filter @objectstack/mcp typecheck`: `tsc --noEmit` passes, and `check:test-typecheck: OK`. The 53 errors it reports are the ones held in the shrink-only debt ledger, and none is in a touched file. All four touched test files are in the `tsconfig.test.json` program, counted with `--listFiles`. - Dependency closure: `pnpm --filter '@objectstack/mcp^...' build` exited 0. After the merge of `main`, `pnpm turbo run build --filter='@objectstack/dogfood^...'` ran 65/65 tasks. - Consumer tests that read MCP tool output, run on the final head: `packages/qa/dogfood` `showcase-mcp-http-identity`, `flow-door-elevated-start`, `showcase-mcp-self-connection` and `showcase-declarative-mcp`, plus the temporary real-door probe. **5 files, 39 tests passed.** - `packages/cli/test/package-install-local-handlers.integration.test.ts` reads only `run_action` and a successful `list_actions`, paths this PR does not change. It is integration tier and declared to CI. - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 63 families from the change set. I ran all 63 locally on this head and each exited 0. The `--ran` reconciliation, fed `command :: exit code` lines, reads: "63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN", a zero derived from the recorded codes. - Verdict lines include `check-adr-0087-registration: this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)`, `No empty-frontmatter changeset introduced by this diff`, `check-nul-bytes: OK`, `doc authoring guard: … clean`, `check-test-source-alias OK`, `check:dual-build-cjs-loads — 110 published require entry point(s) across 68 package(s) load` and `check-lean-entry-closure: 2 published condition(s) measured`. - In an earlier round on a superseded head, the last two answered exit 3, PREREQUISITE NOT MET, because a concurrent build was rewriting `dist/`. They were re-run and are exit 0 on this head. - The repo-wide scans, with `pnpm lint` first, and the rest of the farm run in CI. ## Acceptance notes Two findings go to the seat in this PR's report and are not filed from here: 1. **On `/mcp`, the bridge's exposure refusal carries no code.** - `callData`'s gate on the addressed object (`packages/runtime/src/action-execution.ts:304`) throws `{ statusCode, message }` with no `code`, and `buildMcpBridge` calls `callData` for every `/mcp` data verb. - So after this PR, `get_record` / `query_records` on an object declaring `apiEnabled: false` still renders the message alone on `/mcp`. That case is pinned as code-less here: nothing is invented. - The stdio face (`enforceApiExposure`) and REST (`rest-server.ts:510`) answer `OBJECT_API_DISABLED`, and so does the same `callData`'s relation-target refusal. - The fix belongs at that producer, in `packages/runtime`, not in this consumer. 2. **`run_action` / `resume_run` render `details` verbatim** through `errorResultFromThrown` (#15942's contract, unchanged here). - `resume_run`'s errors are built through the REST error builder. - `run_action` loads its subject record through `callData` as the caller. If a security-gate `PERMISSION_DENIED` escapes it, its `positions` / `permissionSets` would reach the MCP client, while the dispatcher withholds them. - I have not measured this path. Observations, not filed: - The AI tool-registry bridge's catch (`registerToolFromDefinition` in `mcp-server-runtime.ts`) also renders a throw as its message alone. No coded producer was measured on that path. - Members beside `code` / `message` / `status` are not rendered on these surfaces: the stdio 405's `allowedOperations`, and the runtime refusal's `allowed`, `object` and `param`. REST's 405 carries `allowed`. - A refusal's `details` could help an AI self-correct, for example a validation error's field list. Serving them would need the per-class disclosure policy this package does not hold. Nothing pulls on it today. --- _Generated by [Claude Code](https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 56147a8 commit b1cfa3d

7 files changed

Lines changed: 482 additions & 50 deletions
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@objectstack/mcp': minor
3+
---
4+
5+
fix(mcp): the data tools, `list_actions` and the record resource answer a coded refusal with its `code` (and `status`) beside the message, on stdio and Streamable HTTP alike (#23509)
6+
7+
Clause-②: yes (widening)
8+
9+
**What was wrong.** When a producer refused with an ADR-0112 envelope (`code`, `status`, the message), these surfaces handed an MCP client the message alone. The code is the half a client branches on. The refusals affected:
10+
11+
- a key whose owner is held: `MFA_REQUIRED` / 403, refused "with the same code" at every seam;
12+
- `RECORD_NOT_FOUND` / 404;
13+
- `PERMISSION_DENIED` / 403;
14+
- `INVALID_FIELD` / 400;
15+
- the exposure refusals `OBJECT_API_DISABLED` / 404 and `OBJECT_API_METHOD_NOT_ALLOWED` / 405.
16+
17+
`run_action` and `resume_run` already rendered the code. The other tools did not, and neither did the record resource.
18+
19+
**What a client reads now.** The tools render the shape `run_action` already answers with. The record resource adds the code beside its unchanged `error` sentence, in the `{ error, code, status }` vocabulary the sibling resources `objectstack://objects` and `objectstack://objects/{objectName}` already use. A refusal's message is unchanged on both.
20+
21+
A refusal's `details` is not rendered on these surfaces. What `details` may disclose is decided per error class at the HTTP boundary. For example, a `PERMISSION_DENIED` from the security gate carries the caller's positions and permission sets, and REST withholds them. This package holds no copy of that policy.
22+
23+
## FROM → TO
24+
25+
| surface | refusal | FROM | TO |
26+
|:--|:--|:--|:--|
27+
| tools `list_objects`, `describe_object`, `validate_expression`, `query_records`, `aggregate_records`, `get_record`, `create_record`, `update_record`, `delete_record`, `list_actions` | coded | `isError: true`; the text is the message | `isError: true`; the text is the JSON `{ "error": { "code", "message", "status" } }`. `status` appears only when the refusal carries one. |
28+
| resource `objectstack://objects/{objectName}/records/{recordId}` (stdio) | coded | `{ "error": MESSAGE }` | `{ "error": MESSAGE, "code", "status" }`. `status` appears only when the refusal carries one. `error` stays the same string. |
29+
| either | no code (a plain `Error`, a thrown string, an empty or non-string `code`) | the message | unchanged, byte-identical |
30+
31+
**Reading it.** For a tool error, parse the text. If it is an object with `error.code`, branch on the code and show `error.message`. Otherwise the text is the message. For the record resource, `typeof body.error === 'string'` still tells a refusal from a row, and `body.code` is present when the refusal carried one.
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#23509] A thrown CODED refusal reaches an MCP client with its code, from
5+
* every catch that renders one. A throw with no code renders exactly as before.
6+
*
7+
* ## What was open
8+
*
9+
* The data tools' catches and `list_actions`' returned
10+
* `errorResult(messageOf(err))`, and the record resource wrote
11+
* `{ error: message }`. So a refusal whose producer threw the ADR-0112 envelope
12+
* reached the client as its sentence alone. That covers a held owner's key
13+
* (`MFA_REQUIRED`, ruling C′ item 1: refused "with the same code" at every
14+
* seam), `RECORD_NOT_FOUND`, and the exposure and permission refusals.
15+
* `errorResultFromThrown` already rendered the code for `run_action` and
16+
* `resume_run` (#15942); the other catches never used it.
17+
*
18+
* ## What is pinned here
19+
*
20+
* - The HTTP `/mcp` face, through `handleHttpRequest` and the real Streamable
21+
* HTTP transport. Every tool's catch, each driven by its own bridge member,
22+
* renders the real `recordNotFoundError` (the value `protocol.getData`
23+
* throws when `get_record` misses on this face) as
24+
* `{ error: { code, message, status } }` with `isError`. The table is held
25+
* equal to the registered tool list, so a tool added later needs a row.
26+
* - The same catches render a code-less throw as its bare message,
27+
* byte-identical to `errorResult(messageOf(err))`. That includes the value
28+
* the HTTP bridge's exposure gate throws today, `{ statusCode, message }`
29+
* with no `code`. Nothing is invented for it.
30+
* - The data tools and `list_actions` WITHHOLD `details`. Measured on `/mcp`,
31+
* a `PERMISSION_DENIED` carries the caller's `positions` and
32+
* `permissionSets` there, which the runtime dispatcher drops from the wire.
33+
* `errorResultWithCode` says why.
34+
* - The record resource adds `code` / `status` beside the unchanged `error`
35+
* sentence and withholds `details` the same way. A code-less throw answers
36+
* `{ error }` byte-identically.
37+
*
38+
* The stdio face's pins live beside the fixtures that already drive it over a
39+
* real `StdioServerTransport`. `MFA_REQUIRED` is pinned in
40+
* `stdio-api-key-account-posture.pin.test.ts`. The exposure refusals, on both
41+
* the tool and the resource, are pinned in
42+
* `plugin.record-resource-exposure.test.ts`.
43+
*/
44+
45+
import { describe, it, expect, vi } from 'vitest';
46+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
47+
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
48+
import { recordNotFoundError } from '@objectstack/core';
49+
import type { IMetadataService } from '@objectstack/spec/contracts';
50+
51+
import { MCPServerRuntime } from './mcp-server-runtime.js';
52+
import type { McpActionBridge, McpDataBridge } from './mcp-http-tools.js';
53+
54+
type Bridge = McpDataBridge & McpActionBridge & Required<Pick<McpDataBridge, 'aggregate'>>;
55+
56+
/** Every member answers benignly, so a refusal comes only from the member a row names. */
57+
function benignBridge(): Bridge {
58+
return {
59+
async listObjects() { return [{ name: 'task', label: 'Task', fieldCount: 1 }]; },
60+
async describeObject() { return { name: 'task', fields: [{ name: 'title', type: 'text' }] }; },
61+
async query(object) { return { object, records: [], total: 0 }; },
62+
async get(_object, id) { return { id }; },
63+
async create(object, data) { return { object, id: 'n1', record: data }; },
64+
async update(object, id, data) { return { object, id, record: data }; },
65+
async remove(object, id) { return { object, id, success: true }; },
66+
async aggregate() { return []; },
67+
async listActions() { return []; },
68+
async runAction(name) { return { ok: true, action: name }; },
69+
async resumeRun(runId) { return { ok: true, runId }; },
70+
};
71+
}
72+
73+
interface Site {
74+
tool: string;
75+
args: Record<string, unknown>;
76+
/** The bridge member whose throw this tool's catch renders. */
77+
member: keyof Bridge;
78+
/**
79+
* `run_action` / `resume_run` render through `errorResultFromThrown`, which
80+
* carries `details`. That is #15942's contract, and it is not this card's to
81+
* change. The `details` pin below covers every other row.
82+
*/
83+
actionDoor?: true;
84+
}
85+
86+
/** Every tool `wireBridgeTools` registers for {@link benignBridge}, each with the member it calls first. */
87+
const SITES: readonly Site[] = [
88+
{ tool: 'list_objects', args: {}, member: 'listObjects' },
89+
{ tool: 'describe_object', args: { objectName: 'task' }, member: 'describeObject' },
90+
{ tool: 'validate_expression', args: { objectName: 'task', expression: 'record.title' }, member: 'describeObject' },
91+
{ tool: 'query_records', args: { objectName: 'task' }, member: 'query' },
92+
{
93+
tool: 'aggregate_records',
94+
args: { objectName: 'task', aggregations: [{ function: 'count', alias: 'n' }] },
95+
member: 'aggregate',
96+
},
97+
{ tool: 'get_record', args: { objectName: 'task', recordId: 't9' }, member: 'get' },
98+
{ tool: 'create_record', args: { objectName: 'task', data: { title: 'x' } }, member: 'create' },
99+
{ tool: 'update_record', args: { objectName: 'task', recordId: 't9', data: { title: 'x' } }, member: 'update' },
100+
{ tool: 'delete_record', args: { objectName: 'task', recordId: 't9' }, member: 'remove' },
101+
{ tool: 'list_actions', args: {}, member: 'listActions' },
102+
{ tool: 'run_action', args: { actionName: 'complete' }, member: 'runAction', actionDoor: true },
103+
{ tool: 'resume_run', args: { runId: 'r1' }, member: 'resumeRun', actionDoor: true },
104+
];
105+
106+
/**
107+
* The security gate's denial as it reached `/mcp` when measured on the
108+
* showcase stack: the caller's authorization topology rides in `details`. The
109+
* runtime dispatcher drops these `details` from the wire.
110+
*/
111+
function securityGateDenial(): Error {
112+
return Object.assign(
113+
new Error('You do not have permission to perform this action. Contact your administrator if you need access.'),
114+
{
115+
code: 'PERMISSION_DENIED',
116+
status: 403,
117+
details: {
118+
operation: 'delete',
119+
object: 'showcase_private_note',
120+
positions: ['org_member', 'everyone'],
121+
permissionSets: ['showcase_member_default', 'member_default'],
122+
},
123+
},
124+
);
125+
}
126+
127+
function bridgeThrowing(member: keyof Bridge, thrown: unknown): Bridge {
128+
const bridge = benignBridge();
129+
(bridge as unknown as Record<string, unknown>)[member] = async () => {
130+
throw thrown;
131+
};
132+
return bridge;
133+
}
134+
135+
let nextId = 1;
136+
async function rpcOverHttp(bridge: Bridge, method: string, params?: Record<string, unknown>) {
137+
const body = { jsonrpc: '2.0', id: nextId++, method, ...(params ? { params } : {}) };
138+
const runtime = new MCPServerRuntime({ name: 'pin', version: '0.0.0' });
139+
const res = await runtime.handleHttpRequest(
140+
new Request('http://localhost/api/v1/mcp', {
141+
method: 'POST',
142+
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
143+
body: JSON.stringify(body),
144+
}),
145+
{ bridge, parsedBody: body },
146+
);
147+
expect(res.status, `${method}: the HTTP transport itself answered ${res.status}`).toBe(200);
148+
const json = (await res.json()) as { result?: any; error?: unknown };
149+
expect(json.error, `${method}: JSON-RPC error ${JSON.stringify(json.error)}`).toBeUndefined();
150+
return json.result;
151+
}
152+
153+
async function callTool(site: Site, thrown: unknown) {
154+
return rpcOverHttp(bridgeThrowing(site.member, thrown), 'tools/call', { name: site.tool, arguments: site.args });
155+
}
156+
157+
describe('MCP tools over HTTP /mcp: a coded refusal carries its code (#23509)', () => {
158+
it('the table names every registered tool, so a tool added later needs a row here', async () => {
159+
const listed = await rpcOverHttp(benignBridge(), 'tools/list');
160+
const names = (listed.tools as Array<{ name: string }>).map((t) => t.name).sort();
161+
expect(names).toEqual(SITES.map((s) => s.tool).sort());
162+
});
163+
164+
for (const site of SITES) {
165+
it(`${site.tool}: RECORD_NOT_FOUND from ${String(site.member)} answers code, message and status`, async () => {
166+
const result = await callTool(site, recordNotFoundError('task', 't9'));
167+
expect(result.isError).toBe(true);
168+
expect(result.content).toHaveLength(1);
169+
expect(JSON.parse(result.content[0].text)).toEqual({
170+
error: { code: 'RECORD_NOT_FOUND', message: 'Record t9 not found in task', status: 404 },
171+
});
172+
});
173+
174+
it(`${site.tool}: a code-less throw from ${String(site.member)} answers its bare message, as before`, async () => {
175+
const result = await callTool(site, new Error('engine exploded'));
176+
expect(result).toEqual({ content: [{ type: 'text', text: 'engine exploded' }], isError: true });
177+
});
178+
179+
if (!site.actionDoor) {
180+
it(`${site.tool}: a PERMISSION_DENIED answers code, message and status, and WITHHOLDS its details`, async () => {
181+
const result = await callTool(site, securityGateDenial());
182+
expect(result.isError).toBe(true);
183+
expect(JSON.parse(result.content[0].text)).toEqual({
184+
error: {
185+
code: 'PERMISSION_DENIED',
186+
message: 'You do not have permission to perform this action. Contact your administrator if you need access.',
187+
status: 403,
188+
},
189+
});
190+
expect(result.content[0].text).not.toContain('permissionSets');
191+
});
192+
}
193+
}
194+
195+
// One site is enough below: every catch renders through the one reader.
196+
const query = SITES.find((s) => s.tool === 'query_records')!;
197+
198+
it('the HTTP bridge exposure gate\'s `{ statusCode, message }` (no `code`) answers its bare message: nothing is invented', async () => {
199+
const thrown = { statusCode: 404, message: "Object 'task' is not exposed via the API" };
200+
expect(await callTool(query, thrown)).toEqual({
201+
content: [{ type: 'text', text: "Object 'task' is not exposed via the API" }],
202+
isError: true,
203+
});
204+
});
205+
206+
it('an empty or non-string `code`, or a thrown string, is not a code', async () => {
207+
for (const thrown of [
208+
Object.assign(new Error('empty code'), { code: '', status: 403 }),
209+
Object.assign(new Error('numeric code'), { code: 403, status: 403 }),
210+
'a thrown string',
211+
]) {
212+
const text = thrown instanceof Error ? thrown.message : String(thrown);
213+
expect(await callTool(query, thrown), text).toEqual({ content: [{ type: 'text', text }], isError: true });
214+
}
215+
});
216+
217+
it('omits a `status` that is not a number', async () => {
218+
const statusless = Object.assign(new Error('no status here'), { code: 'PERMISSION_DENIED', status: '403' });
219+
expect(JSON.parse((await callTool(query, statusless)).content[0].text)).toEqual({
220+
error: { code: 'PERMISSION_DENIED', message: 'no status here' },
221+
});
222+
});
223+
});
224+
225+
describe('the record resource: a coded refusal adds its code beside the unchanged sentence (#23509)', () => {
226+
const URI = 'objectstack://objects/task/records/t1';
227+
228+
/** Read the record resource of a server whose reader throws `thrown`; answers the raw body text. */
229+
async function readRefused(thrown: unknown): Promise<string> {
230+
const runtime = new MCPServerRuntime({ name: 'pin', version: '0.0.0' });
231+
const metadata = {
232+
listObjects: vi.fn(async () => []),
233+
getObject: vi.fn(async () => null),
234+
} as unknown as IMetadataService;
235+
runtime.bridgeResources(metadata, async () => {
236+
throw thrown;
237+
});
238+
const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
239+
await runtime.server.connect(serverSide);
240+
const client = new Client({ name: 'pin', version: '0.0.0' });
241+
await client.connect(clientSide);
242+
try {
243+
const read = await client.readResource({ uri: URI });
244+
expect(read.contents).toHaveLength(1);
245+
expect(read.contents[0]!.uri).toBe(URI);
246+
return String((read.contents[0] as { text?: unknown }).text);
247+
} finally {
248+
await client.close();
249+
}
250+
}
251+
252+
it('MFA_REQUIRED / 403: `code` and `status` beside `error`, which stays the sentence', async () => {
253+
const message = 'Multi-factor authentication is required. Please set up an authenticator app to continue.';
254+
const body = JSON.parse(await readRefused(Object.assign(new Error(message), { code: 'MFA_REQUIRED', status: 403 })));
255+
expect(body).toEqual({ error: message, code: 'MFA_REQUIRED', status: 403 });
256+
// The discriminator a resource client already uses to tell a refusal from a row.
257+
expect(typeof body.error).toBe('string');
258+
});
259+
260+
it('a PERMISSION_DENIED answers code and status and WITHHOLDS its details', async () => {
261+
const denial = securityGateDenial();
262+
expect(JSON.parse(await readRefused(denial))).toEqual({
263+
error: denial.message,
264+
code: 'PERMISSION_DENIED',
265+
status: 403,
266+
});
267+
});
268+
269+
it('carries no `status` when the refusal has none', async () => {
270+
const thrown = Object.assign(new Error('Field read denied: salary'), { code: 'PERMISSION_DENIED' });
271+
expect(JSON.parse(await readRefused(thrown))).toEqual({
272+
error: 'Field read denied: salary',
273+
code: 'PERMISSION_DENIED',
274+
});
275+
});
276+
277+
it('a code-less throw answers `{ error }` byte-identically', async () => {
278+
expect(await readRefused(new Error('engine exploded'))).toBe(JSON.stringify({ error: 'engine exploded' }));
279+
expect(await readRefused('a thrown string')).toBe(JSON.stringify({ error: 'a thrown string' }));
280+
expect(await readRefused(Object.assign(new Error('empty code'), { code: '' }))).toBe(
281+
JSON.stringify({ error: 'empty code' }),
282+
);
283+
});
284+
});

0 commit comments

Comments
 (0)