@nanobpm/urban 0.84.0 → 0.85.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.
@@ -0,0 +1,814 @@
1
+ // mcp — the runtime-served Model Context Protocol surface (ADR 0067). A Streamable-HTTP MCP
2
+ // endpoint the Urban runtime mounts UNCONDITIONALLY at `/app/mcp` (it answers `POST`, plus `DELETE`
3
+ // to end a session; the optional `GET` server→client stream is declined with 405 at the string-body
4
+ // host seam), exactly like the `/app/agent` brief and `/app/api-docs` docs surfaces — so an MCP
5
+ // client (`copilot mcp add
6
+ // --transport http <name> http://localhost:<port>/app/mcp`) discovers an app's operations as
7
+ // tools with ZERO app-side MCP code.
8
+ //
9
+ // Derive-don't-declare (ADR 0053): the app tool list is PROJECTED from the SAME OpenAPI
10
+ // enumeration `mountApi` routes from — `parseSpec` + `collectOperations` (openapi/spec.ts), read
11
+ // through `readApiBinding` — never a second spec walker. Each read-only operation becomes one tool
12
+ // whose `operationId` is the tool name and whose request-body/params schema is the tool input
13
+ // schema; a tool call is reconstructed into the operation's HTTP request and dispatched through the
14
+ // SAME `mountApi` router, so it flows through the identical delegate registry + validation the HTTP
15
+ // route uses (a tool call is equivalent to the corresponding HTTP call).
16
+ //
17
+ // This slice (ADR 0067 Slice 1) exposes READ-ONLY app operations only (GET/HEAD) — mutating
18
+ // operations and their guards are Slice 3. It also exposes framework-owned read-only
19
+ // process-debugging tools over BOTH truth planes: engine truth via existing `EngineClient` methods,
20
+ // and the ADR 0065 canonical projection stores (`urban_instance_state` / `urban_open_user_tasks`).
21
+ // The app's system brief (`/app/agent.json`) is served as an MCP resource, plus one orientation
22
+ // entry under MCP prompts.
23
+ //
24
+ // Transport: the SDK's Web-Standard Streamable-HTTP transport (`Request`→`Response`), bridged to
25
+ // the runtime's host `HttpRequest`/`HttpResponse` seam — core/ imports no `node:*`; the MCP SDK is
26
+ // an ordinary dependency. Access is loopback-only behind a manifest/env flag that defaults ON for
27
+ // loopback.
28
+
29
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
30
+ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
31
+ import {
32
+ CallToolRequestSchema,
33
+ ErrorCode,
34
+ GetPromptRequestSchema,
35
+ isInitializeRequest,
36
+ ListPromptsRequestSchema,
37
+ ListResourcesRequestSchema,
38
+ ListToolsRequestSchema,
39
+ McpError,
40
+ ReadResourceRequestSchema,
41
+ } from "@modelcontextprotocol/sdk/types.js";
42
+
43
+ import { GENERATED_DIR } from "../../../toolkit/artifact.ts";
44
+ import { SYSTEM_BRIEF_JSON } from "../../../toolkit/derivers/system-brief.ts";
45
+ import {
46
+ collectOperations,
47
+ type OperationInfo,
48
+ parseSpec,
49
+ } from "../../../openapi/spec.ts";
50
+ import type { AppApi, RuntimeContext } from "../context.ts";
51
+ import { errorMessage } from "../guards.ts";
52
+ import type {
53
+ ElementInstanceWaitStateFilter,
54
+ HttpRequest,
55
+ HttpResponse,
56
+ ProcessInstanceState,
57
+ SqliteDb,
58
+ UserTaskFilter,
59
+ UserTaskState,
60
+ WaitStateType,
61
+ } from "../host.ts";
62
+ import { presentFormIdentifier } from "../host.ts";
63
+ import { resolveBindMode } from "../manifest.ts";
64
+ import { json, makeRouter, type Route } from "../router.ts";
65
+ import { API_BASE, readApiBinding } from "./api.ts";
66
+ import { resolveAppPath } from "./datasource.ts";
67
+ import { InstanceStateStore } from "./instance-state-store.ts";
68
+ import { OpenUserTasksStore } from "./open-user-tasks-store.ts";
69
+
70
+ /** The mount path of the MCP endpoint — a sibling of `/app/api` / `/app/agent`, mounted
71
+ * unconditionally so an MCP client always has a stable address to connect to. */
72
+ const MCP_PATH = "/app/mcp";
73
+
74
+ /** The header carrying the Streamable-HTTP session id (MCP spec). */
75
+ const SESSION_HEADER = "mcp-session-id";
76
+
77
+ /** Hard upper bound on concurrently-resident MCP sessions. Each session is a live `Server` +
78
+ * transport pair, so an unbounded session map is a memory/handle-exhaustion vector: any local
79
+ * process can repeatedly `initialize` without reusing its `mcp-session-id` and pin a fresh pair
80
+ * each time. Capping the map (LRU-evicted, refreshed on use — see `evictExcessSessions` /
81
+ * `touchSession`) categorically bounds growth regardless of client behaviour or timing. */
82
+ const MAX_SESSIONS = 256;
83
+
84
+ /** The MCP resource URI for the app's system brief (the `/app/agent.json` content). */
85
+ const SYSTEM_BRIEF_URI = "urban://system-brief.json";
86
+
87
+ /** The single orientation prompt registered under MCP `prompts`. */
88
+ const ORIENTATION_PROMPT = "urban-orientation";
89
+
90
+ /** The framework debug-tool name prefix — a reserved namespace. `loadReadOnlyOps` drops any app
91
+ * `operationId` in this namespace before it is projected as a tool, so a framework tool can never
92
+ * collide with (be shadowed by, or duplicate the name of) an app-projected tool. */
93
+ const DEBUG_PREFIX = "urban_debug_";
94
+
95
+ export interface McpHandle {
96
+ readonly name: string;
97
+ routes: Route[];
98
+ /** Whether the surface actually answers vs. 404s (config-enabled). The route is always mounted
99
+ * for a stable address, so callers deriving an "active surfaces" list must gate on this. */
100
+ readonly enabled: boolean;
101
+ describe(): Record<string, unknown>;
102
+ }
103
+
104
+ /** Mint a fresh MCP session id, host-agnostically. Prefers the Web Crypto `randomUUID` (a global,
105
+ * not `node:crypto`) where present, with a time+random fallback so a host without `crypto` never
106
+ * throws — mirroring how core treats Web Crypto as optional (see `mintRootRequestKey`). */
107
+ export function newSessionId(): string {
108
+ const uuid = globalThis.crypto?.randomUUID?.();
109
+ if (uuid) return uuid;
110
+ return `sess-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
111
+ }
112
+
113
+ /** Mark a session as most-recently-used. A `Map` preserves insertion order, so deleting and
114
+ * re-inserting a live entry moves it to the tail — which makes the first key the least-recently-used
115
+ * entry that `evictExcessSessions` reaps first. Refreshing on every use is what keeps an active
116
+ * session from being evicted out from under a client that is still talking to it. */
117
+ function touchSession<S>(sessions: Map<string, S>, id: string, session: S): void {
118
+ sessions.delete(id);
119
+ sessions.set(id, session);
120
+ }
121
+
122
+ /** Bound the live-session map to `max` by evicting least-recently-used entries (the `Map`'s leading
123
+ * keys, given `touchSession` re-inserts on use), closing each victim best-effort so its transport /
124
+ * server releases. This is the categorical fix for the unbounded-growth vector: no client can pin
125
+ * more than `max` server/transport pairs, whatever its `initialize` cadence. Exported for a direct
126
+ * unit test of the LRU eviction contract. */
127
+ export async function evictExcessSessions<S>(
128
+ sessions: Map<string, S>,
129
+ max: number,
130
+ close: (session: S) => Promise<void>,
131
+ ): Promise<void> {
132
+ while (sessions.size > max) {
133
+ const oldest = sessions.keys().next().value;
134
+ if (oldest === undefined) break;
135
+ const victim = sessions.get(oldest);
136
+ sessions.delete(oldest);
137
+ if (victim !== undefined) {
138
+ try {
139
+ await close(victim);
140
+ } catch {
141
+ // Best-effort: a victim whose transport is already torn down must not abort eviction.
142
+ }
143
+ }
144
+ }
145
+ }
146
+
147
+
148
+ /** The resolved MCP access policy for this app. */
149
+ interface McpConfig {
150
+ /** Whether the MCP surface answers at all. Defaults ON. */
151
+ readonly enabled: boolean;
152
+ /** Whether non-loopback callers are allowed. Defaults OFF (loopback-only). */
153
+ readonly allowRemote: boolean;
154
+ }
155
+
156
+ function isRecord(v: unknown): v is Record<string, unknown> {
157
+ return typeof v === "object" && v !== null && !Array.isArray(v);
158
+ }
159
+
160
+ /**
161
+ * Resolve the MCP access policy from the manifest (`mcp: { enabled?, allowRemote? }`, read
162
+ * reflectively until the app schema folds the field in — mirroring how `readApiBinding` reads
163
+ * `api`) and the environment (`URBAN_MCP_ENABLED`, `URBAN_MCP_ALLOW_REMOTE`). The env flags win so
164
+ * an operator can force the surface off (or open it to a non-loopback bind) without editing the
165
+ * manifest. Enabled defaults ON and access defaults to loopback-only ("defaults ON for loopback").
166
+ */
167
+ export function readMcpConfig(manifest: unknown, env: (name: string) => string | undefined): McpConfig {
168
+ const raw = manifest && typeof manifest === "object" ? Reflect.get(manifest, "mcp") : undefined;
169
+ // Honour manifest flags only when they are actual booleans (mirroring `readApiBinding`'s strict
170
+ // type checks), so a stray non-boolean value falls back to the default instead of coercing
171
+ // surprisingly (e.g. the string `"false"` is not a boolean `false`).
172
+ const rawEnabled = isRecord(raw) ? Reflect.get(raw, "enabled") : undefined;
173
+ const rawAllowRemote = isRecord(raw) ? Reflect.get(raw, "allowRemote") : undefined;
174
+ const manifestEnabled = typeof rawEnabled === "boolean" ? rawEnabled : undefined;
175
+ const manifestAllowRemote = typeof rawAllowRemote === "boolean" ? rawAllowRemote : undefined;
176
+ const envEnabled = env("URBAN_MCP_ENABLED");
177
+ const envAllowRemote = env("URBAN_MCP_ALLOW_REMOTE");
178
+ const enabled = envEnabled === "false" ? false : envEnabled === "true" ? true : manifestEnabled !== false;
179
+ const allowRemote =
180
+ envAllowRemote === "true" ? true : envAllowRemote === "false" ? false : manifestAllowRemote === true;
181
+ return { enabled, allowRemote };
182
+ }
183
+
184
+ /** The host portion of an HTTP `Host` header, lower-cased and stripped of any `:port` (and of the
185
+ * `[...]` brackets around an IPv6 literal), or `undefined` when there is no Host header. */
186
+ function hostName(req: HttpRequest): string | undefined {
187
+ const host = req.headers.get("host");
188
+ if (!host) return undefined;
189
+ const trimmed = host.trim().toLowerCase();
190
+ if (trimmed.startsWith("[")) {
191
+ const end = trimmed.indexOf("]");
192
+ return end > 0 ? trimmed.slice(1, end) : trimmed.slice(1);
193
+ }
194
+ const colon = trimmed.indexOf(":");
195
+ return colon >= 0 ? trimmed.slice(0, colon) : trimmed;
196
+ }
197
+
198
+ /** Whether the request targets a loopback host — the best-effort loopback signal available at the
199
+ * host seam (which carries no peer address). A missing Host header is treated as loopback (a direct
200
+ * local client that sent none). */
201
+ export function isLoopbackRequest(req: HttpRequest): boolean {
202
+ const name = hostName(req);
203
+ return name === undefined || name === "localhost" || name === "127.0.0.1" || name === "::1";
204
+ }
205
+
206
+ /** True for a read-only operation — the only operations this slice projects as tools. Mutating
207
+ * operations (POST/PUT/PATCH/DELETE) are withheld until Slice 3 adds them behind a guard. */
208
+ function isReadOnlyOperation(op: OperationInfo): boolean {
209
+ return op.method === "get" || op.method === "head";
210
+ }
211
+
212
+ /** A JSON Schema (OpenAPI object subset) describing a tool's input, derived from an operation's
213
+ * path/query parameters and its request-body schema. Keeping the property schemas verbatim from the
214
+ * spec means the tool input schema and the HTTP validation share one source of truth. */
215
+ function toolInputSchema(op: OperationInfo): Record<string, unknown> {
216
+ const properties: Record<string, unknown> = {};
217
+ const required: string[] = [];
218
+ for (const p of op.parameters) {
219
+ if (p.in === "path" || p.in === "query") {
220
+ properties[p.name] = p.schema ?? {};
221
+ if (p.required) required.push(p.name);
222
+ }
223
+ }
224
+ if (op.requestBodySchema) {
225
+ properties.body = op.requestBodySchema;
226
+ if (op.requestBodyRequired) required.push("body");
227
+ }
228
+ const schema: Record<string, unknown> = { type: "object", properties };
229
+ if (required.length > 0) schema.required = required;
230
+ return schema;
231
+ }
232
+
233
+ /** Substitute an operation's `{param}` path-template segments with the supplied arguments, building
234
+ * the request path under the shared `/app/api` base so dispatch hits the exact `mountApi` route. */
235
+ function fillPath(op: OperationInfo, args: Record<string, unknown>): string {
236
+ const path = op.path.replace(/\{([^}]+)\}/g, (_m, name: string) => {
237
+ const value = args[name];
238
+ return encodeURIComponent(value === undefined || value === null ? "" : String(value));
239
+ });
240
+ return `${API_BASE}${path}`;
241
+ }
242
+
243
+ /** Build the query string for a tool call from the operation's declared query parameters and the
244
+ * supplied arguments — repeated (array) values become repeated keys, exactly as an HTTP client
245
+ * would send them, so the reused dispatcher validates identical input. */
246
+ function toolQuery(op: OperationInfo, args: Record<string, unknown>): URLSearchParams {
247
+ const query = new URLSearchParams();
248
+ for (const p of op.parameters) {
249
+ if (p.in !== "query") continue;
250
+ const value = args[p.name];
251
+ if (value === undefined || value === null) continue;
252
+ if (Array.isArray(value)) {
253
+ for (const item of value) query.append(p.name, String(item));
254
+ } else {
255
+ query.append(p.name, String(value));
256
+ }
257
+ }
258
+ return query;
259
+ }
260
+
261
+ /** Whether an argument is absent for presence purposes: unset (`undefined`/`null`), or a string that
262
+ * is empty or whitespace-only after trimming. This is the trimmed-presence rule
263
+ * {@link presentFormIdentifier} enforces for form identifiers, reused here so MCP required/path args
264
+ * and form resolution cannot drift on what "provided" means (a whitespace-only `" "` is absent in
265
+ * both). A non-string value (e.g. a numeric path id) is considered present. */
266
+ function isBlankArg(value: unknown): boolean {
267
+ if (value === undefined || value === null) return true;
268
+ if (typeof value === "string") return presentFormIdentifier(value) === undefined;
269
+ return false;
270
+ }
271
+
272
+ /** The required path parameters an operation declares that the tool `args` did not supply. Unlike
273
+ * an HTTP request (whose path can't structurally omit a segment), a tool call can leave a
274
+ * `{param}` unset or blank; `fillPath` would then substitute an empty string and silently mis-route
275
+ * (a 404 route mismatch, or worse a different route shape), so a missing/blank path arg is rejected
276
+ * up front. Blankness uses the shared trimmed-presence rule so a whitespace-only value is rejected
277
+ * too. */
278
+ function missingPathParams(op: OperationInfo, args: Record<string, unknown>): string[] {
279
+ return op.parameters
280
+ .filter((p) => p.in === "path" && isBlankArg(args[p.name]))
281
+ .map((p) => p.name);
282
+ }
283
+
284
+ /** The `required` keys a tool's `inputSchema` declares but the call omitted. Presence is decided
285
+ * solely by {@link isBlankArg} — the single source of truth also used for path args — so a value is
286
+ * "provided" unless it is absent (`undefined`/`null`) or a blank/whitespace-only string. A required
287
+ * non-string input (e.g. a numeric/boolean/object arg) that is present is therefore accepted, not
288
+ * wrongly reported missing. Derived from the schema's own `required` array so declaring a field
289
+ * required is enough to have missing (or whitespace-only) calls rejected rather than silently
290
+ * substituted with an empty string. */
291
+ export function missingRequiredArgs(inputSchema: Record<string, unknown>, args: Record<string, unknown>): string[] {
292
+ const required = inputSchema.required;
293
+ if (!Array.isArray(required)) return [];
294
+ const missing: string[] = [];
295
+ for (const key of required) {
296
+ if (typeof key !== "string") continue;
297
+ if (isBlankArg(args[key])) missing.push(key);
298
+ }
299
+ return missing;
300
+ }
301
+
302
+ /** Reconstruct the HTTP request an operation tool call is equivalent to, ready to hand to the
303
+ * reused `mountApi` router. The JSON body is the tool's `body` argument (when the op declares one).
304
+ */
305
+ function toHttpRequest(op: OperationInfo, args: Record<string, unknown>): HttpRequest {
306
+ const bodyText = op.requestBodySchema !== undefined && args.body !== undefined
307
+ ? JSON.stringify(args.body)
308
+ : "";
309
+ return {
310
+ method: op.method.toUpperCase(),
311
+ path: fillPath(op, args),
312
+ query: toolQuery(op, args),
313
+ headers: new Headers({ "content-type": "application/json" }),
314
+ text: () => Promise.resolve(bodyText),
315
+ };
316
+ }
317
+
318
+ /** Wrap a value as an MCP tool text result. A JSON payload is stringified; `isError` flags a
319
+ * non-2xx dispatch so the client sees the failure (carrying the delegate's own error body). */
320
+ function textResult(value: unknown, isError = false): {
321
+ content: { type: "text"; text: string }[];
322
+ isError?: boolean;
323
+ } {
324
+ const text = typeof value === "string" ? value : JSON.stringify(value ?? null);
325
+ return isError ? { content: [{ type: "text", text }], isError: true } : { content: [{ type: "text", text }] };
326
+ }
327
+
328
+ function readString(args: Record<string, unknown>, key: string): string | undefined {
329
+ const v = args[key];
330
+ return typeof v === "string" ? v : undefined;
331
+ }
332
+
333
+ function readStringArray(args: Record<string, unknown>, key: string): string[] | undefined {
334
+ const v = args[key];
335
+ if (!Array.isArray(v)) return undefined;
336
+ const out: string[] = [];
337
+ for (const item of v) if (typeof item === "string") out.push(item);
338
+ return out;
339
+ }
340
+
341
+ function readProcessInstanceState(args: Record<string, unknown>, key: string): ProcessInstanceState | undefined {
342
+ const v = args[key];
343
+ return v === "ACTIVE" || v === "COMPLETED" || v === "TERMINATED" ? v : undefined;
344
+ }
345
+
346
+ function readUserTaskState(args: Record<string, unknown>, key: string): UserTaskState | undefined {
347
+ const v = args[key];
348
+ return v === "CREATED" || v === "COMPLETED" || v === "CANCELED" || v === "FAILED" ? v : undefined;
349
+ }
350
+
351
+ function readWaitStateType(args: Record<string, unknown>, key: string): WaitStateType | undefined {
352
+ const v = args[key];
353
+ return v === "JOB" || v === "MESSAGE" || v === "USER_TASK" || v === "TIMER" || v === "SIGNAL" || v === "CONDITION"
354
+ ? v
355
+ : undefined;
356
+ }
357
+
358
+ /** One framework-owned read-only debug tool: its MCP descriptor plus a handler that reads engine
359
+ * truth or a projection store and returns JSON. */
360
+ interface DebugTool {
361
+ readonly name: string;
362
+ readonly description: string;
363
+ readonly inputSchema: Record<string, unknown>;
364
+ run(args: Record<string, unknown>): Promise<unknown>;
365
+ }
366
+
367
+ const OPTIONAL_STRING = { type: "string" } as const;
368
+
369
+ /** Build the framework debug tools, closing over the app's engine (truth plane) and default data
370
+ * source (the ADR 0065 projection stores). The engine tools consume only EXISTING `EngineClient`
371
+ * methods — no seam extension in this slice. */
372
+ function buildDebugTools(app: AppApi): DebugTool[] {
373
+ const projectionDb = (): SqliteDb | undefined =>
374
+ app.data.hasDefaultSource() ? app.data.source().db : undefined;
375
+
376
+ return [
377
+ {
378
+ name: `${DEBUG_PREFIX}search_process_instances`,
379
+ description: "Engine truth: search process instances by key and/or lifecycle state.",
380
+ inputSchema: {
381
+ type: "object",
382
+ properties: {
383
+ processInstanceKeys: { type: "array", items: OPTIONAL_STRING },
384
+ state: { type: "string", enum: ["ACTIVE", "COMPLETED", "TERMINATED"] },
385
+ },
386
+ },
387
+ run: (args) => {
388
+ const filter: { processInstanceKeys?: string[]; state?: ProcessInstanceState } = {};
389
+ const keys = readStringArray(args, "processInstanceKeys");
390
+ if (keys) filter.processInstanceKeys = keys;
391
+ const state = readProcessInstanceState(args, "state");
392
+ if (state) filter.state = state;
393
+ return app.engine.searchProcessInstances(filter);
394
+ },
395
+ },
396
+ {
397
+ name: `${DEBUG_PREFIX}search_element_instance_wait_states`,
398
+ description: "Engine truth: search element-instance wait states (every park, not only user tasks).",
399
+ inputSchema: {
400
+ type: "object",
401
+ properties: {
402
+ processInstanceKey: OPTIONAL_STRING,
403
+ elementId: OPTIONAL_STRING,
404
+ waitStateType: {
405
+ type: "string",
406
+ enum: ["JOB", "MESSAGE", "USER_TASK", "TIMER", "SIGNAL", "CONDITION"],
407
+ },
408
+ },
409
+ },
410
+ run: (args) => {
411
+ const filter: ElementInstanceWaitStateFilter = {};
412
+ const pik = readString(args, "processInstanceKey");
413
+ if (pik) filter.processInstanceKey = pik;
414
+ const elementId = readString(args, "elementId");
415
+ if (elementId) filter.elementId = elementId;
416
+ const waitStateType = readWaitStateType(args, "waitStateType");
417
+ if (waitStateType) filter.waitStateType = waitStateType;
418
+ return app.engine.searchElementInstanceWaitStates(filter);
419
+ },
420
+ },
421
+ {
422
+ name: `${DEBUG_PREFIX}get_element_instance`,
423
+ description: "Engine truth: fetch a single element instance by key (null when absent).",
424
+ inputSchema: {
425
+ type: "object",
426
+ properties: { elementInstanceKey: OPTIONAL_STRING },
427
+ required: ["elementInstanceKey"],
428
+ },
429
+ run: (args) => app.engine.getElementInstance(readString(args, "elementInstanceKey") ?? ""),
430
+ },
431
+ {
432
+ name: `${DEBUG_PREFIX}search_user_tasks`,
433
+ description: "Engine truth: search user tasks by process instance, assignee, candidate group, and/or state.",
434
+ inputSchema: {
435
+ type: "object",
436
+ properties: {
437
+ processInstanceKey: OPTIONAL_STRING,
438
+ assignee: OPTIONAL_STRING,
439
+ candidateGroup: OPTIONAL_STRING,
440
+ state: { type: "string", enum: ["CREATED", "COMPLETED", "CANCELED", "FAILED"] },
441
+ },
442
+ },
443
+ run: (args) => {
444
+ const filter: UserTaskFilter & { state?: UserTaskState } = {};
445
+ const pik = readString(args, "processInstanceKey");
446
+ if (pik) filter.processInstanceKey = pik;
447
+ const assignee = readString(args, "assignee");
448
+ if (assignee) filter.assignee = assignee;
449
+ const candidateGroup = readString(args, "candidateGroup");
450
+ if (candidateGroup) filter.candidateGroup = candidateGroup;
451
+ const state = readUserTaskState(args, "state");
452
+ if (state) filter.state = state;
453
+ return app.engine.searchUserTasks(filter);
454
+ },
455
+ },
456
+ {
457
+ name: `${DEBUG_PREFIX}instance_state`,
458
+ description: "Projection (urban_instance_state, ADR 0065): the canonical engine-lifecycle state for an instance.",
459
+ inputSchema: {
460
+ type: "object",
461
+ properties: { processInstanceKey: OPTIONAL_STRING },
462
+ required: ["processInstanceKey"],
463
+ },
464
+ run: (args) => {
465
+ const db = projectionDb();
466
+ if (!db) return Promise.resolve(null);
467
+ const key = readString(args, "processInstanceKey") ?? "";
468
+ return Promise.resolve(new InstanceStateStore(db).getState(key) ?? null);
469
+ },
470
+ },
471
+ {
472
+ name: `${DEBUG_PREFIX}open_user_tasks`,
473
+ description: "Projection (urban_open_user_tasks, ADR 0065): the currently-open user tasks for an instance.",
474
+ inputSchema: {
475
+ type: "object",
476
+ properties: { processInstanceKey: OPTIONAL_STRING },
477
+ required: ["processInstanceKey"],
478
+ },
479
+ run: (args) => {
480
+ const db = projectionDb();
481
+ if (!db) return Promise.resolve([]);
482
+ const key = readString(args, "processInstanceKey") ?? "";
483
+ return Promise.resolve(new OpenUserTasksStore(db).openTasks(key));
484
+ },
485
+ },
486
+ ];
487
+ }
488
+
489
+ /**
490
+ * Mount the runtime-served MCP surface at `/app/mcp` (ADR 0067). Returns a single `*`-method route
491
+ * (mounted unconditionally, like `/app/agent`); the handler bridges the host request/response seam
492
+ * to the SDK's Web-Standard Streamable-HTTP transport, maintaining one MCP session per
493
+ * `mcp-session-id`. The tool projection reuses the SAME OpenAPI enumeration and dispatch path as
494
+ * `mountApi`, so a tool call is equivalent to the corresponding HTTP call.
495
+ */
496
+ export function mountMcp(ctx: RuntimeContext, app: AppApi, apiRoutes: Route[]): McpHandle {
497
+ const config = readMcpConfig(ctx.manifest, ctx.host.env);
498
+ // The effective HTTP bind interface. When the app is bound to all interfaces, the
499
+ // client-controlled `Host` header is not a trustworthy loopback signal (a remote caller can send
500
+ // `Host: localhost`), so a loopback-only surface must refuse EVERY caller rather than trust it.
501
+ const bindMode = resolveBindMode(ctx.manifest, ctx.host.env);
502
+
503
+ // Reuse the app's OpenAPI dispatch VERBATIM: `mountSurfaces` already built the `/app/api` router
504
+ // (validation + delegate registry) via `mountApi` and hands us its routes here, so tool and HTTP
505
+ // calls share ONE dispatcher instance (no forked dispatcher, no second doc/delegate cache). A tool
506
+ // call is reconstructed into the operation's HTTP request and routed through this same router.
507
+ const apiRouter = makeRouter(apiRoutes);
508
+
509
+ // The read-only operation table, projected lazily from the same spec `mountApi` reads and cached
510
+ // (mirroring `mountApi`'s lazy `loadDoc`). A missing/broken spec degrades to "no app tools"
511
+ // rather than breaking the whole MCP endpoint (the debug tools + resource + prompt still serve).
512
+ let opsPromise: Promise<OperationInfo[]> | undefined;
513
+ const loadReadOnlyOps = (): Promise<OperationInfo[]> => {
514
+ if (!opsPromise) {
515
+ opsPromise = (async () => {
516
+ const binding = readApiBinding(ctx.manifest);
517
+ if (!binding) return [];
518
+ const text = await ctx.host.readTextFile(resolveAppPath(ctx.root, binding.spec));
519
+ const readOnly = collectOperations(parseSpec(text)).filter(isReadOnlyOperation);
520
+ // The `urban_debug_` namespace is framework-reserved (see DEBUG_PREFIX). A `urban_debug_*`
521
+ // operationId is a legal safe path segment, so an app could declare one — but `CallTool`
522
+ // resolves `debugByName` before app ops, so such an op would be shadowed by the framework
523
+ // tool AND surface a duplicate name in `tools/list`. Drop reserved-namespace app ops here
524
+ // (the single projection source both `tools/list` and `CallTool` read), warning per drop,
525
+ // so the collision is impossible by construction rather than order-dependent.
526
+ return readOnly.filter((op) => {
527
+ if (op.operationId.startsWith(DEBUG_PREFIX)) {
528
+ ctx.host.log("warn", "mcp: dropped app operation using reserved framework tool namespace", {
529
+ operationId: op.operationId,
530
+ reservedPrefix: DEBUG_PREFIX,
531
+ });
532
+ return false;
533
+ }
534
+ return true;
535
+ });
536
+ })().catch((e) => {
537
+ opsPromise = undefined; // don't cache the failure — let a later request retry
538
+ ctx.host.log("warn", "mcp: failed to project app operations as tools", { error: errorMessage(e) });
539
+ return [];
540
+ });
541
+ }
542
+ return opsPromise;
543
+ };
544
+
545
+ const debugTools = buildDebugTools(app);
546
+ const debugByName = new Map(debugTools.map((t) => [t.name, t]));
547
+
548
+ const dispatchOperationTool = async (
549
+ op: OperationInfo,
550
+ args: Record<string, unknown>,
551
+ ): Promise<{ content: { type: "text"; text: string }[]; isError?: boolean }> => {
552
+ const missing = missingPathParams(op, args);
553
+ if (missing.length > 0) {
554
+ // Surface as an error tool result (like a reused-validator failure), not a silent 404 from an
555
+ // empty-string substitution — the calling agent sees exactly which required params it omitted.
556
+ return textResult(
557
+ { error: `validation failed: missing required path parameter(s): ${missing.join(", ")}` },
558
+ true,
559
+ );
560
+ }
561
+ const res = await apiRouter(toHttpRequest(op, args));
562
+ const status = res.status ?? 200;
563
+ return textResult(res.body ?? "", status >= 400);
564
+ };
565
+
566
+ const readSystemBrief = async (): Promise<string> => {
567
+ const path = resolveAppPath(ctx.root, `${GENERATED_DIR}/${SYSTEM_BRIEF_JSON}`);
568
+ if (!(await ctx.host.exists(path))) {
569
+ throw new McpError(ErrorCode.InvalidParams, "system brief not generated — run `urban gen`");
570
+ }
571
+ return ctx.host.readTextFile(path);
572
+ };
573
+
574
+ const orientationText = (): string =>
575
+ [
576
+ `# ${ctx.manifest.name ?? "Urban app"} — MCP orientation`,
577
+ "",
578
+ "This endpoint is the app's runtime-served MCP surface (ADR 0067).",
579
+ "",
580
+ `- Read the \`${SYSTEM_BRIEF_URI}\` resource first for the app's system model (processes, service-task call graph, decisions, ownership).`,
581
+ "- App tools mirror the app's read-only HTTP operations one-to-one: a tool call is equivalent to the corresponding `/app/api` HTTP call, validated identically.",
582
+ `- \`${DEBUG_PREFIX}*\` tools are framework-owned read-only process-debugging tools: engine truth (process instances, wait states, element instances, user tasks) and the ADR 0065 projection stores (instance state, open user tasks).`,
583
+ "- This slice is read-only; mutating tools are added in a later slice behind an access guard.",
584
+ ].join("\n");
585
+
586
+ /** Build a fresh low-level MCP `Server` with this app's tools/resources/prompts wired. One is
587
+ * created per session and connected to that session's transport. */
588
+ const buildServer = (): Server => {
589
+ const server = new Server(
590
+ { name: "urban", version: "1.0.0" },
591
+ { capabilities: { tools: {}, resources: {}, prompts: {} } },
592
+ );
593
+
594
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
595
+ const ops = await loadReadOnlyOps();
596
+ const appTools = ops.map((op) => ({
597
+ name: op.operationId,
598
+ description: op.summary ?? `${op.method.toUpperCase()} ${op.path}`,
599
+ inputSchema: toolInputSchema(op),
600
+ annotations: { readOnlyHint: true },
601
+ }));
602
+ const frameworkTools = debugTools.map((t) => ({
603
+ name: t.name,
604
+ description: t.description,
605
+ inputSchema: t.inputSchema,
606
+ annotations: { readOnlyHint: true },
607
+ }));
608
+ return { tools: [...appTools, ...frameworkTools] };
609
+ });
610
+
611
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
612
+ const { name } = request.params;
613
+ const args = isRecord(request.params.arguments) ? request.params.arguments : {};
614
+ const debug = debugByName.get(name);
615
+ if (debug) {
616
+ const missing = missingRequiredArgs(debug.inputSchema, args);
617
+ if (missing.length > 0) {
618
+ // Fail fast like an operation-tool validation error, instead of substituting an empty
619
+ // string that makes an invalid call look like a legitimate "not found" result.
620
+ return textResult(
621
+ { error: `validation failed: missing required parameter(s): ${missing.join(", ")}` },
622
+ true,
623
+ );
624
+ }
625
+ try {
626
+ return textResult(await debug.run(args));
627
+ } catch (e) {
628
+ return textResult({ error: errorMessage(e) }, true);
629
+ }
630
+ }
631
+ const ops = await loadReadOnlyOps();
632
+ const op = ops.find((o) => o.operationId === name);
633
+ if (!op) {
634
+ throw new McpError(ErrorCode.MethodNotFound, `no such tool: ${name}`);
635
+ }
636
+ return dispatchOperationTool(op, args);
637
+ });
638
+
639
+ server.setRequestHandler(ListResourcesRequestSchema, async () => ({
640
+ resources: [
641
+ {
642
+ uri: SYSTEM_BRIEF_URI,
643
+ name: "system-brief",
644
+ title: "App system brief",
645
+ description: "The app's derived institutional-memory brief (the /app/agent.json content).",
646
+ mimeType: "application/json",
647
+ },
648
+ ],
649
+ }));
650
+
651
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
652
+ const { uri } = request.params;
653
+ if (uri !== SYSTEM_BRIEF_URI) {
654
+ throw new McpError(ErrorCode.InvalidParams, `no such resource: ${uri}`);
655
+ }
656
+ const text = await readSystemBrief();
657
+ return { contents: [{ uri, mimeType: "application/json", text }] };
658
+ });
659
+
660
+ server.setRequestHandler(ListPromptsRequestSchema, async () => ({
661
+ prompts: [
662
+ {
663
+ name: ORIENTATION_PROMPT,
664
+ title: "Orient on this app",
665
+ description: "How to use this app's MCP surface: its read tools, debug tools, and system-brief resource.",
666
+ arguments: [],
667
+ },
668
+ ],
669
+ }));
670
+
671
+ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
672
+ if (request.params.name !== ORIENTATION_PROMPT) {
673
+ throw new McpError(ErrorCode.InvalidParams, `no such prompt: ${request.params.name}`);
674
+ }
675
+ return {
676
+ description: "Orientation for working against this app over MCP.",
677
+ messages: [{ role: "user", content: { type: "text", text: orientationText() } }],
678
+ };
679
+ });
680
+
681
+ return server;
682
+ };
683
+
684
+ interface Session {
685
+ readonly server: Server;
686
+ readonly transport: WebStandardStreamableHTTPServerTransport;
687
+ }
688
+ const sessions = new Map<string, Session>();
689
+
690
+ const createSession = async (): Promise<Session> => {
691
+ const transport = new WebStandardStreamableHTTPServerTransport({
692
+ sessionIdGenerator: () => newSessionId(),
693
+ enableJsonResponse: true,
694
+ onsessionclosed: (id) => {
695
+ sessions.delete(id);
696
+ },
697
+ });
698
+ transport.onclose = () => {
699
+ if (transport.sessionId) sessions.delete(transport.sessionId);
700
+ };
701
+ const server = buildServer();
702
+ await server.connect(transport);
703
+ return { server, transport };
704
+ };
705
+
706
+ /** Convert the host request into the Web-Standard `Request` the SDK transport consumes. The body
707
+ * is passed separately (as `parsedBody`) so the transport never re-reads it; the Request carries
708
+ * the method + headers (Accept, content-type, `mcp-session-id`, protocol version) it inspects. */
709
+ const toWebRequest = (req: HttpRequest): Request => {
710
+ const search = req.query.toString();
711
+ const url = `http://localhost${req.path}${search ? `?${search}` : ""}`;
712
+ const headers = new Headers();
713
+ for (const [key, value] of req.headers) headers.set(key, value);
714
+ return new Request(url, { method: req.method, headers });
715
+ };
716
+
717
+ const toHttpResponse = async (res: Response): Promise<HttpResponse> => {
718
+ const headers: Record<string, string> = {};
719
+ for (const [key, value] of res.headers) headers[key] = value;
720
+ return { status: res.status, headers, body: await res.text() };
721
+ };
722
+
723
+ const methodNotAllowed = (): HttpResponse => ({
724
+ status: 405,
725
+ headers: { allow: "POST, DELETE", "content-type": "application/json" },
726
+ body: JSON.stringify({
727
+ jsonrpc: "2.0",
728
+ error: { code: -32000, message: "Method Not Allowed: the MCP endpoint accepts POST (and DELETE to end a session)." },
729
+ id: null,
730
+ }),
731
+ });
732
+
733
+ const handle = async (req: HttpRequest): Promise<HttpResponse> => {
734
+ if (!config.enabled) return json({ error: "MCP surface is disabled" }, 404);
735
+ if (!config.allowRemote) {
736
+ // Bound to all interfaces, `Host` can't be trusted as a loopback proof: refuse everyone
737
+ // unless remote access is explicitly opted in (mcp.allowRemote / URBAN_MCP_ALLOW_REMOTE).
738
+ if (bindMode === "all") {
739
+ return json(
740
+ { error: "MCP surface is loopback-only, but the app is bound to all interfaces; set mcp.allowRemote to expose it" },
741
+ 403,
742
+ );
743
+ }
744
+ if (!isLoopbackRequest(req)) {
745
+ return json({ error: "MCP surface is loopback-only" }, 403);
746
+ }
747
+ }
748
+ const method = req.method.toUpperCase();
749
+ // The string-body host seam cannot hold open an SSE stream, so the optional GET server→client
750
+ // stream is declined with 405 (spec-compliant): clients fall back to POST request/response.
751
+ if (method !== "POST" && method !== "DELETE") return methodNotAllowed();
752
+
753
+ const rawBody = method === "POST" ? await req.text() : "";
754
+ let parsedBody: unknown;
755
+ if (rawBody) {
756
+ try {
757
+ parsedBody = JSON.parse(rawBody);
758
+ } catch {
759
+ return json(
760
+ { jsonrpc: "2.0", error: { code: ErrorCode.ParseError, message: "request body is not valid JSON" }, id: null },
761
+ 400,
762
+ );
763
+ }
764
+ }
765
+
766
+ const sessionId = req.headers.get(SESSION_HEADER) ?? undefined;
767
+ let session = sessionId ? sessions.get(sessionId) : undefined;
768
+ // Refresh an existing session's recency so an actively-used session is never the LRU victim.
769
+ if (session && sessionId) touchSession(sessions, sessionId, session);
770
+ if (!session) {
771
+ const messages = Array.isArray(parsedBody) ? parsedBody : [parsedBody];
772
+ if (!messages.some((m) => isInitializeRequest(m))) {
773
+ return json(
774
+ {
775
+ jsonrpc: "2.0",
776
+ error: { code: -32000, message: "Bad Request: no valid session id, and not an initialize request." },
777
+ id: null,
778
+ },
779
+ 400,
780
+ );
781
+ }
782
+ session = await createSession();
783
+ }
784
+
785
+ const webResponse = await session.transport.handleRequest(toWebRequest(req), { parsedBody });
786
+ // A newly-initialized session assigns its id during handleRequest — register it so the client's
787
+ // follow-up requests (carrying `mcp-session-id`) resolve back to this same server/transport.
788
+ const assignedId = session.transport.sessionId;
789
+ if (assignedId && !sessions.has(assignedId)) {
790
+ sessions.set(assignedId, session);
791
+ // Enforce the hard cap once the freshly-initialized session is registered, evicting the
792
+ // least-recently-used pairs (never this one — it is now the newest) so the map stays bounded.
793
+ await evictExcessSessions(sessions, MAX_SESSIONS, (s) => s.server.close());
794
+ }
795
+ return toHttpResponse(webResponse);
796
+ };
797
+
798
+ const routes: Route[] = [
799
+ { method: "*", path: MCP_PATH, source: "surface:mcp", handler: handle },
800
+ ];
801
+ ctx.host.log("info", "mcp surface mounted", {
802
+ path: MCP_PATH,
803
+ enabled: config.enabled,
804
+ loopbackOnly: !config.allowRemote,
805
+ });
806
+ return {
807
+ name: "mcp",
808
+ routes,
809
+ enabled: config.enabled,
810
+ describe: () => ({
811
+ mcp: { path: MCP_PATH, enabled: config.enabled, loopbackOnly: !config.allowRemote },
812
+ }),
813
+ };
814
+ }