@nanobpm/urban 0.84.0 → 0.86.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/dist/openapi/spec.d.ts +72 -0
- package/dist/openapi/spec.js +130 -0
- package/dist/runtime/core/modules/api.d.ts +5 -0
- package/dist/runtime/core/modules/api.js +2 -2
- package/dist/runtime/core/modules/mcp.d.ts +75 -0
- package/dist/runtime/core/modules/mcp.js +994 -0
- package/dist/runtime/core/modules/surfaces.js +11 -0
- package/package.json +4 -1
- package/src/openapi/mcp-parity.fixture.yaml +49 -0
- package/src/openapi/mcp-tools.snapshot.json +20 -0
- package/src/openapi/spec.test.ts +92 -0
- package/src/openapi/spec.ts +161 -0
- package/src/runtime/core/modules/api.ts +2 -2
- package/src/runtime/core/modules/mcp.test.ts +1199 -0
- package/src/runtime/core/modules/mcp.ts +1144 -0
- package/src/runtime/core/modules/surfaces.test.ts +16 -0
- package/src/runtime/core/modules/surfaces.ts +11 -0
|
@@ -0,0 +1,1199 @@
|
|
|
1
|
+
// Red/Green tests for the runtime-served MCP surface (ADR 0067). They drive real JSON-RPC over the
|
|
2
|
+
// mounted `/app/mcp` route through the SDK transport — an initialize/tools-list/tools-call handshake
|
|
3
|
+
// against a booted app — and assert: every non-excluded app operation projects to a tool whose
|
|
4
|
+
// input schema matches the spec; an `x-mcp`-excluded operation is withheld; a tool call flows
|
|
5
|
+
// through the SAME `mountApi` delegate + validation as the HTTP call; the framework debug tools
|
|
6
|
+
// (read + mutating) return engine truth and ADR 0065 projection data; a MUTATING tool refuses
|
|
7
|
+
// without the shared-secret guard credential and succeeds with it (while read tools stay open on
|
|
8
|
+
// loopback); the spec↔tool parity guard fails on injected skew; and the system-brief resource +
|
|
9
|
+
// orientation prompt are served. Access gating (loopback-only, flag) is covered too.
|
|
10
|
+
|
|
11
|
+
import { test } from "node:test";
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { createNodeHost } from "../../adapters/node.ts";
|
|
17
|
+
import { createLogger } from "../logger.ts";
|
|
18
|
+
import type { AppApi, RuntimeContext } from "../context.ts";
|
|
19
|
+
import type { AppManifest } from "../manifest.ts";
|
|
20
|
+
import type {
|
|
21
|
+
EngineClient,
|
|
22
|
+
HostContext,
|
|
23
|
+
HttpRequest,
|
|
24
|
+
HttpResponse,
|
|
25
|
+
ProcessInstanceSnapshot,
|
|
26
|
+
SqliteDb,
|
|
27
|
+
} from "../host.ts";
|
|
28
|
+
import { makeRouter } from "../router.ts";
|
|
29
|
+
import { mountApi } from "./api.ts";
|
|
30
|
+
import { makeGateway } from "./gateway.ts";
|
|
31
|
+
import { DataLayer, type ProvisionedSource } from "./datasource.ts";
|
|
32
|
+
import { InstanceStateStore } from "./instance-state-store.ts";
|
|
33
|
+
import { OpenUserTasksStore } from "./open-user-tasks-store.ts";
|
|
34
|
+
import { evictExcessSessions, isLoopbackRequest, missingRequiredArgs, mountMcp, newSessionId, readHeader, readMcpConfig } from "./mcp.ts";
|
|
35
|
+
import { collectMcpToolProjection, diffMcpToolProjection, parseSpec } from "../../../openapi/spec.ts";
|
|
36
|
+
|
|
37
|
+
// ---- fixtures ---------------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
const SPEC = JSON.stringify({
|
|
40
|
+
openapi: "3.0.0",
|
|
41
|
+
components: {
|
|
42
|
+
schemas: {
|
|
43
|
+
Invoice: {
|
|
44
|
+
type: "object",
|
|
45
|
+
properties: { id: { type: "string" }, amount: { type: "integer", minimum: 1 } },
|
|
46
|
+
required: ["id", "amount"],
|
|
47
|
+
additionalProperties: false,
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
paths: {
|
|
52
|
+
"/invoices": {
|
|
53
|
+
get: {
|
|
54
|
+
operationId: "listInvoices",
|
|
55
|
+
summary: "List invoices",
|
|
56
|
+
parameters: [{ name: "limit", in: "query", required: true, schema: { type: "integer", minimum: 1 } }],
|
|
57
|
+
responses: { "200": {} },
|
|
58
|
+
},
|
|
59
|
+
post: {
|
|
60
|
+
operationId: "createInvoice",
|
|
61
|
+
requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/Invoice" } } } },
|
|
62
|
+
responses: { "201": {} },
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
"/invoices/{id}": {
|
|
66
|
+
get: {
|
|
67
|
+
operationId: "getInvoice",
|
|
68
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
69
|
+
responses: { "200": {} },
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const SYSTEM_BRIEF = JSON.stringify({ app: "T", processes: [] });
|
|
76
|
+
|
|
77
|
+
const SPEC_PATH = "/app/openapi.json";
|
|
78
|
+
const BRIEF_PATH = "/app/nano-generated/system-brief.json";
|
|
79
|
+
|
|
80
|
+
function fakeEngine(overrides: Partial<EngineClient> = {}): EngineClient {
|
|
81
|
+
return {
|
|
82
|
+
deployResources: async () => ({ deployed: 0 }),
|
|
83
|
+
createInstance: async () => ({ processInstanceKey: "pi" }),
|
|
84
|
+
cancelInstance: async () => {},
|
|
85
|
+
publishMessage: async () => {},
|
|
86
|
+
searchUserTasks: async () => [],
|
|
87
|
+
openUserTasks: async () => [],
|
|
88
|
+
getForm: async () => null,
|
|
89
|
+
searchProcessInstances: async () => [],
|
|
90
|
+
searchElementInstances: async () => [],
|
|
91
|
+
searchElementInstanceWaitStates: async () => [],
|
|
92
|
+
getElementInstance: async () => null,
|
|
93
|
+
searchIncidents: async () => [],
|
|
94
|
+
resolveIncident: async () => {},
|
|
95
|
+
updateJobRetries: async () => {},
|
|
96
|
+
setVariables: async () => {},
|
|
97
|
+
completeUserTask: async () => {},
|
|
98
|
+
registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
|
|
99
|
+
close: async () => {},
|
|
100
|
+
...overrides,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface LogEntry {
|
|
105
|
+
level: "debug" | "info" | "warn" | "error";
|
|
106
|
+
msg: string;
|
|
107
|
+
fields?: Record<string, unknown>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface Harness {
|
|
111
|
+
router: (r: HttpRequest) => Promise<HttpResponse>;
|
|
112
|
+
imported: string[];
|
|
113
|
+
logs: LogEntry[];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function buildHarness(opts: {
|
|
117
|
+
modules?: Record<string, Record<string, unknown>>;
|
|
118
|
+
engine?: EngineClient;
|
|
119
|
+
data?: DataLayer;
|
|
120
|
+
env?: (v: string) => string | undefined;
|
|
121
|
+
manifestExtra?: Record<string, unknown>;
|
|
122
|
+
briefExists?: boolean;
|
|
123
|
+
spec?: string;
|
|
124
|
+
} = {}): Harness {
|
|
125
|
+
const modules = opts.modules ?? {};
|
|
126
|
+
const engine = opts.engine ?? fakeEngine();
|
|
127
|
+
const data = opts.data ?? new DataLayer(new Map(), undefined, {});
|
|
128
|
+
const env = opts.env ?? (() => undefined);
|
|
129
|
+
const imported: string[] = [];
|
|
130
|
+
const briefExists = opts.briefExists ?? true;
|
|
131
|
+
const spec = opts.spec ?? SPEC;
|
|
132
|
+
const logs: LogEntry[] = [];
|
|
133
|
+
const host: HostContext = {
|
|
134
|
+
runtime: "node",
|
|
135
|
+
env,
|
|
136
|
+
readTextFile: async (p: string) => {
|
|
137
|
+
if (p === BRIEF_PATH) return SYSTEM_BRIEF;
|
|
138
|
+
return spec;
|
|
139
|
+
},
|
|
140
|
+
listDir: async () => [],
|
|
141
|
+
exists: async (p: string) => (p === BRIEF_PATH ? briefExists : false),
|
|
142
|
+
openSqlite: () => {
|
|
143
|
+
throw new Error("sqlite not used in this harness");
|
|
144
|
+
},
|
|
145
|
+
importModule: (path: string) => {
|
|
146
|
+
imported.push(path);
|
|
147
|
+
const mod = modules[path];
|
|
148
|
+
if (!mod) return Promise.reject(new Error(`no module at ${path}`));
|
|
149
|
+
return Promise.resolve(mod);
|
|
150
|
+
},
|
|
151
|
+
serveHttp: async () => ({ port: 0, stop: async () => {} }),
|
|
152
|
+
now: () => 0,
|
|
153
|
+
log: (level, msg, fields) => {
|
|
154
|
+
logs.push({ level, msg, fields });
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
const manifest: AppManifest = { schemaVersion: 1, id: "t", name: "T" };
|
|
158
|
+
Reflect.set(manifest, "api", { spec: "openapi.json" });
|
|
159
|
+
if (opts.manifestExtra) for (const [k, v] of Object.entries(opts.manifestExtra)) Reflect.set(manifest, k, v);
|
|
160
|
+
const app: AppApi = {
|
|
161
|
+
manifest: { schemaVersion: 1, id: "t", name: "T" },
|
|
162
|
+
data,
|
|
163
|
+
engine,
|
|
164
|
+
env: () => undefined,
|
|
165
|
+
now: () => 0,
|
|
166
|
+
wait: () => Promise.resolve(),
|
|
167
|
+
log: createLogger(() => {}),
|
|
168
|
+
};
|
|
169
|
+
const ctx: RuntimeContext = { root: "/app", manifest, engine, host };
|
|
170
|
+
const handle = mountMcp(ctx, app, mountApi(ctx, app).routes);
|
|
171
|
+
const router = makeRouter(handle.routes);
|
|
172
|
+
return { router: (r) => Promise.resolve(router(r)), imported, logs };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---- MCP JSON-RPC driver ----------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
function mcpPost(body: unknown, headers: Record<string, string> = {}): HttpRequest {
|
|
178
|
+
const text = JSON.stringify(body);
|
|
179
|
+
return {
|
|
180
|
+
method: "POST",
|
|
181
|
+
path: "/app/mcp",
|
|
182
|
+
query: new URLSearchParams(),
|
|
183
|
+
headers: new Headers({
|
|
184
|
+
"content-type": "application/json",
|
|
185
|
+
accept: "application/json, text/event-stream",
|
|
186
|
+
host: "localhost",
|
|
187
|
+
...headers,
|
|
188
|
+
}),
|
|
189
|
+
text: () => Promise.resolve(text),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
interface Session {
|
|
194
|
+
sessionId: string;
|
|
195
|
+
protocolVersion: string;
|
|
196
|
+
headers: Record<string, string>;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function initialize(router: Harness["router"]): Promise<Session> {
|
|
200
|
+
const res = await router(
|
|
201
|
+
mcpPost({
|
|
202
|
+
jsonrpc: "2.0",
|
|
203
|
+
id: 0,
|
|
204
|
+
method: "initialize",
|
|
205
|
+
params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "test", version: "1.0" } },
|
|
206
|
+
}),
|
|
207
|
+
);
|
|
208
|
+
assert.equal(res.status ?? 200, 200, `initialize should be 200, got ${res.status}: ${res.body}`);
|
|
209
|
+
const sessionId = res.headers?.["mcp-session-id"];
|
|
210
|
+
assert.ok(sessionId, "initialize must assign an mcp-session-id");
|
|
211
|
+
const parsed = JSON.parse(res.body ?? "{}");
|
|
212
|
+
const protocolVersion = parsed.result?.protocolVersion ?? "2025-06-18";
|
|
213
|
+
const headers = { "mcp-session-id": sessionId, "mcp-protocol-version": protocolVersion };
|
|
214
|
+
await router(mcpPost({ jsonrpc: "2.0", method: "notifications/initialized" }, headers));
|
|
215
|
+
return { sessionId, protocolVersion, headers };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function rpc(router: Harness["router"], session: Session, method: string, params: unknown, id = 1): Promise<{ result?: Record<string, unknown>; error?: { code: number; message: string } }> {
|
|
219
|
+
const res = await router(mcpPost({ jsonrpc: "2.0", id, method, params }, session.headers));
|
|
220
|
+
assert.equal(res.status ?? 200, 200, `${method} should be 200, got ${res.status}: ${res.body}`);
|
|
221
|
+
return JSON.parse(res.body ?? "{}");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function connect(router: Harness["router"]): Promise<Session> {
|
|
225
|
+
return initialize(router);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function toolContentText(result: Record<string, unknown> | undefined): string {
|
|
229
|
+
assert.ok(result, "tools/call must return a result");
|
|
230
|
+
const content = result.content;
|
|
231
|
+
assert.ok(Array.isArray(content) && content.length > 0, "tool result must carry content");
|
|
232
|
+
const first = content[0];
|
|
233
|
+
assert.ok(first && typeof first === "object" && "text" in first, "tool content must be a text block");
|
|
234
|
+
return String(Reflect.get(first, "text"));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---- handshake --------------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
test("the endpoint speaks the MCP initialize handshake for a booted app", async () => {
|
|
240
|
+
const { router } = buildHarness();
|
|
241
|
+
const session = await connect(router);
|
|
242
|
+
assert.ok(session.sessionId.length > 0);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("a POST without a session and without an initialize request is a 400", async () => {
|
|
246
|
+
const { router } = buildHarness();
|
|
247
|
+
const res = await router(mcpPost({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }));
|
|
248
|
+
assert.equal(res.status, 400);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// ---- tool projection --------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
test("every app operation projects to a tool; read tools are annotated read-only, mutating ones destructive", async () => {
|
|
254
|
+
const { router } = buildHarness();
|
|
255
|
+
const session = await connect(router);
|
|
256
|
+
const list = await rpc(router, session, "tools/list", {});
|
|
257
|
+
const tools = list.result?.tools;
|
|
258
|
+
assert.ok(Array.isArray(tools));
|
|
259
|
+
const byName = new Map(tools.map((t) => [Reflect.get(t, "name"), t]));
|
|
260
|
+
assert.ok(byName.has("getInvoice"), "read-only getInvoice must be a tool");
|
|
261
|
+
assert.ok(byName.has("listInvoices"), "read-only listInvoices must be a tool");
|
|
262
|
+
// Slice 3: mutating app operations ARE now projected (guarded by their OpenAPI security at
|
|
263
|
+
// dispatch, or open when the op declares none — matching the HTTP route), no longer withheld.
|
|
264
|
+
assert.ok(byName.has("createInvoice"), "mutating createInvoice must now be a tool (guarded, not withheld)");
|
|
265
|
+
|
|
266
|
+
const readAnn = Reflect.get(byName.get("getInvoice") ?? {}, "annotations");
|
|
267
|
+
assert.equal(Reflect.get(readAnn ?? {}, "readOnlyHint"), true, "a read op must be annotated read-only");
|
|
268
|
+
const mutAnn = Reflect.get(byName.get("createInvoice") ?? {}, "annotations");
|
|
269
|
+
assert.equal(Reflect.get(mutAnn ?? {}, "readOnlyHint"), false, "a mutating op must not be annotated read-only");
|
|
270
|
+
assert.equal(Reflect.get(mutAnn ?? {}, "destructiveHint"), true, "a mutating op must be annotated destructive");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("an x-mcp-excluded operation is withheld from the tool surface while its siblings project", async () => {
|
|
274
|
+
const excludedSpec = JSON.stringify({
|
|
275
|
+
openapi: "3.1.0",
|
|
276
|
+
paths: {
|
|
277
|
+
"/things": {
|
|
278
|
+
get: { operationId: "listThings", summary: "read", responses: { "200": {} } },
|
|
279
|
+
},
|
|
280
|
+
"/things/reindex": {
|
|
281
|
+
post: {
|
|
282
|
+
operationId: "reindexThings",
|
|
283
|
+
summary: "operator-only, withheld",
|
|
284
|
+
"x-mcp": false,
|
|
285
|
+
responses: { "202": {} },
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
const { router } = buildHarness({ spec: excludedSpec });
|
|
291
|
+
const session = await connect(router);
|
|
292
|
+
const list = await rpc(router, session, "tools/list", {});
|
|
293
|
+
const tools = list.result?.tools;
|
|
294
|
+
assert.ok(Array.isArray(tools));
|
|
295
|
+
const names = tools.map((t) => Reflect.get(t, "name"));
|
|
296
|
+
assert.ok(names.includes("listThings"), "a non-excluded op must still project");
|
|
297
|
+
assert.ok(!names.includes("reindexThings"), "an x-mcp-excluded op must NOT appear as a tool");
|
|
298
|
+
|
|
299
|
+
// And it is unreachable by name too — CallTool resolves it to neither a debug nor an app tool.
|
|
300
|
+
const call = await router(
|
|
301
|
+
mcpPost({ jsonrpc: "2.0", id: 9, method: "tools/call", params: { name: "reindexThings", arguments: {} } }, session.headers),
|
|
302
|
+
);
|
|
303
|
+
const parsed = JSON.parse(call.body ?? "{}");
|
|
304
|
+
assert.ok(parsed.error, "an excluded op must not be callable");
|
|
305
|
+
assert.match(String(parsed.error.message), /no such tool/i);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("a projected tool's input schema matches the spec's params", async () => {
|
|
309
|
+
const { router } = buildHarness();
|
|
310
|
+
const session = await connect(router);
|
|
311
|
+
const list = await rpc(router, session, "tools/list", {});
|
|
312
|
+
const tools = list.result?.tools;
|
|
313
|
+
assert.ok(Array.isArray(tools));
|
|
314
|
+
const getInvoice = tools.find((t) => Reflect.get(t, "name") === "getInvoice");
|
|
315
|
+
const schema = Reflect.get(getInvoice ?? {}, "inputSchema");
|
|
316
|
+
assert.deepEqual(schema, {
|
|
317
|
+
type: "object",
|
|
318
|
+
properties: { id: { type: "string" } },
|
|
319
|
+
required: ["id"],
|
|
320
|
+
});
|
|
321
|
+
const listInvoices = tools.find((t) => Reflect.get(t, "name") === "listInvoices");
|
|
322
|
+
const listSchema = Reflect.get(listInvoices ?? {}, "inputSchema");
|
|
323
|
+
assert.deepEqual(listSchema, {
|
|
324
|
+
type: "object",
|
|
325
|
+
properties: { limit: { type: "integer", minimum: 1 } },
|
|
326
|
+
required: ["limit"],
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("a tool call routes through the SAME mountApi delegate as the HTTP call", async () => {
|
|
331
|
+
let seenParams: Record<string, unknown> | undefined;
|
|
332
|
+
const { router, imported } = buildHarness({
|
|
333
|
+
modules: {
|
|
334
|
+
"/app/operations/getInvoice": {
|
|
335
|
+
default: (input: { params: Record<string, unknown> }) => {
|
|
336
|
+
seenParams = input.params;
|
|
337
|
+
return { body: { id: "42", amount: 7 } };
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
const session = await connect(router);
|
|
343
|
+
const call = await rpc(router, session, "tools/call", { name: "getInvoice", arguments: { id: "42" } });
|
|
344
|
+
const text = toolContentText(call.result);
|
|
345
|
+
assert.deepEqual(JSON.parse(text), { id: "42", amount: 7 });
|
|
346
|
+
assert.deepEqual(seenParams, { id: "42" }, "the delegate must receive the validated path param");
|
|
347
|
+
assert.ok(imported.includes("/app/operations/getInvoice"), "the SAME api delegate module must be imported");
|
|
348
|
+
assert.notEqual(call.result?.isError, true);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("a tool call with schema-invalid input fails validation identically to the HTTP call", async () => {
|
|
352
|
+
const { router } = buildHarness({
|
|
353
|
+
modules: {
|
|
354
|
+
"/app/operations/listInvoices": { default: () => ({ body: [] }) },
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
const session = await connect(router);
|
|
358
|
+
// limit=0 violates `minimum: 1` — the reused validator must reject before the delegate runs.
|
|
359
|
+
const call = await rpc(router, session, "tools/call", { name: "listInvoices", arguments: { limit: 0 } });
|
|
360
|
+
assert.equal(call.result?.isError, true, "a validation failure must surface as an error tool result");
|
|
361
|
+
const text = toolContentText(call.result);
|
|
362
|
+
assert.match(text, /validation failed/i);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test("a tool call missing a required path parameter is rejected before dispatch", async () => {
|
|
366
|
+
let delegateRan = false;
|
|
367
|
+
const { router } = buildHarness({
|
|
368
|
+
modules: {
|
|
369
|
+
"/app/operations/getInvoice": {
|
|
370
|
+
default: () => {
|
|
371
|
+
delegateRan = true;
|
|
372
|
+
return { body: { id: "x" } };
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
});
|
|
377
|
+
const session = await connect(router);
|
|
378
|
+
// `getInvoice` declares a required `{id}` path param; omitting it must fail validation up front
|
|
379
|
+
// rather than silently substitute an empty string and 404 (or mis-route to a different shape).
|
|
380
|
+
const call = await rpc(router, session, "tools/call", { name: "getInvoice", arguments: {} });
|
|
381
|
+
assert.equal(call.result?.isError, true, "a missing required path param must surface as an error result");
|
|
382
|
+
const text = toolContentText(call.result);
|
|
383
|
+
assert.match(text, /missing required path parameter/i);
|
|
384
|
+
assert.match(text, /\bid\b/);
|
|
385
|
+
assert.equal(delegateRan, false, "the delegate must not run for a missing-path-param tool call");
|
|
386
|
+
|
|
387
|
+
// A whitespace-only path arg is blank under the shared trimmed-presence rule: it would `fillPath`
|
|
388
|
+
// to a blank segment and mis-route, so it is rejected the same as an omitted param.
|
|
389
|
+
const blankCall = await rpc(router, session, "tools/call", { name: "getInvoice", arguments: { id: " " } });
|
|
390
|
+
assert.equal(blankCall.result?.isError, true, "a whitespace-only path param must also be rejected");
|
|
391
|
+
assert.match(toolContentText(blankCall.result), /missing required path parameter/i);
|
|
392
|
+
assert.equal(delegateRan, false, "the delegate must not run for a blank-path-param tool call");
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// ---- framework debug tools --------------------------------------------------------------------
|
|
396
|
+
|
|
397
|
+
test("the framework debug tools appear and return engine truth", async () => {
|
|
398
|
+
const snapshot: ProcessInstanceSnapshot = {
|
|
399
|
+
processInstanceKey: "pi-1",
|
|
400
|
+
state: "ACTIVE",
|
|
401
|
+
};
|
|
402
|
+
const engine = fakeEngine({ searchProcessInstances: async () => [snapshot] });
|
|
403
|
+
const { router } = buildHarness({ engine });
|
|
404
|
+
const session = await connect(router);
|
|
405
|
+
const list = await rpc(router, session, "tools/list", {});
|
|
406
|
+
const tools = list.result?.tools;
|
|
407
|
+
assert.ok(Array.isArray(tools));
|
|
408
|
+
const names = tools.map((t) => Reflect.get(t, "name"));
|
|
409
|
+
assert.ok(names.includes("urban_debug_search_process_instances"));
|
|
410
|
+
assert.ok(names.includes("urban_debug_search_element_instance_wait_states"));
|
|
411
|
+
assert.ok(names.includes("urban_debug_get_element_instance"));
|
|
412
|
+
assert.ok(names.includes("urban_debug_search_user_tasks"));
|
|
413
|
+
assert.ok(names.includes("urban_debug_instance_state"));
|
|
414
|
+
assert.ok(names.includes("urban_debug_open_user_tasks"));
|
|
415
|
+
|
|
416
|
+
const call = await rpc(router, session, "tools/call", {
|
|
417
|
+
name: "urban_debug_search_process_instances",
|
|
418
|
+
arguments: { state: "ACTIVE" },
|
|
419
|
+
});
|
|
420
|
+
const text = toolContentText(call.result);
|
|
421
|
+
assert.deepEqual(JSON.parse(text), [snapshot]);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("an app operationId in the reserved urban_debug_ namespace can't shadow a framework tool", async () => {
|
|
425
|
+
// A `urban_debug_*` operationId is a legal safe path segment, so an app could declare one. Because
|
|
426
|
+
// `CallTool` resolves framework debug tools before app ops, an unreserved namespace would let such
|
|
427
|
+
// an op be shadowed by (and duplicate the name of) the framework tool. The reservation must drop
|
|
428
|
+
// the colliding app op before projection so `tools/list` stays duplicate-free and the name still
|
|
429
|
+
// reaches the framework debug tool.
|
|
430
|
+
const collidingSpec = JSON.stringify({
|
|
431
|
+
openapi: "3.0.0",
|
|
432
|
+
paths: {
|
|
433
|
+
"/shadow": {
|
|
434
|
+
get: {
|
|
435
|
+
operationId: "urban_debug_search_process_instances",
|
|
436
|
+
summary: "malicious/accidental shadow of a framework tool",
|
|
437
|
+
responses: { "200": {} },
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
},
|
|
441
|
+
});
|
|
442
|
+
const snapshot: ProcessInstanceSnapshot = { processInstanceKey: "pi-1", state: "ACTIVE" };
|
|
443
|
+
const engine = fakeEngine({ searchProcessInstances: async () => [snapshot] });
|
|
444
|
+
const { router } = buildHarness({ engine, spec: collidingSpec });
|
|
445
|
+
const session = await connect(router);
|
|
446
|
+
|
|
447
|
+
const list = await rpc(router, session, "tools/list", {});
|
|
448
|
+
const tools = list.result?.tools;
|
|
449
|
+
assert.ok(Array.isArray(tools));
|
|
450
|
+
const names = tools.map((t) => Reflect.get(t, "name"));
|
|
451
|
+
const shadows = names.filter((n) => n === "urban_debug_search_process_instances");
|
|
452
|
+
assert.equal(shadows.length, 1, "the reserved-namespace app op must not appear as a duplicate tool");
|
|
453
|
+
|
|
454
|
+
// The name resolves to the framework debug tool (engine truth), not the dropped app op.
|
|
455
|
+
const call = await rpc(router, session, "tools/call", {
|
|
456
|
+
name: "urban_debug_search_process_instances",
|
|
457
|
+
arguments: { state: "ACTIVE" },
|
|
458
|
+
});
|
|
459
|
+
const text = toolContentText(call.result);
|
|
460
|
+
assert.deepEqual(JSON.parse(text), [snapshot]);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
test("the reserved-namespace drop warns once, not on every tools/list and tools/call", async () => {
|
|
464
|
+
// The projected op table is memoized per parsed spec doc, so `collectOperations` + the reserved-
|
|
465
|
+
// namespace filter (which emits this warn) runs once — NOT on every request. Guards against the
|
|
466
|
+
// recompute/log-spam defect class: an app that accidentally declares a `urban_debug_*` op must not
|
|
467
|
+
// spam a warn on each of the many `tools/list`/`tools/call` requests a session makes.
|
|
468
|
+
const collidingSpec = JSON.stringify({
|
|
469
|
+
openapi: "3.0.0",
|
|
470
|
+
paths: {
|
|
471
|
+
"/shadow": {
|
|
472
|
+
get: { operationId: "urban_debug_search_process_instances", responses: { "200": {} } },
|
|
473
|
+
},
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
const engine = fakeEngine({ searchProcessInstances: async () => [] });
|
|
477
|
+
const { router, logs } = buildHarness({ engine, spec: collidingSpec });
|
|
478
|
+
const session = await connect(router);
|
|
479
|
+
|
|
480
|
+
await rpc(router, session, "tools/list", {});
|
|
481
|
+
await rpc(router, session, "tools/list", {});
|
|
482
|
+
await rpc(router, session, "tools/call", { name: "urban_debug_search_process_instances", arguments: { state: "ACTIVE" } });
|
|
483
|
+
|
|
484
|
+
const drops = logs.filter((l) => l.msg.includes("reserved framework tool namespace"));
|
|
485
|
+
assert.equal(drops.length, 1, "the reserved-namespace drop must warn exactly once across many requests");
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test("the projection debug tools read the ADR 0065 canonical stores", async () => {
|
|
489
|
+
const dir = await mkdtemp(join(tmpdir(), "urban-mcp-proj-"));
|
|
490
|
+
const host = createNodeHost({ cwd: dir, log: () => {} });
|
|
491
|
+
const db: SqliteDb = host.openSqlite(join(dir, "test.db"));
|
|
492
|
+
try {
|
|
493
|
+
new InstanceStateStore(db, { clock: { now: () => 0 } }).ensureSchema();
|
|
494
|
+
new InstanceStateStore(db, { clock: { now: () => 0 } }).recordState("pi-7", "ACTIVE");
|
|
495
|
+
const openStore = new OpenUserTasksStore(db, { clock: { now: () => 0 } });
|
|
496
|
+
openStore.ensureSchema();
|
|
497
|
+
openStore.recordOpenTask("pi-7", { userTaskKey: "ut-1", elementId: "Task_A" });
|
|
498
|
+
|
|
499
|
+
const source: ProvisionedSource = {
|
|
500
|
+
name: "default",
|
|
501
|
+
driver: "sqlite",
|
|
502
|
+
db,
|
|
503
|
+
source: makeGateway(db),
|
|
504
|
+
migrationsApplied: [],
|
|
505
|
+
close: () => {},
|
|
506
|
+
};
|
|
507
|
+
const data = new DataLayer(new Map([["default", source]]), "default", {});
|
|
508
|
+
const { router } = buildHarness({ data });
|
|
509
|
+
const session = await connect(router);
|
|
510
|
+
|
|
511
|
+
const stateCall = await rpc(router, session, "tools/call", {
|
|
512
|
+
name: "urban_debug_instance_state",
|
|
513
|
+
arguments: { processInstanceKey: "pi-7" },
|
|
514
|
+
});
|
|
515
|
+
const state = JSON.parse(toolContentText(stateCall.result));
|
|
516
|
+
assert.equal(Reflect.get(state, "state"), "ACTIVE");
|
|
517
|
+
|
|
518
|
+
const tasksCall = await rpc(router, session, "tools/call", {
|
|
519
|
+
name: "urban_debug_open_user_tasks",
|
|
520
|
+
arguments: { processInstanceKey: "pi-7" },
|
|
521
|
+
});
|
|
522
|
+
const tasks = JSON.parse(toolContentText(tasksCall.result));
|
|
523
|
+
assert.ok(Array.isArray(tasks) && tasks.length === 1);
|
|
524
|
+
assert.equal(Reflect.get(tasks[0], "userTaskKey"), "ut-1");
|
|
525
|
+
} finally {
|
|
526
|
+
db.close();
|
|
527
|
+
await rm(dir, { recursive: true, force: true });
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
// ---- mutation guard (Slice 3) -----------------------------------------------------------------
|
|
532
|
+
|
|
533
|
+
// A repeated credential header must read the SAME under the MCP guard as under OpenAPI route
|
|
534
|
+
// enforcement. `mountApi`/`toHttpRequest` read it via `Headers.get()`, which comma-joins repeated
|
|
535
|
+
// values; `readHeader` must match that (not pick the first) so the shared-secret guard cannot
|
|
536
|
+
// diverge from route enforcement on a `string[]` header (as Node's raw adapter surfaces it).
|
|
537
|
+
test("readHeader comma-joins a repeated header to match Headers.get() semantics", () => {
|
|
538
|
+
// Baseline: a single string value is returned verbatim; a missing header is undefined.
|
|
539
|
+
assert.equal(readHeader({ "x-nano-key": "abc" }, "X-Nano-Key"), "abc");
|
|
540
|
+
assert.equal(readHeader({}, "X-Nano-Key"), undefined);
|
|
541
|
+
// A repeated header (string[]) is joined with ", " — exactly what `Headers.get()` returns for the
|
|
542
|
+
// reconstructed request in `toHttpRequest`, so the guard reads the identical credential string.
|
|
543
|
+
const joined = readHeader({ "x-nano-key": ["a", "b"] }, "X-Nano-Key");
|
|
544
|
+
const viaHeaders = (() => {
|
|
545
|
+
const h = new Headers();
|
|
546
|
+
for (const v of ["a", "b"]) h.append("X-Nano-Key", v);
|
|
547
|
+
return h.get("X-Nano-Key") ?? undefined;
|
|
548
|
+
})();
|
|
549
|
+
assert.equal(joined, viaHeaders, "readHeader must match Headers.get() for a repeated header");
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
// A spec that declares the app's shared-secret scheme (an apiKey header pointing at an env var via
|
|
553
|
+
// `x-nano-secret-env`) — the credential the framework MUTATING tools require. REUSING the app's
|
|
554
|
+
// existing shared secret, never an MCP-specific synonym (ADR 0067 §4).
|
|
555
|
+
const GUARDED_SPEC = JSON.stringify({
|
|
556
|
+
openapi: "3.1.0",
|
|
557
|
+
components: {
|
|
558
|
+
securitySchemes: {
|
|
559
|
+
appSecret: { type: "apiKey", in: "header", name: "X-Nano-Key", "x-nano-secret-env": "NANO_WEBHOOK_KEY" },
|
|
560
|
+
},
|
|
561
|
+
},
|
|
562
|
+
paths: {
|
|
563
|
+
"/invoices": {
|
|
564
|
+
get: { operationId: "listInvoices", summary: "List invoices", responses: { "200": {} } },
|
|
565
|
+
},
|
|
566
|
+
},
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
const SECRET = "s3cr3t-value";
|
|
570
|
+
const guardEnv = (v: string) => (v === "NANO_WEBHOOK_KEY" ? SECRET : undefined);
|
|
571
|
+
|
|
572
|
+
test("a mutating framework tool refuses without the guard credential and succeeds with it", async () => {
|
|
573
|
+
const cancelled: string[] = [];
|
|
574
|
+
const engine = fakeEngine({
|
|
575
|
+
cancelInstance: async ({ processInstanceKey }) => {
|
|
576
|
+
cancelled.push(processInstanceKey);
|
|
577
|
+
},
|
|
578
|
+
});
|
|
579
|
+
const { router } = buildHarness({ engine, spec: GUARDED_SPEC, env: guardEnv });
|
|
580
|
+
const session = await connect(router);
|
|
581
|
+
|
|
582
|
+
// Without the credential the mutation is refused before the engine is touched.
|
|
583
|
+
const refused = await rpc(router, session, "tools/call", {
|
|
584
|
+
name: "urban_debug_cancel_instance",
|
|
585
|
+
arguments: { processInstanceKey: "pi-1" },
|
|
586
|
+
});
|
|
587
|
+
assert.equal(refused.result?.isError, true, "a mutating tool must refuse without the guard credential");
|
|
588
|
+
assert.match(toolContentText(refused.result), /unauthorized/i);
|
|
589
|
+
assert.deepEqual(cancelled, [], "the engine mutation must not run for an unauthorized call");
|
|
590
|
+
|
|
591
|
+
// With the correct shared secret on the connection header the mutation runs.
|
|
592
|
+
const authedSession = { ...session, headers: { ...session.headers, "X-Nano-Key": SECRET } };
|
|
593
|
+
const ok = await rpc(router, authedSession, "tools/call", {
|
|
594
|
+
name: "urban_debug_cancel_instance",
|
|
595
|
+
arguments: { processInstanceKey: "pi-1" },
|
|
596
|
+
});
|
|
597
|
+
assert.notEqual(ok.result?.isError, true, "a mutating tool must succeed with the guard credential");
|
|
598
|
+
assert.deepEqual(cancelled, ["pi-1"], "the engine mutation must run once authorized");
|
|
599
|
+
|
|
600
|
+
// A wrong credential is refused too (constant-time compare via evaluateSecurity).
|
|
601
|
+
const wrongSession = { ...session, headers: { ...session.headers, "X-Nano-Key": "nope" } };
|
|
602
|
+
const wrong = await rpc(router, wrongSession, "tools/call", {
|
|
603
|
+
name: "urban_debug_cancel_instance",
|
|
604
|
+
arguments: { processInstanceKey: "pi-2" },
|
|
605
|
+
});
|
|
606
|
+
assert.equal(wrong.result?.isError, true, "a wrong credential must be refused");
|
|
607
|
+
assert.deepEqual(cancelled, ["pi-1"], "a wrong-credential call must not run the mutation");
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
test("read tools work on loopback without any credential, even when a shared-secret scheme is declared", async () => {
|
|
611
|
+
const engine = fakeEngine({ searchIncidents: async () => [] });
|
|
612
|
+
const { router } = buildHarness({
|
|
613
|
+
engine,
|
|
614
|
+
spec: GUARDED_SPEC,
|
|
615
|
+
env: guardEnv,
|
|
616
|
+
modules: { "/app/operations/listInvoices": { default: () => ({ body: [{ id: "1" }] }) } },
|
|
617
|
+
});
|
|
618
|
+
const session = await connect(router);
|
|
619
|
+
|
|
620
|
+
// A read app operation dispatches with no credential (its GET route declares no security).
|
|
621
|
+
const appRead = await rpc(router, session, "tools/call", { name: "listInvoices", arguments: {} });
|
|
622
|
+
assert.notEqual(appRead.result?.isError, true, "a read app op must serve on loopback without a credential");
|
|
623
|
+
assert.deepEqual(JSON.parse(toolContentText(appRead.result)), [{ id: "1" }]);
|
|
624
|
+
|
|
625
|
+
// A read framework debug tool likewise needs no credential.
|
|
626
|
+
const dbgRead = await rpc(router, session, "tools/call", {
|
|
627
|
+
name: "urban_debug_search_incidents",
|
|
628
|
+
arguments: { state: "ACTIVE" },
|
|
629
|
+
});
|
|
630
|
+
assert.notEqual(dbgRead.result?.isError, true, "a read debug tool must serve on loopback without a credential");
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
test("the explicit allowMutations opt-in authorizes a mutating tool without a credential", async () => {
|
|
634
|
+
const resolved: string[] = [];
|
|
635
|
+
const engine = fakeEngine({
|
|
636
|
+
resolveIncident: async ({ incidentKey }) => {
|
|
637
|
+
resolved.push(incidentKey);
|
|
638
|
+
},
|
|
639
|
+
});
|
|
640
|
+
// No shared-secret scheme in the spec; the runtime opt-in alone opens mutations.
|
|
641
|
+
const { router } = buildHarness({
|
|
642
|
+
engine,
|
|
643
|
+
env: (v) => (v === "URBAN_MCP_ALLOW_MUTATIONS" ? "true" : undefined),
|
|
644
|
+
});
|
|
645
|
+
const session = await connect(router);
|
|
646
|
+
const ok = await rpc(router, session, "tools/call", {
|
|
647
|
+
name: "urban_debug_resolve_incident",
|
|
648
|
+
arguments: { incidentKey: "inc-9" },
|
|
649
|
+
});
|
|
650
|
+
assert.notEqual(ok.result?.isError, true, "allowMutations must authorize a mutation with no credential");
|
|
651
|
+
assert.deepEqual(resolved, ["inc-9"]);
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
test("the allowMutations opt-in is loopback-only: with allowRemote on, mutations still require the shared secret", async () => {
|
|
655
|
+
// Regression guard for the failure class: a credential-free opt-in must NOT silently apply once
|
|
656
|
+
// the surface is opened to non-loopback callers, or mutating urban_debug_* tools would be exposed
|
|
657
|
+
// remotely with no guard at all. With allowMutations AND allowRemote both on, the bypass is
|
|
658
|
+
// ignored and the shared secret is still enforced (refused without it, allowed with it).
|
|
659
|
+
const cancelled: string[] = [];
|
|
660
|
+
const engine = fakeEngine({
|
|
661
|
+
cancelInstance: async ({ processInstanceKey }) => {
|
|
662
|
+
cancelled.push(processInstanceKey);
|
|
663
|
+
},
|
|
664
|
+
});
|
|
665
|
+
const remoteMutateEnv = (v: string) =>
|
|
666
|
+
v === "URBAN_MCP_ALLOW_MUTATIONS" || v === "URBAN_MCP_ALLOW_REMOTE"
|
|
667
|
+
? "true"
|
|
668
|
+
: v === "NANO_WEBHOOK_KEY"
|
|
669
|
+
? SECRET
|
|
670
|
+
: undefined;
|
|
671
|
+
const { router } = buildHarness({ engine, spec: GUARDED_SPEC, env: remoteMutateEnv });
|
|
672
|
+
const session = await connect(router);
|
|
673
|
+
|
|
674
|
+
// allowMutations is set, but because allowRemote is on it must NOT bypass the shared secret.
|
|
675
|
+
const refused = await rpc(router, session, "tools/call", {
|
|
676
|
+
name: "urban_debug_cancel_instance",
|
|
677
|
+
arguments: { processInstanceKey: "pi-1" },
|
|
678
|
+
});
|
|
679
|
+
assert.equal(refused.result?.isError, true, "allowMutations must not bypass the guard when allowRemote is on");
|
|
680
|
+
assert.equal(JSON.parse(toolContentText(refused.result)).status, 401, "a remote-exposed mutation without the secret is 401");
|
|
681
|
+
assert.deepEqual(cancelled, [], "the engine mutation must not run for an unauthorized remote call");
|
|
682
|
+
|
|
683
|
+
// Presenting the app's shared secret still authorizes it (the shared-secret path is unchanged).
|
|
684
|
+
const authed = { ...session, headers: { ...session.headers, "X-Nano-Key": SECRET } };
|
|
685
|
+
const ok = await rpc(router, authed, "tools/call", {
|
|
686
|
+
name: "urban_debug_cancel_instance",
|
|
687
|
+
arguments: { processInstanceKey: "pi-1" },
|
|
688
|
+
});
|
|
689
|
+
assert.notEqual(ok.result?.isError, true, "the shared secret must still authorize a mutation under allowRemote");
|
|
690
|
+
assert.deepEqual(cancelled, ["pi-1"]);
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
test("with allowMutations + allowRemote but no shared-secret scheme, mutations fail closed", async () => {
|
|
694
|
+
// The remote-exposed bypass being ignored must leave NO other credential-free path: with no
|
|
695
|
+
// shared-secret scheme configured either, the mutation is refused rather than silently allowed.
|
|
696
|
+
const engine = fakeEngine({ resolveIncident: async () => assert.fail("must not run") });
|
|
697
|
+
const remoteMutateEnv = (v: string) =>
|
|
698
|
+
v === "URBAN_MCP_ALLOW_MUTATIONS" || v === "URBAN_MCP_ALLOW_REMOTE" ? "true" : undefined;
|
|
699
|
+
const { router } = buildHarness({ engine, env: remoteMutateEnv });
|
|
700
|
+
const session = await connect(router);
|
|
701
|
+
const refused = await rpc(router, session, "tools/call", {
|
|
702
|
+
name: "urban_debug_resolve_incident",
|
|
703
|
+
arguments: { incidentKey: "inc-9" },
|
|
704
|
+
});
|
|
705
|
+
assert.equal(refused.result?.isError, true, "a remote-exposed mutation with no scheme and no in-scope opt-in must fail closed");
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
test("with no shared-secret scheme and no opt-in, a mutating tool fails closed", async () => {
|
|
709
|
+
const engine = fakeEngine({ setVariables: async () => assert.fail("must not run") });
|
|
710
|
+
const { router } = buildHarness({ engine });
|
|
711
|
+
const session = await connect(router);
|
|
712
|
+
const refused = await rpc(router, session, "tools/call", {
|
|
713
|
+
name: "urban_debug_set_variables",
|
|
714
|
+
arguments: { scopeKey: "pi-1", variables: { x: 1 } },
|
|
715
|
+
});
|
|
716
|
+
assert.equal(refused.result?.isError, true, "a mutation must fail closed when nothing can authorize it");
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
test("a mutating tool refused for a misconfigured shared secret logs a 500 and surfaces the status", async () => {
|
|
720
|
+
// The spec declares a shared-secret scheme, but its secret env var is unset — a server
|
|
721
|
+
// misconfiguration (500), not a bad credential (401). Like `mountApi`, the MCP guard must log it
|
|
722
|
+
// for operators and carry the 500 status in the error payload so a client can tell them apart.
|
|
723
|
+
const engine = fakeEngine({ cancelInstance: async () => assert.fail("must not run") });
|
|
724
|
+
const { router, logs } = buildHarness({ engine, spec: GUARDED_SPEC, env: () => undefined });
|
|
725
|
+
const session = await connect(router);
|
|
726
|
+
const refused = await rpc(router, session, "tools/call", {
|
|
727
|
+
name: "urban_debug_cancel_instance",
|
|
728
|
+
arguments: { processInstanceKey: "pi-1" },
|
|
729
|
+
});
|
|
730
|
+
assert.equal(refused.result?.isError, true, "a misconfigured shared secret must refuse the mutation");
|
|
731
|
+
const payload = JSON.parse(toolContentText(refused.result));
|
|
732
|
+
assert.equal(payload.status, 500, "a misconfig refusal must surface status 500, not 401");
|
|
733
|
+
assert.match(payload.error, /misconfigured/i);
|
|
734
|
+
const misconfigLogs = logs.filter((l) => l.level === "error" && l.msg.includes("misconfigured"));
|
|
735
|
+
assert.equal(misconfigLogs.length, 1, "a 500-class refusal must be logged for operators");
|
|
736
|
+
assert.equal(misconfigLogs[0].fields?.tool, "urban_debug_cancel_instance");
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
test("a mutating tool refused for a missing credential surfaces a 401 status without an error log", async () => {
|
|
740
|
+
const engine = fakeEngine({ cancelInstance: async () => assert.fail("must not run") });
|
|
741
|
+
const { router, logs } = buildHarness({ engine, spec: GUARDED_SPEC, env: guardEnv });
|
|
742
|
+
const session = await connect(router);
|
|
743
|
+
const refused = await rpc(router, session, "tools/call", {
|
|
744
|
+
name: "urban_debug_cancel_instance",
|
|
745
|
+
arguments: { processInstanceKey: "pi-1" },
|
|
746
|
+
});
|
|
747
|
+
assert.equal(refused.result?.isError, true, "a missing credential must refuse the mutation");
|
|
748
|
+
const payload = JSON.parse(toolContentText(refused.result));
|
|
749
|
+
assert.equal(payload.status, 401, "a missing/invalid credential must surface status 401");
|
|
750
|
+
assert.equal(
|
|
751
|
+
logs.filter((l) => l.level === "error" && l.msg.includes("misconfigured")).length,
|
|
752
|
+
0,
|
|
753
|
+
"a plain unauthorized (401) is not a misconfig and must not be logged as one",
|
|
754
|
+
);
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
test("a mutating tool refused because the OpenAPI spec failed to load surfaces a 500, not a 401", async () => {
|
|
758
|
+
// A broken/unparseable spec is a server misconfiguration: the guard cannot tell whether a
|
|
759
|
+
// shared-secret scheme exists, so it must surface a 500 (logged for operators) rather than a
|
|
760
|
+
// misleading 401 that looks like a missing credential.
|
|
761
|
+
const engine = fakeEngine({ cancelInstance: async () => assert.fail("must not run") });
|
|
762
|
+
// Valid JSON but not an object at the root — `parseSpec` throws, so `loadSpecDoc` fails to load.
|
|
763
|
+
const { router, logs } = buildHarness({ engine, spec: "[1, 2, 3]", env: guardEnv });
|
|
764
|
+
const session = await connect(router);
|
|
765
|
+
const refused = await rpc(router, session, "tools/call", {
|
|
766
|
+
name: "urban_debug_cancel_instance",
|
|
767
|
+
arguments: { processInstanceKey: "pi-1" },
|
|
768
|
+
});
|
|
769
|
+
assert.equal(refused.result?.isError, true, "a broken spec must refuse the mutation");
|
|
770
|
+
const payload = JSON.parse(toolContentText(refused.result));
|
|
771
|
+
assert.equal(payload.status, 500, "a spec-load failure must surface status 500, not 401");
|
|
772
|
+
assert.match(payload.error, /failed to load/i);
|
|
773
|
+
const loadWarns = logs.filter((l) => l.level === "warn" && l.msg.includes("failed to load the app OpenAPI spec"));
|
|
774
|
+
assert.equal(loadWarns.length >= 1, true, "the spec-load failure must be logged");
|
|
775
|
+
const misconfigLogs = logs.filter((l) => l.level === "error" && l.msg.includes("misconfigured"));
|
|
776
|
+
assert.equal(misconfigLogs.length, 1, "a 500-class refusal must be logged for operators");
|
|
777
|
+
assert.equal(misconfigLogs[0].fields?.tool, "urban_debug_cancel_instance");
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
test("an authorized mutating tool rejects a blank/non-string key or negative retries before touching the engine", async () => {
|
|
781
|
+
const touched: string[] = [];
|
|
782
|
+
const engine = fakeEngine({
|
|
783
|
+
cancelInstance: async () => {
|
|
784
|
+
touched.push("cancelInstance");
|
|
785
|
+
},
|
|
786
|
+
resolveIncident: async () => {
|
|
787
|
+
touched.push("resolveIncident");
|
|
788
|
+
},
|
|
789
|
+
updateJobRetries: async () => {
|
|
790
|
+
touched.push("updateJobRetries");
|
|
791
|
+
},
|
|
792
|
+
setVariables: async () => {
|
|
793
|
+
touched.push("setVariables");
|
|
794
|
+
},
|
|
795
|
+
});
|
|
796
|
+
// Fully authorized via the opt-in — so a rejection can only come from argument validation.
|
|
797
|
+
const { router } = buildHarness({
|
|
798
|
+
engine,
|
|
799
|
+
env: (v) => (v === "URBAN_MCP_ALLOW_MUTATIONS" ? "true" : undefined),
|
|
800
|
+
});
|
|
801
|
+
const session = await connect(router);
|
|
802
|
+
|
|
803
|
+
const badCalls: { name: string; arguments: Record<string, unknown>; why: RegExp }[] = [
|
|
804
|
+
// Blank strings are rejected upstream as a missing required parameter; a non-string value slips
|
|
805
|
+
// past that check (it is "present") and must be rejected by the tool's own argument guard.
|
|
806
|
+
{ name: "urban_debug_cancel_instance", arguments: { processInstanceKey: "" }, why: /missing required/i },
|
|
807
|
+
{ name: "urban_debug_cancel_instance", arguments: { processInstanceKey: 123 }, why: /non-empty string/i },
|
|
808
|
+
{ name: "urban_debug_resolve_incident", arguments: { incidentKey: 7 }, why: /non-empty string/i },
|
|
809
|
+
{ name: "urban_debug_set_variables", arguments: { scopeKey: 1, variables: { x: 1 } }, why: /non-empty string/i },
|
|
810
|
+
{ name: "urban_debug_retry_job", arguments: { jobKey: 42, retries: 0 }, why: /non-empty string/i },
|
|
811
|
+
{ name: "urban_debug_retry_job", arguments: { jobKey: "j-1", retries: -1 }, why: /non-negative integer/i },
|
|
812
|
+
];
|
|
813
|
+
|
|
814
|
+
for (const call of badCalls) {
|
|
815
|
+
const res = await rpc(router, session, "tools/call", { name: call.name, arguments: call.arguments });
|
|
816
|
+
assert.equal(res.result?.isError, true, `${call.name} must reject invalid args: ${JSON.stringify(call.arguments)}`);
|
|
817
|
+
assert.match(toolContentText(res.result), call.why);
|
|
818
|
+
}
|
|
819
|
+
assert.deepEqual(touched, [], "no invalid mutating call may reach the engine");
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
test("an authorized mutating tool trims a padded identifying key before mutating the engine", async () => {
|
|
823
|
+
// A padded key like " pi-1 " is "present" (the required-arg check trims via presentFormIdentifier),
|
|
824
|
+
// so it slips past presence validation. The tool's own `requireString` guard must apply the SAME
|
|
825
|
+
// trimmed-presence rule and forward the TRIMMED value, or the engine mutation targets a whitespace-
|
|
826
|
+
// padded entity that does not exist. This guards the drift between the presence check and the guard.
|
|
827
|
+
const cancelled: string[] = [];
|
|
828
|
+
const engine = fakeEngine({
|
|
829
|
+
cancelInstance: async ({ processInstanceKey }) => {
|
|
830
|
+
cancelled.push(processInstanceKey);
|
|
831
|
+
},
|
|
832
|
+
});
|
|
833
|
+
const { router } = buildHarness({
|
|
834
|
+
engine,
|
|
835
|
+
env: (v) => (v === "URBAN_MCP_ALLOW_MUTATIONS" ? "true" : undefined),
|
|
836
|
+
});
|
|
837
|
+
const session = await connect(router);
|
|
838
|
+
const ok = await rpc(router, session, "tools/call", {
|
|
839
|
+
name: "urban_debug_cancel_instance",
|
|
840
|
+
arguments: { processInstanceKey: " pi-1 " },
|
|
841
|
+
});
|
|
842
|
+
assert.notEqual(ok.result?.isError, true, "a padded but present key must be accepted");
|
|
843
|
+
assert.deepEqual(cancelled, ["pi-1"], "the engine must receive the trimmed key, not the padded one");
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
test("a read tool trims a padded filter value before querying the engine", async () => {
|
|
847
|
+
// The read path had the same drift the mutating guard closes: an optional filter value read raw
|
|
848
|
+
// reaches the engine untrimmed, so a padded key like " pi-1 " becomes a filter that silently
|
|
849
|
+
// matches nothing. `readPresentString` — the single source of truth `requireString` derives from —
|
|
850
|
+
// applies the same trimmed-presence rule to optional reads, so search filters cannot mis-target.
|
|
851
|
+
const seen: (string | undefined)[] = [];
|
|
852
|
+
const engine = fakeEngine({
|
|
853
|
+
searchIncidents: async (filter) => {
|
|
854
|
+
seen.push(filter?.processInstanceKey);
|
|
855
|
+
return [];
|
|
856
|
+
},
|
|
857
|
+
});
|
|
858
|
+
const { router } = buildHarness({ engine });
|
|
859
|
+
const session = await connect(router);
|
|
860
|
+
const ok = await rpc(router, session, "tools/call", {
|
|
861
|
+
name: "urban_debug_search_incidents",
|
|
862
|
+
arguments: { processInstanceKey: " pi-1 " },
|
|
863
|
+
});
|
|
864
|
+
assert.notEqual(ok.result?.isError, true, "a padded filter value must be accepted");
|
|
865
|
+
assert.deepEqual(seen, ["pi-1"], "the engine must receive the trimmed filter value, not the padded one");
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
test("every debug tool with a required string key rejects a non-string value before querying/mutating", async () => {
|
|
869
|
+
// Defect-class guard (not a single-line squash): `missingRequiredArgs` treats a present non-string
|
|
870
|
+
// (number/object) as provided, so any tool that declares a key `required` but reads it with a
|
|
871
|
+
// degrade-to-empty pattern (`readPresentString(...) ?? ""`) would let that value slip past the
|
|
872
|
+
// presence check and query/mutate with an empty key — a confusing empty result, not a fast failure.
|
|
873
|
+
// The canonical fix is that EVERY required string key is read via `requireString`, which fails fast
|
|
874
|
+
// with InvalidParams. This test enumerates the live tool list and asserts that invariant holds for
|
|
875
|
+
// ALL current and future debug tools, so a new tool cannot silently reintroduce the drift.
|
|
876
|
+
const { router } = buildHarness({
|
|
877
|
+
// Fully authorized so a rejection can only come from argument validation, not the mutation gate.
|
|
878
|
+
env: (v) => (v === "URBAN_MCP_ALLOW_MUTATIONS" ? "true" : undefined),
|
|
879
|
+
});
|
|
880
|
+
const session = await connect(router);
|
|
881
|
+
const list = await rpc(router, session, "tools/list", {});
|
|
882
|
+
const tools = list.result?.tools;
|
|
883
|
+
assert.ok(Array.isArray(tools));
|
|
884
|
+
|
|
885
|
+
// A valid dummy for a required key of a given JSON-schema type, used to satisfy the OTHER required
|
|
886
|
+
// keys so the rejection is attributable solely to the one non-string key we poke.
|
|
887
|
+
const dummyForType = (type: unknown): unknown => {
|
|
888
|
+
if (type === "string") return "dummy";
|
|
889
|
+
if (type === "integer" || type === "number") return 0;
|
|
890
|
+
if (type === "boolean") return false;
|
|
891
|
+
if (type === "object") return {};
|
|
892
|
+
if (type === "array") return [];
|
|
893
|
+
return "dummy";
|
|
894
|
+
};
|
|
895
|
+
|
|
896
|
+
let requiredStringKeysChecked = 0;
|
|
897
|
+
for (const tool of tools) {
|
|
898
|
+
const name = Reflect.get(tool, "name");
|
|
899
|
+
if (typeof name !== "string" || !name.startsWith("urban_debug_")) continue;
|
|
900
|
+
const schema = Reflect.get(tool, "inputSchema");
|
|
901
|
+
if (schema === null || typeof schema !== "object") continue;
|
|
902
|
+
const required = Reflect.get(schema, "required");
|
|
903
|
+
const properties = Reflect.get(schema, "properties");
|
|
904
|
+
if (!Array.isArray(required) || properties === null || typeof properties !== "object") continue;
|
|
905
|
+
|
|
906
|
+
const requiredStringKeys = required.filter((k) => {
|
|
907
|
+
if (typeof k !== "string") return false;
|
|
908
|
+
const prop = Reflect.get(properties, k);
|
|
909
|
+
return prop !== null && typeof prop === "object" && Reflect.get(prop, "type") === "string";
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
for (const key of requiredStringKeys) {
|
|
913
|
+
const args: Record<string, unknown> = {};
|
|
914
|
+
for (const other of required) {
|
|
915
|
+
if (typeof other !== "string") continue;
|
|
916
|
+
const prop = Reflect.get(properties, other);
|
|
917
|
+
args[other] = dummyForType(prop !== null && typeof prop === "object" ? Reflect.get(prop, "type") : undefined);
|
|
918
|
+
}
|
|
919
|
+
// Poke exactly one required string key with a present-but-non-string value.
|
|
920
|
+
args[key] = 123;
|
|
921
|
+
const res = await rpc(router, session, "tools/call", { name, arguments: args });
|
|
922
|
+
assert.equal(
|
|
923
|
+
res.result?.isError,
|
|
924
|
+
true,
|
|
925
|
+
`${name} must reject a non-string ${key} instead of querying/mutating with an empty key`,
|
|
926
|
+
);
|
|
927
|
+
assert.match(toolContentText(res.result), /non-empty string/i, `${name}.${key} must fail fast via requireString`);
|
|
928
|
+
requiredStringKeysChecked++;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
// Guard the guard: if this drops to zero the enumeration silently stopped covering anything.
|
|
932
|
+
assert.ok(requiredStringKeysChecked >= 3, "expected at least the known required-string-key debug tools to be checked");
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
// ---- spec ↔ tool parity guard (Slice 3) -------------------------------------------------------
|
|
936
|
+
|
|
937
|
+
test("the spec↔tool parity guard reports drift on an injected skew and none in parity", () => {
|
|
938
|
+
const doc = parseSpec(
|
|
939
|
+
JSON.stringify({
|
|
940
|
+
openapi: "3.1.0",
|
|
941
|
+
paths: {
|
|
942
|
+
"/a": { get: { operationId: "readA", responses: { "200": {} } } },
|
|
943
|
+
"/b": { post: { operationId: "mutateB", responses: { "201": {} } } },
|
|
944
|
+
"/c": { post: { operationId: "hiddenC", "x-mcp": false, responses: { "202": {} } } },
|
|
945
|
+
},
|
|
946
|
+
}),
|
|
947
|
+
);
|
|
948
|
+
const projection = collectMcpToolProjection(doc);
|
|
949
|
+
// A snapshot identical to the current projection is in parity.
|
|
950
|
+
assert.deepEqual(diffMcpToolProjection(projection, projection), []);
|
|
951
|
+
|
|
952
|
+
// Inject skew: drop one op, flip another's mutating/excluded facts.
|
|
953
|
+
const skewed = projection
|
|
954
|
+
.filter((p) => p.operationId !== "readA")
|
|
955
|
+
.map((p) => (p.operationId === "hiddenC" ? { ...p, excluded: false } : p));
|
|
956
|
+
const drift = diffMcpToolProjection(skewed, projection);
|
|
957
|
+
assert.ok(drift.length >= 2, "both a missing op and a changed fact must be reported");
|
|
958
|
+
assert.ok(drift.some((l) => l.includes("readA")), "the dropped op must be reported");
|
|
959
|
+
assert.ok(drift.some((l) => l.includes("hiddenC")), "the flipped exclusion must be reported");
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
// ---- resource + prompt ------------------------------------------------------------------------
|
|
963
|
+
|
|
964
|
+
test("a debug tool call missing a required parameter is rejected before its handler runs", async () => {
|
|
965
|
+
let handlerRan = false;
|
|
966
|
+
const engine = fakeEngine({
|
|
967
|
+
getElementInstance: async () => {
|
|
968
|
+
handlerRan = true;
|
|
969
|
+
return null;
|
|
970
|
+
},
|
|
971
|
+
});
|
|
972
|
+
const { router } = buildHarness({ engine });
|
|
973
|
+
const session = await connect(router);
|
|
974
|
+
// `urban_debug_get_element_instance` declares `elementInstanceKey` required; omitting it must
|
|
975
|
+
// fail validation up front rather than substitute an empty string and query the engine with it.
|
|
976
|
+
const call = await rpc(router, session, "tools/call", {
|
|
977
|
+
name: "urban_debug_get_element_instance",
|
|
978
|
+
arguments: {},
|
|
979
|
+
});
|
|
980
|
+
assert.equal(call.result?.isError, true, "a missing required debug-tool arg must surface as an error result");
|
|
981
|
+
const text = toolContentText(call.result);
|
|
982
|
+
assert.match(text, /missing required parameter/i);
|
|
983
|
+
assert.match(text, /elementInstanceKey/);
|
|
984
|
+
assert.equal(handlerRan, false, "the debug handler must not run for a missing required-arg call");
|
|
985
|
+
|
|
986
|
+
// An empty string is treated as absent too — it is exactly the masked-invalid-call the guard closes.
|
|
987
|
+
const emptyCall = await rpc(router, session, "tools/call", {
|
|
988
|
+
name: "urban_debug_get_element_instance",
|
|
989
|
+
arguments: { elementInstanceKey: "" },
|
|
990
|
+
});
|
|
991
|
+
assert.equal(emptyCall.result?.isError, true, "an empty required arg must also be rejected");
|
|
992
|
+
assert.equal(handlerRan, false, "the debug handler must not run for an empty required-arg call");
|
|
993
|
+
|
|
994
|
+
// A whitespace-only value is absent under the same trimmed-presence rule `presentFormIdentifier`
|
|
995
|
+
// enforces — it would otherwise slip through and query the engine with a blank identifier.
|
|
996
|
+
const blankCall = await rpc(router, session, "tools/call", {
|
|
997
|
+
name: "urban_debug_get_element_instance",
|
|
998
|
+
arguments: { elementInstanceKey: " " },
|
|
999
|
+
});
|
|
1000
|
+
assert.equal(blankCall.result?.isError, true, "a whitespace-only required arg must also be rejected");
|
|
1001
|
+
assert.match(toolContentText(blankCall.result), /missing required parameter/i);
|
|
1002
|
+
assert.equal(handlerRan, false, "the debug handler must not run for a whitespace-only required-arg call");
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
test("missingRequiredArgs treats a present non-string value as provided, not missing", () => {
|
|
1006
|
+
// Presence is decided solely by isBlankArg, so a required numeric/boolean/object arg that is
|
|
1007
|
+
// actually supplied must NOT be reported missing — the earlier `typeof value !== "string"` guard
|
|
1008
|
+
// wrongly rejected every non-string required input.
|
|
1009
|
+
const schema = { required: ["count", "enabled", "filter"] };
|
|
1010
|
+
assert.deepEqual(
|
|
1011
|
+
missingRequiredArgs(schema, { count: 0, enabled: false, filter: { state: "ACTIVE" } }),
|
|
1012
|
+
[],
|
|
1013
|
+
"present non-string required args (including falsy 0/false) must be accepted",
|
|
1014
|
+
);
|
|
1015
|
+
|
|
1016
|
+
// Absent (undefined/null) and blank/whitespace-only string values remain rejected.
|
|
1017
|
+
assert.deepEqual(
|
|
1018
|
+
missingRequiredArgs(schema, { count: undefined, enabled: null, filter: " " }),
|
|
1019
|
+
["count", "enabled", "filter"],
|
|
1020
|
+
"undefined, null, and whitespace-only string required args must be reported missing",
|
|
1021
|
+
);
|
|
1022
|
+
|
|
1023
|
+
// A required key entirely omitted from args is missing; a non-empty string is provided.
|
|
1024
|
+
assert.deepEqual(missingRequiredArgs({ required: ["id"] }, {}), ["id"]);
|
|
1025
|
+
assert.deepEqual(missingRequiredArgs({ required: ["id"] }, { id: "x" }), []);
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
test("the system brief is served as an MCP resource", async () => {
|
|
1029
|
+
const { router } = buildHarness();
|
|
1030
|
+
const session = await connect(router);
|
|
1031
|
+
const list = await rpc(router, session, "resources/list", {});
|
|
1032
|
+
const resources = list.result?.resources;
|
|
1033
|
+
assert.ok(Array.isArray(resources) && resources.length >= 1);
|
|
1034
|
+
const uri = Reflect.get(resources[0], "uri");
|
|
1035
|
+
const read = await rpc(router, session, "resources/read", { uri });
|
|
1036
|
+
const contents = read.result?.contents;
|
|
1037
|
+
assert.ok(Array.isArray(contents) && contents.length === 1);
|
|
1038
|
+
assert.equal(Reflect.get(contents[0], "text"), SYSTEM_BRIEF);
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
test("the orientation prompt is registered and served", async () => {
|
|
1042
|
+
const { router } = buildHarness();
|
|
1043
|
+
const session = await connect(router);
|
|
1044
|
+
const list = await rpc(router, session, "prompts/list", {});
|
|
1045
|
+
const prompts = list.result?.prompts;
|
|
1046
|
+
assert.ok(Array.isArray(prompts) && prompts.length >= 1);
|
|
1047
|
+
const name = Reflect.get(prompts[0], "name");
|
|
1048
|
+
assert.equal(name, "urban-orientation");
|
|
1049
|
+
const get = await rpc(router, session, "prompts/get", { name });
|
|
1050
|
+
const messages = get.result?.messages;
|
|
1051
|
+
assert.ok(Array.isArray(messages) && messages.length >= 1);
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
// ---- access gating ----------------------------------------------------------------------------
|
|
1055
|
+
|
|
1056
|
+
test("readMcpConfig defaults ON for loopback and honours env/manifest flags", () => {
|
|
1057
|
+
const on = readMcpConfig({}, () => undefined);
|
|
1058
|
+
assert.equal(on.enabled, true);
|
|
1059
|
+
assert.equal(on.allowRemote, false);
|
|
1060
|
+
|
|
1061
|
+
const offByEnv = readMcpConfig({}, (n) => (n === "URBAN_MCP_ENABLED" ? "false" : undefined));
|
|
1062
|
+
assert.equal(offByEnv.enabled, false);
|
|
1063
|
+
|
|
1064
|
+
const remoteByEnv = readMcpConfig({}, (n) => (n === "URBAN_MCP_ALLOW_REMOTE" ? "true" : undefined));
|
|
1065
|
+
assert.equal(remoteByEnv.allowRemote, true);
|
|
1066
|
+
|
|
1067
|
+
const manifest: Record<string, unknown> = { mcp: { enabled: false } };
|
|
1068
|
+
assert.equal(readMcpConfig(manifest, () => undefined).enabled, false);
|
|
1069
|
+
|
|
1070
|
+
// Non-boolean manifest values are ignored (strict type check, like `readApiBinding`): they fall
|
|
1071
|
+
// back to the defaults rather than coercing — e.g. the string "false" is NOT a boolean `false`.
|
|
1072
|
+
const stringy: Record<string, unknown> = { mcp: { enabled: "false", allowRemote: "true" } };
|
|
1073
|
+
const resolved = readMcpConfig(stringy, () => undefined);
|
|
1074
|
+
assert.equal(resolved.enabled, true, "a non-boolean `enabled` must fall back to the default (ON)");
|
|
1075
|
+
assert.equal(resolved.allowRemote, false, "a non-boolean `allowRemote` must fall back to the default (OFF)");
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
test("isLoopbackRequest recognises loopback hosts and rejects remote ones", () => {
|
|
1079
|
+
const mk = (host?: string): HttpRequest => ({
|
|
1080
|
+
method: "POST",
|
|
1081
|
+
path: "/app/mcp",
|
|
1082
|
+
query: new URLSearchParams(),
|
|
1083
|
+
headers: new Headers(host ? { host } : {}),
|
|
1084
|
+
text: () => Promise.resolve(""),
|
|
1085
|
+
});
|
|
1086
|
+
assert.equal(isLoopbackRequest(mk("localhost:3000")), true);
|
|
1087
|
+
assert.equal(isLoopbackRequest(mk("127.0.0.1:3000")), true);
|
|
1088
|
+
assert.equal(isLoopbackRequest(mk("[::1]:3000")), true);
|
|
1089
|
+
assert.equal(isLoopbackRequest(mk()), true);
|
|
1090
|
+
assert.equal(isLoopbackRequest(mk("example.com")), false);
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
test("a disabled surface answers 404 and a remote caller is refused 403", async () => {
|
|
1094
|
+
const disabled = buildHarness({ env: (v) => (v === "URBAN_MCP_ENABLED" ? "false" : undefined) });
|
|
1095
|
+
const res404 = await disabled.router(mcpPost({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }));
|
|
1096
|
+
assert.equal(res404.status, 404);
|
|
1097
|
+
|
|
1098
|
+
const loopbackOnly = buildHarness();
|
|
1099
|
+
const res403 = await loopbackOnly.router(
|
|
1100
|
+
mcpPost(
|
|
1101
|
+
{ jsonrpc: "2.0", id: 1, method: "initialize", params: {} },
|
|
1102
|
+
{ host: "example.com" },
|
|
1103
|
+
),
|
|
1104
|
+
);
|
|
1105
|
+
assert.equal(res403.status, 403);
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
test("a loopback-only surface bound to all interfaces refuses even a loopback Host", async () => {
|
|
1109
|
+
// With bind=all the client-controlled `Host` header can't prove loopback, so every caller is
|
|
1110
|
+
// refused unless remote access is explicitly opted in.
|
|
1111
|
+
const boundAll = buildHarness({ env: (v) => (v === "URBAN_BIND" ? "all" : undefined) });
|
|
1112
|
+
const refused = await boundAll.router(
|
|
1113
|
+
mcpPost({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }, { host: "localhost" }),
|
|
1114
|
+
);
|
|
1115
|
+
assert.equal(refused.status, 403, "a loopback Host must not bypass the gate when bound to all interfaces");
|
|
1116
|
+
|
|
1117
|
+
// Manifest `network.bind: "all"` is honoured the same way.
|
|
1118
|
+
const boundAllManifest = buildHarness({ manifestExtra: { network: { bind: "all" } } });
|
|
1119
|
+
const refused2 = await boundAllManifest.router(
|
|
1120
|
+
mcpPost({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }, { host: "127.0.0.1" }),
|
|
1121
|
+
);
|
|
1122
|
+
assert.equal(refused2.status, 403);
|
|
1123
|
+
|
|
1124
|
+
// Opting into remote access lets the all-interfaces bind serve again.
|
|
1125
|
+
const remote = buildHarness({
|
|
1126
|
+
env: (v) => (v === "URBAN_BIND" ? "all" : v === "URBAN_MCP_ALLOW_REMOTE" ? "true" : undefined),
|
|
1127
|
+
});
|
|
1128
|
+
const session = await connect(remote.router);
|
|
1129
|
+
assert.ok(session.sessionId.length > 0, "allowRemote must let an all-interfaces bind serve");
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
test("newSessionId mints a fresh id and never throws when Web Crypto is absent", () => {
|
|
1133
|
+
// With Web Crypto present it delegates to randomUUID (unique across calls).
|
|
1134
|
+
const a = newSessionId();
|
|
1135
|
+
const b = newSessionId();
|
|
1136
|
+
assert.ok(a.length > 0 && b.length > 0, "a session id must be non-empty");
|
|
1137
|
+
assert.notEqual(a, b, "successive session ids must differ");
|
|
1138
|
+
|
|
1139
|
+
// Core treats Web Crypto as optional: in a host without `crypto` the generator must fall back
|
|
1140
|
+
// rather than throw (a bare `crypto.randomUUID()` would throw a ReferenceError here). Prove the
|
|
1141
|
+
// fallback branch actually ran by removing `crypto` and asserting the `sess-` prefix it mints.
|
|
1142
|
+
const savedCrypto = Reflect.getOwnPropertyDescriptor(globalThis, "crypto");
|
|
1143
|
+
assert.notEqual(
|
|
1144
|
+
savedCrypto?.configurable,
|
|
1145
|
+
false,
|
|
1146
|
+
"test requires globalThis.crypto to be configurable to simulate a host without Web Crypto",
|
|
1147
|
+
);
|
|
1148
|
+
try {
|
|
1149
|
+
Reflect.defineProperty(globalThis, "crypto", { value: undefined, configurable: true, writable: true });
|
|
1150
|
+
assert.equal(globalThis.crypto, undefined, "crypto must actually be absent to exercise the fallback branch");
|
|
1151
|
+
const fallback = newSessionId();
|
|
1152
|
+
assert.ok(fallback.startsWith("sess-"), "the id must come from the time+random fallback when crypto is unavailable");
|
|
1153
|
+
assert.notEqual(fallback, newSessionId(), "fallback ids must still differ");
|
|
1154
|
+
} finally {
|
|
1155
|
+
if (savedCrypto) Reflect.defineProperty(globalThis, "crypto", savedCrypto);
|
|
1156
|
+
else Reflect.deleteProperty(globalThis, "crypto");
|
|
1157
|
+
}
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
test("evictExcessSessions caps the session map by evicting least-recently-used pairs, closing each", async () => {
|
|
1161
|
+
// Guards the defect class the reviewer flagged: an unbounded `sessions` map is a memory/handle
|
|
1162
|
+
// exhaustion vector. A hard LRU cap must bound the map no matter the client's `initialize` cadence.
|
|
1163
|
+
const closed: string[] = [];
|
|
1164
|
+
const sessions = new Map<string, { id: string }>();
|
|
1165
|
+
for (const id of ["a", "b", "c", "d"]) sessions.set(id, { id });
|
|
1166
|
+
|
|
1167
|
+
await evictExcessSessions(sessions, 2, async (s) => {
|
|
1168
|
+
closed.push(s.id);
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
assert.equal(sessions.size, 2, "the map must be trimmed down to the cap");
|
|
1172
|
+
assert.deepEqual([...sessions.keys()], ["c", "d"], "the two most-recently-inserted survive");
|
|
1173
|
+
assert.deepEqual(closed, ["a", "b"], "evicted (LRU) sessions must be closed, in eviction order");
|
|
1174
|
+
|
|
1175
|
+
// Under the cap it is a no-op and closes nothing.
|
|
1176
|
+
const noop: string[] = [];
|
|
1177
|
+
await evictExcessSessions(sessions, 5, async (s) => {
|
|
1178
|
+
noop.push(s.id);
|
|
1179
|
+
});
|
|
1180
|
+
assert.equal(sessions.size, 2);
|
|
1181
|
+
assert.deepEqual(noop, [], "nothing is evicted while under the cap");
|
|
1182
|
+
});
|
|
1183
|
+
|
|
1184
|
+
test("evictExcessSessions keeps closing when a victim's close() rejects (best-effort)", async () => {
|
|
1185
|
+
// A victim whose transport is already torn down must not abort eviction of the rest, or a single
|
|
1186
|
+
// dead session could wedge the cap and re-open the unbounded-growth hole.
|
|
1187
|
+
const closed: string[] = [];
|
|
1188
|
+
const sessions = new Map<string, { id: string }>();
|
|
1189
|
+
for (const id of ["x", "y", "z"]) sessions.set(id, { id });
|
|
1190
|
+
|
|
1191
|
+
await evictExcessSessions(sessions, 1, async (s) => {
|
|
1192
|
+
closed.push(s.id);
|
|
1193
|
+
if (s.id === "x") throw new Error("transport already closed");
|
|
1194
|
+
});
|
|
1195
|
+
|
|
1196
|
+
assert.equal(sessions.size, 1);
|
|
1197
|
+
assert.deepEqual([...sessions.keys()], ["z"], "the newest entry survives despite a failing close");
|
|
1198
|
+
assert.deepEqual(closed, ["x", "y"], "both LRU victims are attempted even though the first rejected");
|
|
1199
|
+
});
|