@nanobpm/nano-workforce 0.158.1 → 0.159.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +8 -0
- package/.github/workflows/invariants.yml +7 -0
- package/CHANGELOG.md +6 -0
- package/docs/mcp-runbook.md +46 -0
- package/e2e/mcp-surface.e2e.ts +235 -0
- package/e2e/support/mcp-harness.ts +373 -0
- package/openapi.yaml +1121 -13
- package/package.json +5 -2
- package/scripts/inline-mcp-bodies.ts +316 -0
- package/test/mcp-tool-schemas.test.ts +127 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
// Reusable MCP end-to-end harness (epic #605 slice S1, issue #607).
|
|
2
|
+
//
|
|
3
|
+
// WHAT THIS IS
|
|
4
|
+
// ============
|
|
5
|
+
// The shared, importable module that drives the app's REAL runtime-served MCP surface (ADR 0067,
|
|
6
|
+
// `/app/mcp`) exactly the way a Streamable-HTTP MCP client does — `initialize` → capture the
|
|
7
|
+
// `Mcp-Session-Id` → `notifications/initialized` → `tools/list` → `tools/call` — against a locally
|
|
8
|
+
// booted, hermetic instance (`@nanobpm/urban-testkit`'s `bootTestApp`, in-process, no socket, no
|
|
9
|
+
// network). It exists so an object-body / serialization regression on the MCP projection layer
|
|
10
|
+
// (the S0 defect: a leaked `$ref` in a tool schema, or an object argument coerced to a string)
|
|
11
|
+
// FAILS THE BUILD from nwf's side instead of reaching an agent at runtime.
|
|
12
|
+
//
|
|
13
|
+
// It is deliberately a *module of helpers*, not a monolithic test: sibling slices S2 (#608, the
|
|
14
|
+
// write→read `listStagedProposals` test), S4 (#610, the `sequenceIssues` test) and S5 (#611, the
|
|
15
|
+
// addressable-guide test) each add THEIR OWN per-tool regression case by importing
|
|
16
|
+
// `bootMcpHarness` and calling `harness.callTool(...)` / `harness.listTools()`, never
|
|
17
|
+
// re-implementing the handshake. See "EXTENSION SEAM" below.
|
|
18
|
+
//
|
|
19
|
+
// WHY `URBAN_MCP_ALLOW_REMOTE`
|
|
20
|
+
// ============================
|
|
21
|
+
// `bootTestApp` binds the app to all interfaces (`bind: "all"`), and the MCP surface is
|
|
22
|
+
// loopback-only by default. The in-process router carries no real peer address, so a loopback-only
|
|
23
|
+
// surface refuses EVERY in-process request with a 403. `bootMcpHarness` therefore boots with
|
|
24
|
+
// `URBAN_MCP_ALLOW_REMOTE: "true"` so the hermetic harness can reach the surface. This flips only
|
|
25
|
+
// the loopback gate; the projection, validation, dispatch and session handshake under test are the
|
|
26
|
+
// production code paths, unchanged.
|
|
27
|
+
//
|
|
28
|
+
// EXTENSION SEAM — how to register a new per-tool case (S2 / S4 / S5)
|
|
29
|
+
// ===================================================================
|
|
30
|
+
// In your own `e2e/<slice>.e2e.ts`:
|
|
31
|
+
//
|
|
32
|
+
// import { after, before, describe, test } from "node:test";
|
|
33
|
+
// import assert from "node:assert/strict";
|
|
34
|
+
// import { bootMcpHarness, type McpHarness } from "./support/mcp-harness.ts";
|
|
35
|
+
//
|
|
36
|
+
// describe("S<n> — <your regression>", () => {
|
|
37
|
+
// let h: McpHarness;
|
|
38
|
+
// before(async () => { h = await bootMcpHarness(); }); // handshake already done
|
|
39
|
+
// after(async () => { await h.stop(); }); // teardown + tmpdir cleanup
|
|
40
|
+
//
|
|
41
|
+
// test("my tool round-trips", async () => {
|
|
42
|
+
// // (optional) assert the tool's projected schema is client-usable:
|
|
43
|
+
// const tools = await h.listTools();
|
|
44
|
+
// const mine = tools.find((t) => t.name === "myNewTool");
|
|
45
|
+
// assert(mine, "myNewTool must be projected onto the MCP surface");
|
|
46
|
+
// assertSchemaSelfContained(mine.inputSchema, "myNewTool"); // exported below
|
|
47
|
+
//
|
|
48
|
+
// // drive an actual tools/call and assert the client-visible contract:
|
|
49
|
+
// const res = await h.callTool("myNewTool", { body: { /* an OBJECT, never a string */ } });
|
|
50
|
+
// assert(!res.isError, res.text);
|
|
51
|
+
// // side-effecting? keep the harness repeatable: call with an invalid/no-op body so
|
|
52
|
+
// // validation rejects it (nothing is persisted), OR clean up what you created before
|
|
53
|
+
// // `after` — the harness leaves no live staged proposals behind between runs.
|
|
54
|
+
// });
|
|
55
|
+
// });
|
|
56
|
+
//
|
|
57
|
+
// The handshake, session management and teardown are OWNED HERE. A slice adds a `test(...)`, not a
|
|
58
|
+
// second transport.
|
|
59
|
+
|
|
60
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
61
|
+
import { tmpdir } from "node:os";
|
|
62
|
+
import { join, resolve } from "node:path";
|
|
63
|
+
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
64
|
+
|
|
65
|
+
/** The app root (repo root — a sibling of this `e2e/support/` dir). */
|
|
66
|
+
const APP_ROOT = resolve(import.meta.dirname, "..", "..");
|
|
67
|
+
|
|
68
|
+
/** The mount path of the runtime-served MCP endpoint (ADR 0067). */
|
|
69
|
+
export const MCP_PATH = "/app/mcp";
|
|
70
|
+
|
|
71
|
+
/** The Streamable-HTTP session header (MCP spec). Lower-cased — the runtime seam lower-cases
|
|
72
|
+
* response header keys. */
|
|
73
|
+
export const SESSION_HEADER = "mcp-session-id";
|
|
74
|
+
|
|
75
|
+
/** The exact validation-issue message the door returns when an object body arrives coerced to a
|
|
76
|
+
* string — the client-visible signature of the S0 object-body stringification defect. The harness
|
|
77
|
+
* asserts a real object body NEVER produces this, and the reintroduction guard asserts a
|
|
78
|
+
* deliberately-stringified body DOES. */
|
|
79
|
+
export const STRINGIFIED_BODY_MESSAGE = "expected object, got string";
|
|
80
|
+
|
|
81
|
+
/** A minimal, valid `DeliveryGraph` (a single bare `human` node — its config is optional). Compiles
|
|
82
|
+
* cleanly through `previewDeliveryGraph` (a PURE door — nothing is staged), so it is the canonical
|
|
83
|
+
* side-effect-free positive object-body payload. Exported for siblings that need a known-good graph. */
|
|
84
|
+
export const MINIMAL_VALID_GRAPH: Readonly<{ nodes: ReadonlyArray<Record<string, unknown>> }> = {
|
|
85
|
+
nodes: [{ id: "h", kind: "human" }],
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** One tool as projected onto `tools/list`. */
|
|
89
|
+
export interface McpTool {
|
|
90
|
+
name: string;
|
|
91
|
+
description?: string;
|
|
92
|
+
inputSchema: Record<string, unknown>;
|
|
93
|
+
annotations?: Record<string, unknown>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The parsed result of a `tools/call`. `text` is the first text content block (the app operations
|
|
97
|
+
* and framework tools always answer with a single JSON text block); `json` is that block parsed
|
|
98
|
+
* when it is JSON. `isError` is the MCP tool-level error flag (a door 4xx/5xx surfaces here, not as
|
|
99
|
+
* a JSON-RPC error). `httpStatus` is the transport status of the POST itself (200 for any
|
|
100
|
+
* well-formed JSON-RPC exchange, including a tool-level error). */
|
|
101
|
+
export interface McpToolResult {
|
|
102
|
+
isError: boolean;
|
|
103
|
+
text: string;
|
|
104
|
+
json: unknown;
|
|
105
|
+
httpStatus: number;
|
|
106
|
+
/** The raw JSON-RPC envelope, for a case that needs more than the first content block. */
|
|
107
|
+
raw: unknown;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The low-level result of a single JSON-RPC POST to `/app/mcp`. */
|
|
111
|
+
export interface McpRpcResult {
|
|
112
|
+
httpStatus: number;
|
|
113
|
+
headers: Record<string, string>;
|
|
114
|
+
/** The parsed JSON-RPC response body, or `undefined` for a notification (which has no response). */
|
|
115
|
+
body: unknown;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The booted harness: a live MCP session against a hermetic instance, with the handshake already
|
|
119
|
+
* performed. Reusable across many `tools/call`s; call {@link McpHarness.stop} once at teardown. */
|
|
120
|
+
export interface McpHarness {
|
|
121
|
+
/** The underlying booted app — exposed for a slice that needs to seed/inspect the app DB or drive
|
|
122
|
+
* an operator-only (`x-mcp`-excluded) cleanup route the MCP surface does not expose. */
|
|
123
|
+
readonly app: TestApp;
|
|
124
|
+
/** The negotiated MCP session id (the captured `Mcp-Session-Id`). */
|
|
125
|
+
readonly sessionId: string;
|
|
126
|
+
/** `tools/list` — the projected tool catalogue (app operations + framework debug tools). */
|
|
127
|
+
listTools(): Promise<McpTool[]>;
|
|
128
|
+
/** `tools/call` — invoke a tool by name with its argument object. */
|
|
129
|
+
callTool(name: string, args?: Record<string, unknown>): Promise<McpToolResult>;
|
|
130
|
+
/** A raw JSON-RPC request against `/app/mcp` (escape hatch for a bespoke case). `params` omitted →
|
|
131
|
+
* no `params` field; a `notifications/*` method is sent as a notification (no `id`, no response). */
|
|
132
|
+
rpc(method: string, params?: unknown): Promise<McpRpcResult>;
|
|
133
|
+
/** Tear down: stop the app (workers, engine, DB) and remove the temp DB dir. Idempotent. */
|
|
134
|
+
stop(): Promise<void>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Options for {@link bootMcpHarness}. */
|
|
138
|
+
export interface BootMcpHarnessOptions {
|
|
139
|
+
/** Extra environment overlaid on the harness defaults (e.g. to enable a shared-secret guard, or a
|
|
140
|
+
* GitHub transport). Merged over the defaults; the caller wins on a key collision. */
|
|
141
|
+
env?: Record<string, string>;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The MCP JSON-RPC protocol version the harness negotiates. Kept in one place so a spec bump is a
|
|
145
|
+
* one-line change every slice inherits. */
|
|
146
|
+
const PROTOCOL_VERSION = "2025-06-18";
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Boot a hermetic app instance and complete the MCP handshake, returning a live {@link McpHarness}.
|
|
150
|
+
*
|
|
151
|
+
* The handshake is the real client sequence against the real `/app/mcp` surface:
|
|
152
|
+
* 1. `initialize` — negotiate the protocol; the runtime mints and returns the `Mcp-Session-Id`.
|
|
153
|
+
* 2. `notifications/initialized` — the client's post-init notification, carrying the session id.
|
|
154
|
+
* After this the returned harness is ready for `listTools()` / `callTool(...)`.
|
|
155
|
+
*/
|
|
156
|
+
export async function bootMcpHarness(opts: BootMcpHarnessOptions = {}): Promise<McpHarness> {
|
|
157
|
+
const dbDir = mkdtempSync(join(tmpdir(), "nwf-mcp-e2e-"));
|
|
158
|
+
const env: Record<string, string> = {
|
|
159
|
+
NANO_APP_DB_URL: `file:${join(dbDir, "app.db")}`,
|
|
160
|
+
// Hermetic: never reach GitHub. `pr` probes/connectors are not exercised by the surface harness.
|
|
161
|
+
NANO_PR_GITHUB_TRANSPORT: "token",
|
|
162
|
+
GITHUB_TOKEN: "",
|
|
163
|
+
// See the module header: the in-process router has no real peer address, so the loopback-only
|
|
164
|
+
// MCP surface must be told to answer. This flips ONLY the loopback gate.
|
|
165
|
+
URBAN_MCP_ALLOW_REMOTE: "true",
|
|
166
|
+
...opts.env,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
let app: TestApp;
|
|
170
|
+
try {
|
|
171
|
+
app = await bootTestApp(APP_ROOT, { env });
|
|
172
|
+
} catch (err) {
|
|
173
|
+
rmSync(dbDir, { recursive: true, force: true });
|
|
174
|
+
throw err;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let idCounter = 0;
|
|
178
|
+
const rpc = async (method: string, params?: unknown, sessionId?: string): Promise<McpRpcResult> => {
|
|
179
|
+
const headers: Record<string, string> = {
|
|
180
|
+
"content-type": "application/json",
|
|
181
|
+
// The Streamable-HTTP transport inspects Accept; a real client offers both even when the
|
|
182
|
+
// server answers JSON (the runtime sets `enableJsonResponse`).
|
|
183
|
+
accept: "application/json, text/event-stream",
|
|
184
|
+
};
|
|
185
|
+
if (sessionId) headers[SESSION_HEADER] = sessionId;
|
|
186
|
+
const isNotification = method.startsWith("notifications/");
|
|
187
|
+
const message: Record<string, unknown> = { jsonrpc: "2.0", method };
|
|
188
|
+
if (params !== undefined) message.params = params;
|
|
189
|
+
if (!isNotification) message.id = ++idCounter;
|
|
190
|
+
|
|
191
|
+
const res = await app.ui.call({
|
|
192
|
+
method: "POST",
|
|
193
|
+
path: MCP_PATH,
|
|
194
|
+
headers,
|
|
195
|
+
body: JSON.stringify(message),
|
|
196
|
+
});
|
|
197
|
+
const rawBody = res.body ?? "";
|
|
198
|
+
return {
|
|
199
|
+
httpStatus: res.status ?? 200,
|
|
200
|
+
headers: res.headers ?? {},
|
|
201
|
+
body: rawBody ? JSON.parse(rawBody) : undefined,
|
|
202
|
+
};
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// Any step of the handshake below can throw — a JSON-parse failure inside `rpc`, a non-200
|
|
206
|
+
// initialize, a missing session header, or the post-init notification. A single try/catch keeps
|
|
207
|
+
// failure deterministic: whatever throws, always stop the in-process app and remove the temp DB
|
|
208
|
+
// dir so a failed boot never leaks host resources into a CI run.
|
|
209
|
+
const teardown = async (): Promise<void> => {
|
|
210
|
+
await app.stop().catch(() => {});
|
|
211
|
+
rmSync(dbDir, { recursive: true, force: true });
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
let sessionId: string;
|
|
215
|
+
try {
|
|
216
|
+
// 1. initialize — capture the runtime-minted session id.
|
|
217
|
+
const initRes = await rpc("initialize", {
|
|
218
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
219
|
+
capabilities: {},
|
|
220
|
+
clientInfo: { name: "nwf-mcp-e2e-harness", version: "1.0.0" },
|
|
221
|
+
});
|
|
222
|
+
if (initRes.httpStatus !== 200) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
`MCP initialize failed (status ${initRes.httpStatus}): ${JSON.stringify(initRes.body)}`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
const mintedId = initRes.headers[SESSION_HEADER];
|
|
228
|
+
if (!mintedId) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`MCP initialize returned no ${SESSION_HEADER} header — headers: ${JSON.stringify(initRes.headers)}`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
sessionId = mintedId;
|
|
234
|
+
|
|
235
|
+
// 2. notifications/initialized — the client's post-init notification (no response expected).
|
|
236
|
+
await rpc("notifications/initialized", undefined, sessionId);
|
|
237
|
+
} catch (err) {
|
|
238
|
+
await teardown();
|
|
239
|
+
throw err;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
let stopped = false;
|
|
243
|
+
const harness: McpHarness = {
|
|
244
|
+
app,
|
|
245
|
+
sessionId,
|
|
246
|
+
rpc: (method, params) => rpc(method, params, sessionId),
|
|
247
|
+
async listTools(): Promise<McpTool[]> {
|
|
248
|
+
const res = await rpc("tools/list", {}, sessionId);
|
|
249
|
+
const body = res.body as { result?: { tools?: McpTool[] }; error?: unknown } | undefined;
|
|
250
|
+
if (!body?.result?.tools) {
|
|
251
|
+
throw new Error(`tools/list returned no result.tools: ${JSON.stringify(body)}`);
|
|
252
|
+
}
|
|
253
|
+
return body.result.tools;
|
|
254
|
+
},
|
|
255
|
+
async callTool(name, args = {}): Promise<McpToolResult> {
|
|
256
|
+
const res = await rpc("tools/call", { name, arguments: args }, sessionId);
|
|
257
|
+
const body = res.body as
|
|
258
|
+
| { result?: { isError?: boolean; content?: Array<{ type: string; text?: string }> }; error?: { message?: string } }
|
|
259
|
+
| undefined;
|
|
260
|
+
if (body?.error) {
|
|
261
|
+
// A JSON-RPC-level error (e.g. an unknown tool name / protocol error) — distinct from a
|
|
262
|
+
// tool-level `isError` door failure. Surface it as an errored result carrying the message.
|
|
263
|
+
const text = body.error.message ?? JSON.stringify(body.error);
|
|
264
|
+
return { isError: true, text, json: safeParse(text), httpStatus: res.httpStatus, raw: body };
|
|
265
|
+
}
|
|
266
|
+
const first = body?.result?.content?.find((c) => c.type === "text");
|
|
267
|
+
const text = first?.text ?? "";
|
|
268
|
+
return {
|
|
269
|
+
isError: body?.result?.isError === true,
|
|
270
|
+
text,
|
|
271
|
+
json: safeParse(text),
|
|
272
|
+
httpStatus: res.httpStatus,
|
|
273
|
+
raw: body,
|
|
274
|
+
};
|
|
275
|
+
},
|
|
276
|
+
async stop(): Promise<void> {
|
|
277
|
+
if (stopped) return;
|
|
278
|
+
stopped = true;
|
|
279
|
+
try {
|
|
280
|
+
await app.stop();
|
|
281
|
+
} finally {
|
|
282
|
+
rmSync(dbDir, { recursive: true, force: true });
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
return harness;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Parse `text` as JSON, returning `undefined` when it is not JSON (a non-JSON text block, or empty). */
|
|
290
|
+
function safeParse(text: string): unknown {
|
|
291
|
+
if (!text) return undefined;
|
|
292
|
+
try {
|
|
293
|
+
return JSON.parse(text);
|
|
294
|
+
} catch {
|
|
295
|
+
return undefined;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Deep predicate: does any node of the (already-parsed) JSON Schema carry a `$ref` key? A projected
|
|
300
|
+
* MCP tool input schema MUST be self-contained — a `$ref` is unresolvable in the MCP context and is
|
|
301
|
+
* exactly the S0 leak. */
|
|
302
|
+
export function schemaHasRef(schema: unknown): boolean {
|
|
303
|
+
if (Array.isArray(schema)) return schema.some(schemaHasRef);
|
|
304
|
+
if (schema && typeof schema === "object") {
|
|
305
|
+
const obj = schema as Record<string, unknown>;
|
|
306
|
+
if ("$ref" in obj) return true;
|
|
307
|
+
return Object.values(obj).some(schemaHasRef);
|
|
308
|
+
}
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Assert a projected tool input schema is client-usable (the S0 contract): a top-level
|
|
314
|
+
* `type: "object"`, and `$ref`-free ANYWHERE in the tree. Throws (with the tool name and the
|
|
315
|
+
* offending schema) on a violation — this is the regression detector S2/S4/S5 reuse for their new
|
|
316
|
+
* tools, and the guard whose teeth the reintroduction test pins.
|
|
317
|
+
*/
|
|
318
|
+
export function assertSchemaSelfContained(schema: unknown, toolName: string): void {
|
|
319
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
|
|
320
|
+
throw new Error(`tool "${toolName}": input schema must be an object, got ${JSON.stringify(schema)}`);
|
|
321
|
+
}
|
|
322
|
+
const obj = schema as Record<string, unknown>;
|
|
323
|
+
if (obj.type !== "object") {
|
|
324
|
+
throw new Error(
|
|
325
|
+
`tool "${toolName}": input schema must declare an explicit \`type: "object"\` (got ${JSON.stringify(obj.type)}) — ` +
|
|
326
|
+
`a client cannot encode arguments against a typeless schema (S0 / nano-ide#502).`,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
if (schemaHasRef(obj)) {
|
|
330
|
+
throw new Error(
|
|
331
|
+
`tool "${toolName}": input schema leaks a \`$ref\` — it must be self-contained (inline the ` +
|
|
332
|
+
`component). A \`$ref\` is unresolvable in the MCP context and coerces object-body callers to ` +
|
|
333
|
+
`stringify (S0 / nano-ide#502). Schema: ${JSON.stringify(obj)}`,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Assert a `tools/call` result did NOT come back as the object-body stringification failure — i.e.
|
|
340
|
+
* the object argument reached the door AS AN OBJECT. Throws if the result carries the
|
|
341
|
+
* {@link STRINGIFIED_BODY_MESSAGE} signature. Use after any object-body `callTool`.
|
|
342
|
+
*/
|
|
343
|
+
export function assertObjectBodyAccepted(result: McpToolResult, toolName: string): void {
|
|
344
|
+
if (result.text.includes(STRINGIFIED_BODY_MESSAGE)) {
|
|
345
|
+
throw new Error(
|
|
346
|
+
`tool "${toolName}": object argument arrived stringified — the door reported "${STRINGIFIED_BODY_MESSAGE}". ` +
|
|
347
|
+
`This is the S0 object-body serialization defect (nano-ide#503). Result: ${result.text}`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Assert a `tools/call` result is a uniform validation failure: the door's
|
|
354
|
+
* `{ error, issues: [{ path, message }] }` contract. Throws otherwise. Lets a slice drive a
|
|
355
|
+
* side-effecting door with a deliberately-invalid (but object-shaped) body — proving the tool is
|
|
356
|
+
* reachable AND the body arrived as an object — WITHOUT persisting anything.
|
|
357
|
+
*/
|
|
358
|
+
export function assertValidationIssues(result: McpToolResult, toolName: string): void {
|
|
359
|
+
const json = result.json as { error?: unknown; issues?: Array<{ path?: unknown; message?: unknown }> } | undefined;
|
|
360
|
+
if (!json || !Array.isArray(json.issues) || json.issues.length === 0) {
|
|
361
|
+
throw new Error(
|
|
362
|
+
`tool "${toolName}": expected a validation failure carrying issues[{path,message}], got: ${result.text}`,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
for (const issue of json.issues) {
|
|
366
|
+
if (typeof issue.path !== "string" || typeof issue.message !== "string") {
|
|
367
|
+
throw new Error(
|
|
368
|
+
`tool "${toolName}": each validation issue must carry a string {path,message}; got ${JSON.stringify(issue)}`,
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
assertObjectBodyAccepted(result, toolName);
|
|
373
|
+
}
|