@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,1144 @@
|
|
|
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 projected 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 module (ADR 0067) exposes app operations as MCP tools — each authorized exactly as its own
|
|
18
|
+
// HTTP route by the operation's OpenAPI `security` (a read or mutating op with no `security` is
|
|
19
|
+
// open; there is NO extra MCP-level shared-secret guard on app operations). An operation opts out
|
|
20
|
+
// of projection with the `x-mcp` extension. It also exposes framework-owned process-debugging tools
|
|
21
|
+
// over BOTH truth planes: engine truth via existing `EngineClient` methods (reads, plus mutating
|
|
22
|
+
// unstick tools — cancel/resolve/retry/set-variables, which ARE guarded by the app's shared secret
|
|
23
|
+
// or the `mcp.allowMutations` opt-in), and the ADR 0065 canonical projection stores
|
|
24
|
+
// (`urban_instance_state` / `urban_open_user_tasks`).
|
|
25
|
+
// The app's system brief (`/app/agent.json`) is served as an MCP resource, plus one orientation
|
|
26
|
+
// entry under MCP prompts.
|
|
27
|
+
//
|
|
28
|
+
// Transport: the SDK's Web-Standard Streamable-HTTP transport (`Request`→`Response`), bridged to
|
|
29
|
+
// the runtime's host `HttpRequest`/`HttpResponse` seam — core/ imports no `node:*`; the MCP SDK is
|
|
30
|
+
// an ordinary dependency. Access is loopback-only behind a manifest/env flag that defaults ON for
|
|
31
|
+
// loopback.
|
|
32
|
+
|
|
33
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
34
|
+
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
35
|
+
import {
|
|
36
|
+
CallToolRequestSchema,
|
|
37
|
+
ErrorCode,
|
|
38
|
+
GetPromptRequestSchema,
|
|
39
|
+
isInitializeRequest,
|
|
40
|
+
ListPromptsRequestSchema,
|
|
41
|
+
ListResourcesRequestSchema,
|
|
42
|
+
ListToolsRequestSchema,
|
|
43
|
+
McpError,
|
|
44
|
+
ReadResourceRequestSchema,
|
|
45
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
46
|
+
|
|
47
|
+
import { GENERATED_DIR } from "../../../toolkit/artifact.ts";
|
|
48
|
+
import { SYSTEM_BRIEF_JSON } from "../../../toolkit/derivers/system-brief.ts";
|
|
49
|
+
import {
|
|
50
|
+
collectOperations,
|
|
51
|
+
evaluateSecurity,
|
|
52
|
+
isMutatingMethod,
|
|
53
|
+
type OpenApiDoc,
|
|
54
|
+
type OperationInfo,
|
|
55
|
+
parseSpec,
|
|
56
|
+
type SecurityDecision,
|
|
57
|
+
sharedSecretSchemeName,
|
|
58
|
+
} from "../../../openapi/spec.ts";
|
|
59
|
+
import type { AppApi, RuntimeContext } from "../context.ts";
|
|
60
|
+
import { errorMessage } from "../guards.ts";
|
|
61
|
+
import type {
|
|
62
|
+
ElementInstanceWaitStateFilter,
|
|
63
|
+
EngineIncidentState,
|
|
64
|
+
HttpRequest,
|
|
65
|
+
HttpResponse,
|
|
66
|
+
IncidentFilter,
|
|
67
|
+
ProcessInstanceState,
|
|
68
|
+
SqliteDb,
|
|
69
|
+
UserTaskFilter,
|
|
70
|
+
UserTaskState,
|
|
71
|
+
WaitStateType,
|
|
72
|
+
} from "../host.ts";
|
|
73
|
+
import { presentFormIdentifier } from "../host.ts";
|
|
74
|
+
import { resolveBindMode } from "../manifest.ts";
|
|
75
|
+
import { json, makeRouter, type Route } from "../router.ts";
|
|
76
|
+
import { API_BASE, readApiBinding } from "./api.ts";
|
|
77
|
+
import { resolveAppPath } from "./datasource.ts";
|
|
78
|
+
import { InstanceStateStore } from "./instance-state-store.ts";
|
|
79
|
+
import { OpenUserTasksStore } from "./open-user-tasks-store.ts";
|
|
80
|
+
|
|
81
|
+
/** The mount path of the MCP endpoint — a sibling of `/app/api` / `/app/agent`, mounted
|
|
82
|
+
* unconditionally so an MCP client always has a stable address to connect to. */
|
|
83
|
+
const MCP_PATH = "/app/mcp";
|
|
84
|
+
|
|
85
|
+
/** The header carrying the Streamable-HTTP session id (MCP spec). */
|
|
86
|
+
const SESSION_HEADER = "mcp-session-id";
|
|
87
|
+
|
|
88
|
+
/** Hard upper bound on concurrently-resident MCP sessions. Each session is a live `Server` +
|
|
89
|
+
* transport pair, so an unbounded session map is a memory/handle-exhaustion vector: any local
|
|
90
|
+
* process can repeatedly `initialize` without reusing its `mcp-session-id` and pin a fresh pair
|
|
91
|
+
* each time. Capping the map (LRU-evicted, refreshed on use — see `evictExcessSessions` /
|
|
92
|
+
* `touchSession`) categorically bounds growth regardless of client behaviour or timing. */
|
|
93
|
+
const MAX_SESSIONS = 256;
|
|
94
|
+
|
|
95
|
+
/** The MCP resource URI for the app's system brief (the `/app/agent.json` content). */
|
|
96
|
+
const SYSTEM_BRIEF_URI = "urban://system-brief.json";
|
|
97
|
+
|
|
98
|
+
/** The single orientation prompt registered under MCP `prompts`. */
|
|
99
|
+
const ORIENTATION_PROMPT = "urban-orientation";
|
|
100
|
+
|
|
101
|
+
/** The framework debug-tool name prefix — a reserved namespace. `loadReadOnlyOps` drops any app
|
|
102
|
+
* `operationId` in this namespace before it is projected as a tool, so a framework tool can never
|
|
103
|
+
* collide with (be shadowed by, or duplicate the name of) an app-projected tool. */
|
|
104
|
+
const DEBUG_PREFIX = "urban_debug_";
|
|
105
|
+
|
|
106
|
+
export interface McpHandle {
|
|
107
|
+
readonly name: string;
|
|
108
|
+
routes: Route[];
|
|
109
|
+
/** Whether the surface actually answers vs. 404s (config-enabled). The route is always mounted
|
|
110
|
+
* for a stable address, so callers deriving an "active surfaces" list must gate on this. */
|
|
111
|
+
readonly enabled: boolean;
|
|
112
|
+
describe(): Record<string, unknown>;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Mint a fresh MCP session id, host-agnostically. Prefers the Web Crypto `randomUUID` (a global,
|
|
116
|
+
* not `node:crypto`) where present, with a time+random fallback so a host without `crypto` never
|
|
117
|
+
* throws — mirroring how core treats Web Crypto as optional (see `mintRootRequestKey`). */
|
|
118
|
+
export function newSessionId(): string {
|
|
119
|
+
const uuid = globalThis.crypto?.randomUUID?.();
|
|
120
|
+
if (uuid) return uuid;
|
|
121
|
+
return `sess-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Mark a session as most-recently-used. A `Map` preserves insertion order, so deleting and
|
|
125
|
+
* re-inserting a live entry moves it to the tail — which makes the first key the least-recently-used
|
|
126
|
+
* entry that `evictExcessSessions` reaps first. Refreshing on every use is what keeps an active
|
|
127
|
+
* session from being evicted out from under a client that is still talking to it. */
|
|
128
|
+
function touchSession<S>(sessions: Map<string, S>, id: string, session: S): void {
|
|
129
|
+
sessions.delete(id);
|
|
130
|
+
sessions.set(id, session);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Bound the live-session map to `max` by evicting least-recently-used entries (the `Map`'s leading
|
|
134
|
+
* keys, given `touchSession` re-inserts on use), closing each victim best-effort so its transport /
|
|
135
|
+
* server releases. This is the categorical fix for the unbounded-growth vector: no client can pin
|
|
136
|
+
* more than `max` server/transport pairs, whatever its `initialize` cadence. Exported for a direct
|
|
137
|
+
* unit test of the LRU eviction contract. */
|
|
138
|
+
export async function evictExcessSessions<S>(
|
|
139
|
+
sessions: Map<string, S>,
|
|
140
|
+
max: number,
|
|
141
|
+
close: (session: S) => Promise<void>,
|
|
142
|
+
): Promise<void> {
|
|
143
|
+
while (sessions.size > max) {
|
|
144
|
+
const oldest = sessions.keys().next().value;
|
|
145
|
+
if (oldest === undefined) break;
|
|
146
|
+
const victim = sessions.get(oldest);
|
|
147
|
+
sessions.delete(oldest);
|
|
148
|
+
if (victim !== undefined) {
|
|
149
|
+
try {
|
|
150
|
+
await close(victim);
|
|
151
|
+
} catch {
|
|
152
|
+
// Best-effort: a victim whose transport is already torn down must not abort eviction.
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
/** The resolved MCP access policy for this app. */
|
|
160
|
+
interface McpConfig {
|
|
161
|
+
/** Whether the MCP surface answers at all. Defaults ON. */
|
|
162
|
+
readonly enabled: boolean;
|
|
163
|
+
/** Whether non-loopback callers are allowed. Defaults OFF (loopback-only). */
|
|
164
|
+
readonly allowRemote: boolean;
|
|
165
|
+
/** Explicit runtime opt-in that authorizes the framework MUTATING debug tools WITHOUT a
|
|
166
|
+
* shared-secret credential (ADR 0067 Slice 3). Defaults OFF: mutations then require the app's
|
|
167
|
+
* shared secret. An operator sets this to open mutations on a trusted, credential-less setup
|
|
168
|
+
* (e.g. a purely local debugging box). It is honoured LOOPBACK-ONLY: when `allowRemote` is also
|
|
169
|
+
* enabled the credential-free bypass is ignored and remote mutations still require the shared
|
|
170
|
+
* secret, so opening the surface to non-loopback callers can never silently expose unguarded
|
|
171
|
+
* mutating tools. */
|
|
172
|
+
readonly allowMutations: boolean;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
176
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Resolve the MCP access policy from the manifest
|
|
181
|
+
* (`mcp: { enabled?, allowRemote?, allowMutations? }`, read reflectively until the app schema folds
|
|
182
|
+
* the field in — mirroring how `readApiBinding` reads `api`) and the environment
|
|
183
|
+
* (`URBAN_MCP_ENABLED`, `URBAN_MCP_ALLOW_REMOTE`, `URBAN_MCP_ALLOW_MUTATIONS`). The env flags win so
|
|
184
|
+
* an operator can force the surface off (or open it to a non-loopback bind, or enable mutating
|
|
185
|
+
* tools) without editing the manifest. Enabled defaults ON and access defaults to loopback-only
|
|
186
|
+
* ("defaults ON for loopback"); `allowMutations` defaults OFF (mutating tools stay closed).
|
|
187
|
+
*/
|
|
188
|
+
export function readMcpConfig(manifest: unknown, env: (name: string) => string | undefined): McpConfig {
|
|
189
|
+
const raw = manifest && typeof manifest === "object" ? Reflect.get(manifest, "mcp") : undefined;
|
|
190
|
+
// Honour manifest flags only when they are actual booleans (mirroring `readApiBinding`'s strict
|
|
191
|
+
// type checks), so a stray non-boolean value falls back to the default instead of coercing
|
|
192
|
+
// surprisingly (e.g. the string `"false"` is not a boolean `false`).
|
|
193
|
+
const rawEnabled = isRecord(raw) ? Reflect.get(raw, "enabled") : undefined;
|
|
194
|
+
const rawAllowRemote = isRecord(raw) ? Reflect.get(raw, "allowRemote") : undefined;
|
|
195
|
+
const rawAllowMutations = isRecord(raw) ? Reflect.get(raw, "allowMutations") : undefined;
|
|
196
|
+
const manifestEnabled = typeof rawEnabled === "boolean" ? rawEnabled : undefined;
|
|
197
|
+
const manifestAllowRemote = typeof rawAllowRemote === "boolean" ? rawAllowRemote : undefined;
|
|
198
|
+
const manifestAllowMutations = typeof rawAllowMutations === "boolean" ? rawAllowMutations : undefined;
|
|
199
|
+
const envEnabled = env("URBAN_MCP_ENABLED");
|
|
200
|
+
const envAllowRemote = env("URBAN_MCP_ALLOW_REMOTE");
|
|
201
|
+
const envAllowMutations = env("URBAN_MCP_ALLOW_MUTATIONS");
|
|
202
|
+
const enabled = envEnabled === "false" ? false : envEnabled === "true" ? true : manifestEnabled !== false;
|
|
203
|
+
const allowRemote =
|
|
204
|
+
envAllowRemote === "true" ? true : envAllowRemote === "false" ? false : manifestAllowRemote === true;
|
|
205
|
+
const allowMutations =
|
|
206
|
+
envAllowMutations === "true" ? true : envAllowMutations === "false" ? false : manifestAllowMutations === true;
|
|
207
|
+
return { enabled, allowRemote, allowMutations };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The host portion of an HTTP `Host` header, lower-cased and stripped of any `:port` (and of the
|
|
211
|
+
* `[...]` brackets around an IPv6 literal), or `undefined` when there is no Host header. */
|
|
212
|
+
function hostName(req: HttpRequest): string | undefined {
|
|
213
|
+
const host = req.headers.get("host");
|
|
214
|
+
if (!host) return undefined;
|
|
215
|
+
const trimmed = host.trim().toLowerCase();
|
|
216
|
+
if (trimmed.startsWith("[")) {
|
|
217
|
+
const end = trimmed.indexOf("]");
|
|
218
|
+
return end > 0 ? trimmed.slice(1, end) : trimmed.slice(1);
|
|
219
|
+
}
|
|
220
|
+
const colon = trimmed.indexOf(":");
|
|
221
|
+
return colon >= 0 ? trimmed.slice(0, colon) : trimmed;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Whether the request targets a loopback host — the best-effort loopback signal available at the
|
|
225
|
+
* host seam (which carries no peer address). A missing Host header is treated as loopback (a direct
|
|
226
|
+
* local client that sent none). */
|
|
227
|
+
export function isLoopbackRequest(req: HttpRequest): boolean {
|
|
228
|
+
const name = hostName(req);
|
|
229
|
+
return name === undefined || name === "localhost" || name === "127.0.0.1" || name === "::1";
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Read a header value from the SDK's per-request `requestInfo.headers` map (lower-cased keys,
|
|
233
|
+
* values `string | string[] | undefined`). A repeated header (`string[]`, as Node's raw adapter
|
|
234
|
+
* surfaces it) is comma-joined to MATCH `Headers.get()` semantics — the same read `mountApi` and
|
|
235
|
+
* the reconstructed `toHttpRequest` use for OpenAPI route enforcement — so the shared-secret guard
|
|
236
|
+
* cannot diverge from route enforcement on a repeated credential header. Returns `undefined` when
|
|
237
|
+
* absent. Used to read the shared-secret credential the client presents on the MCP connection for a
|
|
238
|
+
* mutating tool call. */
|
|
239
|
+
export function readHeader(headers: Record<string, string | string[] | undefined>, name: string): string | undefined {
|
|
240
|
+
const value = headers[name.toLowerCase()];
|
|
241
|
+
if (Array.isArray(value)) return value.join(", ");
|
|
242
|
+
return value;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** A minimal read of the `x-mcp` extension for a projected operation is done at parse time
|
|
246
|
+
* (`OperationInfo.mcpExcluded`); here we only need the mutation classification, shared with the CI
|
|
247
|
+
* parity guard via {@link isMutatingMethod}. Kept as a named predicate for readability at the call
|
|
248
|
+
* sites (tool annotations + guard). */
|
|
249
|
+
function isMutatingOperation(op: OperationInfo): boolean {
|
|
250
|
+
return isMutatingMethod(op.method);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** A JSON Schema (OpenAPI object subset) describing a tool's input, derived from an operation's
|
|
254
|
+
* path/query parameters and its request-body schema. Keeping the property schemas verbatim from the
|
|
255
|
+
* spec means the tool input schema and the HTTP validation share one source of truth. */
|
|
256
|
+
function toolInputSchema(op: OperationInfo): Record<string, unknown> {
|
|
257
|
+
const properties: Record<string, unknown> = {};
|
|
258
|
+
const required: string[] = [];
|
|
259
|
+
for (const p of op.parameters) {
|
|
260
|
+
if (p.in === "path" || p.in === "query") {
|
|
261
|
+
properties[p.name] = p.schema ?? {};
|
|
262
|
+
if (p.required) required.push(p.name);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (op.requestBodySchema) {
|
|
266
|
+
properties.body = op.requestBodySchema;
|
|
267
|
+
if (op.requestBodyRequired) required.push("body");
|
|
268
|
+
}
|
|
269
|
+
const schema: Record<string, unknown> = { type: "object", properties };
|
|
270
|
+
if (required.length > 0) schema.required = required;
|
|
271
|
+
return schema;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Substitute an operation's `{param}` path-template segments with the supplied arguments, building
|
|
275
|
+
* the request path under the shared `/app/api` base so dispatch hits the exact `mountApi` route. */
|
|
276
|
+
function fillPath(op: OperationInfo, args: Record<string, unknown>): string {
|
|
277
|
+
const path = op.path.replace(/\{([^}]+)\}/g, (_m, name: string) => {
|
|
278
|
+
const value = args[name];
|
|
279
|
+
return encodeURIComponent(value === undefined || value === null ? "" : String(value));
|
|
280
|
+
});
|
|
281
|
+
return `${API_BASE}${path}`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Build the query string for a tool call from the operation's declared query parameters and the
|
|
285
|
+
* supplied arguments — repeated (array) values become repeated keys, exactly as an HTTP client
|
|
286
|
+
* would send them, so the reused dispatcher validates identical input. */
|
|
287
|
+
function toolQuery(op: OperationInfo, args: Record<string, unknown>): URLSearchParams {
|
|
288
|
+
const query = new URLSearchParams();
|
|
289
|
+
for (const p of op.parameters) {
|
|
290
|
+
if (p.in !== "query") continue;
|
|
291
|
+
const value = args[p.name];
|
|
292
|
+
if (value === undefined || value === null) continue;
|
|
293
|
+
if (Array.isArray(value)) {
|
|
294
|
+
for (const item of value) query.append(p.name, String(item));
|
|
295
|
+
} else {
|
|
296
|
+
query.append(p.name, String(value));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return query;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Whether an argument is absent for presence purposes: unset (`undefined`/`null`), or a string that
|
|
303
|
+
* is empty or whitespace-only after trimming. This is the trimmed-presence rule
|
|
304
|
+
* {@link presentFormIdentifier} enforces for form identifiers, reused here so MCP required/path args
|
|
305
|
+
* and form resolution cannot drift on what "provided" means (a whitespace-only `" "` is absent in
|
|
306
|
+
* both). A non-string value (e.g. a numeric path id) is considered present. */
|
|
307
|
+
function isBlankArg(value: unknown): boolean {
|
|
308
|
+
if (value === undefined || value === null) return true;
|
|
309
|
+
if (typeof value === "string") return presentFormIdentifier(value) === undefined;
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** The required path parameters an operation declares that the tool `args` did not supply. Unlike
|
|
314
|
+
* an HTTP request (whose path can't structurally omit a segment), a tool call can leave a
|
|
315
|
+
* `{param}` unset or blank; `fillPath` would then substitute an empty string and silently mis-route
|
|
316
|
+
* (a 404 route mismatch, or worse a different route shape), so a missing/blank path arg is rejected
|
|
317
|
+
* up front. Blankness uses the shared trimmed-presence rule so a whitespace-only value is rejected
|
|
318
|
+
* too. */
|
|
319
|
+
function missingPathParams(op: OperationInfo, args: Record<string, unknown>): string[] {
|
|
320
|
+
return op.parameters
|
|
321
|
+
.filter((p) => p.in === "path" && isBlankArg(args[p.name]))
|
|
322
|
+
.map((p) => p.name);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** The `required` keys a tool's `inputSchema` declares but the call omitted. Presence is decided
|
|
326
|
+
* solely by {@link isBlankArg} — the single source of truth also used for path args — so a value is
|
|
327
|
+
* "provided" unless it is absent (`undefined`/`null`) or a blank/whitespace-only string. A required
|
|
328
|
+
* non-string input (e.g. a numeric/boolean/object arg) that is present is therefore accepted, not
|
|
329
|
+
* wrongly reported missing. Derived from the schema's own `required` array so declaring a field
|
|
330
|
+
* required is enough to have missing (or whitespace-only) calls rejected rather than silently
|
|
331
|
+
* substituted with an empty string. */
|
|
332
|
+
export function missingRequiredArgs(inputSchema: Record<string, unknown>, args: Record<string, unknown>): string[] {
|
|
333
|
+
const required = inputSchema.required;
|
|
334
|
+
if (!Array.isArray(required)) return [];
|
|
335
|
+
const missing: string[] = [];
|
|
336
|
+
for (const key of required) {
|
|
337
|
+
if (typeof key !== "string") continue;
|
|
338
|
+
if (isBlankArg(args[key])) missing.push(key);
|
|
339
|
+
}
|
|
340
|
+
return missing;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Reconstruct the HTTP request an operation tool call is equivalent to, ready to hand to the
|
|
344
|
+
* reused `mountApi` router. The JSON body is the tool's `body` argument (when the op declares one).
|
|
345
|
+
* The MCP connection's request headers are FORWARDED (with `content-type` pinned to JSON) so the
|
|
346
|
+
* reused router's `evaluateSecurity` sees the client's credential: a mutating (or a secured
|
|
347
|
+
* read-only) app operation is then guarded exactly as its HTTP route is — the app operation tools
|
|
348
|
+
* inherit the OpenAPI `security` contract rather than a bespoke MCP gate (ADR 0067 §4). */
|
|
349
|
+
function toHttpRequest(
|
|
350
|
+
op: OperationInfo,
|
|
351
|
+
args: Record<string, unknown>,
|
|
352
|
+
forwardHeaders: Record<string, string | string[] | undefined>,
|
|
353
|
+
): HttpRequest {
|
|
354
|
+
const bodyText = op.requestBodySchema !== undefined && args.body !== undefined
|
|
355
|
+
? JSON.stringify(args.body)
|
|
356
|
+
: "";
|
|
357
|
+
const headers = new Headers();
|
|
358
|
+
for (const [key, value] of Object.entries(forwardHeaders)) {
|
|
359
|
+
if (typeof value === "string") headers.set(key, value);
|
|
360
|
+
else if (Array.isArray(value)) for (const v of value) headers.append(key, v);
|
|
361
|
+
}
|
|
362
|
+
headers.set("content-type", "application/json");
|
|
363
|
+
return {
|
|
364
|
+
method: op.method.toUpperCase(),
|
|
365
|
+
path: fillPath(op, args),
|
|
366
|
+
query: toolQuery(op, args),
|
|
367
|
+
headers,
|
|
368
|
+
text: () => Promise.resolve(bodyText),
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Wrap a value as an MCP tool text result. A JSON payload is stringified; `isError` flags a
|
|
373
|
+
* non-2xx dispatch so the client sees the failure (carrying the delegate's own error body). */
|
|
374
|
+
function textResult(value: unknown, isError = false): {
|
|
375
|
+
content: { type: "text"; text: string }[];
|
|
376
|
+
isError?: boolean;
|
|
377
|
+
} {
|
|
378
|
+
const text = typeof value === "string" ? value : JSON.stringify(value ?? null);
|
|
379
|
+
return isError ? { content: [{ type: "text", text }], isError: true } : { content: [{ type: "text", text }] };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function readString(args: Record<string, unknown>, key: string): string | undefined {
|
|
383
|
+
const v = args[key];
|
|
384
|
+
return typeof v === "string" ? v : undefined;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Read an OPTIONAL string argument in its TRIMMED present form, or `undefined` when it is missing,
|
|
388
|
+
* non-string, or blank/whitespace-only. The canonical read for identifying keys and filter values
|
|
389
|
+
* (processInstanceKey/elementId/assignee/candidateGroup): it applies the SAME
|
|
390
|
+
* {@link presentFormIdentifier} trimmed-presence rule that `missingRequiredArgs`/{@link isBlankArg}
|
|
391
|
+
* use for presence, so a padded value like `" pi-1 "` normalizes to `"pi-1"` instead of reaching
|
|
392
|
+
* the engine untrimmed — where a filter would silently match nothing and a keyed lookup would target
|
|
393
|
+
* a whitespace-padded entity that does not exist. The single source of truth {@link requireString}
|
|
394
|
+
* also derives from, so required and optional reads cannot drift on how they normalize. */
|
|
395
|
+
function readPresentString(args: Record<string, unknown>, key: string): string | undefined {
|
|
396
|
+
return presentFormIdentifier(readString(args, key));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** Read a REQUIRED non-empty string argument, TRIMMED, or throw `InvalidParams`. The canonical guard
|
|
400
|
+
* the mutating debug tools use for their identifying keys (processInstanceKey/incidentKey/jobKey/
|
|
401
|
+
* scopeKey): a missing, non-string, or blank value fails fast before any engine state is touched,
|
|
402
|
+
* rather than silently degrading to `""` and performing a broken/misleading mutation. Derived from
|
|
403
|
+
* {@link readPresentString} — the same trimmed-presence rule {@link isBlankArg} (hence
|
|
404
|
+
* `missingRequiredArgs`) uses — so a padded key like `" pi-1 "` cannot pass the required-arg check
|
|
405
|
+
* yet reach the engine untrimmed and mis-target the entity. */
|
|
406
|
+
function requireString(args: Record<string, unknown>, key: string): string {
|
|
407
|
+
const present = readPresentString(args, key);
|
|
408
|
+
if (present === undefined) throw new McpError(ErrorCode.InvalidParams, `${key} must be a non-empty string`);
|
|
409
|
+
return present;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function readStringArray(args: Record<string, unknown>, key: string): string[] | undefined {
|
|
413
|
+
const v = args[key];
|
|
414
|
+
if (!Array.isArray(v)) return undefined;
|
|
415
|
+
const out: string[] = [];
|
|
416
|
+
for (const item of v) if (typeof item === "string") out.push(item);
|
|
417
|
+
return out;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function readProcessInstanceState(args: Record<string, unknown>, key: string): ProcessInstanceState | undefined {
|
|
421
|
+
const v = args[key];
|
|
422
|
+
return v === "ACTIVE" || v === "COMPLETED" || v === "TERMINATED" ? v : undefined;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function readUserTaskState(args: Record<string, unknown>, key: string): UserTaskState | undefined {
|
|
426
|
+
const v = args[key];
|
|
427
|
+
return v === "CREATED" || v === "COMPLETED" || v === "CANCELED" || v === "FAILED" ? v : undefined;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function readWaitStateType(args: Record<string, unknown>, key: string): WaitStateType | undefined {
|
|
431
|
+
const v = args[key];
|
|
432
|
+
return v === "JOB" || v === "MESSAGE" || v === "USER_TASK" || v === "TIMER" || v === "SIGNAL" || v === "CONDITION"
|
|
433
|
+
? v
|
|
434
|
+
: undefined;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Narrow an argument to a real engine incident state — the `"UNKNOWN"` client-side sentinel is
|
|
438
|
+
* deliberately NOT a selector (see `IncidentState`/`IncidentFilter`), so it is rejected here. */
|
|
439
|
+
function readIncidentState(args: Record<string, unknown>, key: string): EngineIncidentState | undefined {
|
|
440
|
+
const v = args[key];
|
|
441
|
+
return v === "ACTIVE" || v === "MIGRATED" || v === "PENDING" || v === "RESOLVED" ? v : undefined;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Read a plain JSON object argument (an MCP `object` input), else `undefined`. Used for the
|
|
445
|
+
* `set_variables` payload — an arbitrary variable map handed to the engine verbatim. */
|
|
446
|
+
function readObject(args: Record<string, unknown>, key: string): Record<string, unknown> | undefined {
|
|
447
|
+
const v = args[key];
|
|
448
|
+
return isRecord(v) ? v : undefined;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Read a finite integer argument (for a job's retry count), else `undefined`. */
|
|
452
|
+
function readInteger(args: Record<string, unknown>, key: string): number | undefined {
|
|
453
|
+
const v = args[key];
|
|
454
|
+
return typeof v === "number" && Number.isInteger(v) ? v : undefined;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** One framework-owned debug tool: its MCP descriptor plus a handler that reads engine truth / a
|
|
458
|
+
* projection store, or (for a `mutating` tool) performs an unstick operation. A `mutating` tool is
|
|
459
|
+
* gated by the shared-secret guard before its handler runs (ADR 0067 §4); a read tool is not. */
|
|
460
|
+
interface DebugTool {
|
|
461
|
+
readonly name: string;
|
|
462
|
+
readonly description: string;
|
|
463
|
+
readonly inputSchema: Record<string, unknown>;
|
|
464
|
+
/** Whether the tool mutates engine state — gates it behind the shared-secret guard and flips the
|
|
465
|
+
* `readOnlyHint` annotation off in `tools/list`. Read tools omit it (default read-only). */
|
|
466
|
+
readonly mutating?: boolean;
|
|
467
|
+
run(args: Record<string, unknown>): Promise<unknown>;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const OPTIONAL_STRING = { type: "string" } as const;
|
|
471
|
+
|
|
472
|
+
/** Build the framework debug tools, closing over the app's engine (truth plane) and default data
|
|
473
|
+
* source (the ADR 0065 projection stores). READ tools consume the engine's read accessors and the
|
|
474
|
+
* projection stores; MUTATING tools (ADR 0067 Slice 3, gated by the shared-secret guard) drive the
|
|
475
|
+
* Slice 2 seam extension — cancel an instance, resolve/retry an incident, set variables. */
|
|
476
|
+
function buildDebugTools(app: AppApi): DebugTool[] {
|
|
477
|
+
const projectionDb = (): SqliteDb | undefined =>
|
|
478
|
+
app.data.hasDefaultSource() ? app.data.source().db : undefined;
|
|
479
|
+
|
|
480
|
+
return [
|
|
481
|
+
{
|
|
482
|
+
name: `${DEBUG_PREFIX}search_process_instances`,
|
|
483
|
+
description: "Engine truth: search process instances by key and/or lifecycle state.",
|
|
484
|
+
inputSchema: {
|
|
485
|
+
type: "object",
|
|
486
|
+
properties: {
|
|
487
|
+
processInstanceKeys: { type: "array", items: OPTIONAL_STRING },
|
|
488
|
+
state: { type: "string", enum: ["ACTIVE", "COMPLETED", "TERMINATED"] },
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
run: (args) => {
|
|
492
|
+
const filter: { processInstanceKeys?: string[]; state?: ProcessInstanceState } = {};
|
|
493
|
+
const keys = readStringArray(args, "processInstanceKeys");
|
|
494
|
+
if (keys) filter.processInstanceKeys = keys;
|
|
495
|
+
const state = readProcessInstanceState(args, "state");
|
|
496
|
+
if (state) filter.state = state;
|
|
497
|
+
return app.engine.searchProcessInstances(filter);
|
|
498
|
+
},
|
|
499
|
+
},
|
|
500
|
+
{
|
|
501
|
+
name: `${DEBUG_PREFIX}search_element_instance_wait_states`,
|
|
502
|
+
description: "Engine truth: search element-instance wait states (every park, not only user tasks).",
|
|
503
|
+
inputSchema: {
|
|
504
|
+
type: "object",
|
|
505
|
+
properties: {
|
|
506
|
+
processInstanceKey: OPTIONAL_STRING,
|
|
507
|
+
elementId: OPTIONAL_STRING,
|
|
508
|
+
waitStateType: {
|
|
509
|
+
type: "string",
|
|
510
|
+
enum: ["JOB", "MESSAGE", "USER_TASK", "TIMER", "SIGNAL", "CONDITION"],
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
},
|
|
514
|
+
run: (args) => {
|
|
515
|
+
const filter: ElementInstanceWaitStateFilter = {};
|
|
516
|
+
const pik = readPresentString(args, "processInstanceKey");
|
|
517
|
+
if (pik) filter.processInstanceKey = pik;
|
|
518
|
+
const elementId = readPresentString(args, "elementId");
|
|
519
|
+
if (elementId) filter.elementId = elementId;
|
|
520
|
+
const waitStateType = readWaitStateType(args, "waitStateType");
|
|
521
|
+
if (waitStateType) filter.waitStateType = waitStateType;
|
|
522
|
+
return app.engine.searchElementInstanceWaitStates(filter);
|
|
523
|
+
},
|
|
524
|
+
},
|
|
525
|
+
{
|
|
526
|
+
name: `${DEBUG_PREFIX}get_element_instance`,
|
|
527
|
+
description: "Engine truth: fetch a single element instance by key (null when absent).",
|
|
528
|
+
inputSchema: {
|
|
529
|
+
type: "object",
|
|
530
|
+
properties: { elementInstanceKey: OPTIONAL_STRING },
|
|
531
|
+
required: ["elementInstanceKey"],
|
|
532
|
+
},
|
|
533
|
+
run: (args) => app.engine.getElementInstance(requireString(args, "elementInstanceKey")),
|
|
534
|
+
},
|
|
535
|
+
{
|
|
536
|
+
name: `${DEBUG_PREFIX}search_user_tasks`,
|
|
537
|
+
description: "Engine truth: search user tasks by process instance, assignee, candidate group, and/or state.",
|
|
538
|
+
inputSchema: {
|
|
539
|
+
type: "object",
|
|
540
|
+
properties: {
|
|
541
|
+
processInstanceKey: OPTIONAL_STRING,
|
|
542
|
+
assignee: OPTIONAL_STRING,
|
|
543
|
+
candidateGroup: OPTIONAL_STRING,
|
|
544
|
+
state: { type: "string", enum: ["CREATED", "COMPLETED", "CANCELED", "FAILED"] },
|
|
545
|
+
},
|
|
546
|
+
},
|
|
547
|
+
run: (args) => {
|
|
548
|
+
const filter: UserTaskFilter & { state?: UserTaskState } = {};
|
|
549
|
+
const pik = readPresentString(args, "processInstanceKey");
|
|
550
|
+
if (pik) filter.processInstanceKey = pik;
|
|
551
|
+
const assignee = readPresentString(args, "assignee");
|
|
552
|
+
if (assignee) filter.assignee = assignee;
|
|
553
|
+
const candidateGroup = readPresentString(args, "candidateGroup");
|
|
554
|
+
if (candidateGroup) filter.candidateGroup = candidateGroup;
|
|
555
|
+
const state = readUserTaskState(args, "state");
|
|
556
|
+
if (state) filter.state = state;
|
|
557
|
+
return app.engine.searchUserTasks(filter);
|
|
558
|
+
},
|
|
559
|
+
},
|
|
560
|
+
{
|
|
561
|
+
name: `${DEBUG_PREFIX}search_incidents`,
|
|
562
|
+
description:
|
|
563
|
+
"Engine truth: search incidents (stuck tokens — a job out of retries, an unhandled error, a failed gateway) by process instance and/or state. Read-only; each result carries the incidentKey/jobKey the mutating unstick tools take.",
|
|
564
|
+
inputSchema: {
|
|
565
|
+
type: "object",
|
|
566
|
+
properties: {
|
|
567
|
+
processInstanceKey: OPTIONAL_STRING,
|
|
568
|
+
state: { type: "string", enum: ["ACTIVE", "MIGRATED", "PENDING", "RESOLVED"] },
|
|
569
|
+
},
|
|
570
|
+
},
|
|
571
|
+
run: (args) => {
|
|
572
|
+
const filter: IncidentFilter = {};
|
|
573
|
+
const pik = readPresentString(args, "processInstanceKey");
|
|
574
|
+
if (pik) filter.processInstanceKey = pik;
|
|
575
|
+
const state = readIncidentState(args, "state");
|
|
576
|
+
if (state) filter.state = state;
|
|
577
|
+
return app.engine.searchIncidents(filter);
|
|
578
|
+
},
|
|
579
|
+
},
|
|
580
|
+
{
|
|
581
|
+
name: `${DEBUG_PREFIX}cancel_instance`,
|
|
582
|
+
description: "MUTATING: cancel a running process instance. Requires the shared-secret guard.",
|
|
583
|
+
mutating: true,
|
|
584
|
+
inputSchema: {
|
|
585
|
+
type: "object",
|
|
586
|
+
properties: { processInstanceKey: OPTIONAL_STRING },
|
|
587
|
+
required: ["processInstanceKey"],
|
|
588
|
+
},
|
|
589
|
+
run: async (args) => {
|
|
590
|
+
const processInstanceKey = requireString(args, "processInstanceKey");
|
|
591
|
+
await app.engine.cancelInstance({ processInstanceKey });
|
|
592
|
+
return { cancelled: processInstanceKey };
|
|
593
|
+
},
|
|
594
|
+
},
|
|
595
|
+
{
|
|
596
|
+
name: `${DEBUG_PREFIX}resolve_incident`,
|
|
597
|
+
description:
|
|
598
|
+
"MUTATING: resolve an open incident by key, unblocking the parked token (bump the job's retries first for a jobNoRetries incident — see retry_job). Requires the shared-secret guard.",
|
|
599
|
+
mutating: true,
|
|
600
|
+
inputSchema: {
|
|
601
|
+
type: "object",
|
|
602
|
+
properties: { incidentKey: OPTIONAL_STRING },
|
|
603
|
+
required: ["incidentKey"],
|
|
604
|
+
},
|
|
605
|
+
run: async (args) => {
|
|
606
|
+
const incidentKey = requireString(args, "incidentKey");
|
|
607
|
+
await app.engine.resolveIncident({ incidentKey });
|
|
608
|
+
return { resolved: incidentKey };
|
|
609
|
+
},
|
|
610
|
+
},
|
|
611
|
+
{
|
|
612
|
+
name: `${DEBUG_PREFIX}retry_job`,
|
|
613
|
+
description:
|
|
614
|
+
"MUTATING: set a failed job's remaining retries (making it activatable again — the 'retry' half of unsticking a jobNoRetries incident). Requires the shared-secret guard.",
|
|
615
|
+
mutating: true,
|
|
616
|
+
inputSchema: {
|
|
617
|
+
type: "object",
|
|
618
|
+
properties: { jobKey: OPTIONAL_STRING, retries: { type: "integer", minimum: 0 } },
|
|
619
|
+
required: ["jobKey", "retries"],
|
|
620
|
+
},
|
|
621
|
+
run: async (args) => {
|
|
622
|
+
const jobKey = requireString(args, "jobKey");
|
|
623
|
+
const retries = readInteger(args, "retries");
|
|
624
|
+
if (retries === undefined || retries < 0)
|
|
625
|
+
throw new McpError(ErrorCode.InvalidParams, "retries must be a non-negative integer");
|
|
626
|
+
await app.engine.updateJobRetries({ jobKey, retries });
|
|
627
|
+
return { jobKey, retries };
|
|
628
|
+
},
|
|
629
|
+
},
|
|
630
|
+
{
|
|
631
|
+
name: `${DEBUG_PREFIX}set_variables`,
|
|
632
|
+
description:
|
|
633
|
+
"MUTATING: merge variables into a scope (a process-instance or element-instance key); set `local` true to keep them in that local scope. Repairs state before resolving an incident. Requires the shared-secret guard.",
|
|
634
|
+
mutating: true,
|
|
635
|
+
inputSchema: {
|
|
636
|
+
type: "object",
|
|
637
|
+
properties: {
|
|
638
|
+
scopeKey: OPTIONAL_STRING,
|
|
639
|
+
variables: { type: "object" },
|
|
640
|
+
local: { type: "boolean" },
|
|
641
|
+
},
|
|
642
|
+
required: ["scopeKey", "variables"],
|
|
643
|
+
},
|
|
644
|
+
run: async (args) => {
|
|
645
|
+
const scopeKey = requireString(args, "scopeKey");
|
|
646
|
+
const variables = readObject(args, "variables");
|
|
647
|
+
if (variables === undefined) throw new McpError(ErrorCode.InvalidParams, "variables must be an object");
|
|
648
|
+
const input: { scopeKey: string; variables: Record<string, unknown>; local?: boolean } = {
|
|
649
|
+
scopeKey,
|
|
650
|
+
variables,
|
|
651
|
+
};
|
|
652
|
+
if (args.local === true) input.local = true;
|
|
653
|
+
await app.engine.setVariables(input);
|
|
654
|
+
return { scopeKey, set: Object.keys(variables) };
|
|
655
|
+
},
|
|
656
|
+
},
|
|
657
|
+
{
|
|
658
|
+
name: `${DEBUG_PREFIX}instance_state`,
|
|
659
|
+
description: "Projection (urban_instance_state, ADR 0065): the canonical engine-lifecycle state for an instance.",
|
|
660
|
+
inputSchema: {
|
|
661
|
+
type: "object",
|
|
662
|
+
properties: { processInstanceKey: OPTIONAL_STRING },
|
|
663
|
+
required: ["processInstanceKey"],
|
|
664
|
+
},
|
|
665
|
+
run: (args) => {
|
|
666
|
+
const key = requireString(args, "processInstanceKey");
|
|
667
|
+
const db = projectionDb();
|
|
668
|
+
if (!db) return Promise.resolve(null);
|
|
669
|
+
return Promise.resolve(new InstanceStateStore(db).getState(key) ?? null);
|
|
670
|
+
},
|
|
671
|
+
},
|
|
672
|
+
{
|
|
673
|
+
name: `${DEBUG_PREFIX}open_user_tasks`,
|
|
674
|
+
description: "Projection (urban_open_user_tasks, ADR 0065): the currently-open user tasks for an instance.",
|
|
675
|
+
inputSchema: {
|
|
676
|
+
type: "object",
|
|
677
|
+
properties: { processInstanceKey: OPTIONAL_STRING },
|
|
678
|
+
required: ["processInstanceKey"],
|
|
679
|
+
},
|
|
680
|
+
run: (args) => {
|
|
681
|
+
const key = requireString(args, "processInstanceKey");
|
|
682
|
+
const db = projectionDb();
|
|
683
|
+
if (!db) return Promise.resolve([]);
|
|
684
|
+
return Promise.resolve(new OpenUserTasksStore(db).openTasks(key));
|
|
685
|
+
},
|
|
686
|
+
},
|
|
687
|
+
];
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Mount the runtime-served MCP surface at `/app/mcp` (ADR 0067). Returns a single `*`-method route
|
|
692
|
+
* (mounted unconditionally, like `/app/agent`); the handler bridges the host request/response seam
|
|
693
|
+
* to the SDK's Web-Standard Streamable-HTTP transport, maintaining one MCP session per
|
|
694
|
+
* `mcp-session-id`. The tool projection reuses the SAME OpenAPI enumeration and dispatch path as
|
|
695
|
+
* `mountApi`, so a tool call is equivalent to the corresponding HTTP call.
|
|
696
|
+
*/
|
|
697
|
+
export function mountMcp(ctx: RuntimeContext, app: AppApi, apiRoutes: Route[]): McpHandle {
|
|
698
|
+
const config = readMcpConfig(ctx.manifest, ctx.host.env);
|
|
699
|
+
// The effective HTTP bind interface. When the app is bound to all interfaces, the
|
|
700
|
+
// client-controlled `Host` header is not a trustworthy loopback signal (a remote caller can send
|
|
701
|
+
// `Host: localhost`), so a loopback-only surface must refuse EVERY caller rather than trust it.
|
|
702
|
+
const bindMode = resolveBindMode(ctx.manifest, ctx.host.env);
|
|
703
|
+
|
|
704
|
+
// Reuse the app's OpenAPI dispatch VERBATIM: `mountSurfaces` already built the `/app/api` router
|
|
705
|
+
// (validation + delegate registry) via `mountApi` and hands us its routes here, so tool and HTTP
|
|
706
|
+
// calls share ONE dispatcher instance (no forked dispatcher, no second doc/delegate cache). A tool
|
|
707
|
+
// call is reconstructed into the operation's HTTP request and routed through this same router.
|
|
708
|
+
const apiRouter = makeRouter(apiRoutes);
|
|
709
|
+
|
|
710
|
+
// The parsed OpenAPI document, loaded lazily from the same spec `mountApi` reads and cached
|
|
711
|
+
// (mirroring `mountApi`'s lazy `loadDoc`). Shared by the tool projection AND the mutation guard's
|
|
712
|
+
// shared-secret-scheme resolution, so both read one parse of one spec. The absent-`doc` outcome is
|
|
713
|
+
// discriminated: `failed: false` is a legitimately-absent binding (no app tools, no shared-secret
|
|
714
|
+
// scheme); `failed: true` is a broken/unparseable spec (a server misconfiguration, already logged)
|
|
715
|
+
// — the mutation guard needs the distinction to answer a `401` (nothing to authorize against) vs a
|
|
716
|
+
// `500` (spec broken, can't even tell) rather than conflating both as unauthorized. A failure is
|
|
717
|
+
// not cached, so a later request retries.
|
|
718
|
+
type SpecLoad = { doc: OpenApiDoc } | { doc: undefined; failed: boolean };
|
|
719
|
+
let docPromise: Promise<SpecLoad> | undefined;
|
|
720
|
+
const loadSpecDoc = (): Promise<SpecLoad> => {
|
|
721
|
+
if (!docPromise) {
|
|
722
|
+
docPromise = (async (): Promise<SpecLoad> => {
|
|
723
|
+
const binding = readApiBinding(ctx.manifest);
|
|
724
|
+
if (!binding) return { doc: undefined, failed: false };
|
|
725
|
+
const text = await ctx.host.readTextFile(resolveAppPath(ctx.root, binding.spec));
|
|
726
|
+
return { doc: parseSpec(text) };
|
|
727
|
+
})().catch((e) => {
|
|
728
|
+
docPromise = undefined; // don't cache the failure — let a later request retry
|
|
729
|
+
ctx.host.log("warn", "mcp: failed to load the app OpenAPI spec", { error: errorMessage(e) });
|
|
730
|
+
return { doc: undefined, failed: true };
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
return docPromise;
|
|
734
|
+
};
|
|
735
|
+
|
|
736
|
+
// The projected app operation table (read AND mutating), derived from the same enumeration
|
|
737
|
+
// `mountApi` routes. `x-mcp`-excluded operations are dropped (the security-relevant opt-out) as
|
|
738
|
+
// are reserved-namespace ids; the read/mutate split is applied at annotation + guard time via
|
|
739
|
+
// `isMutatingOperation`, never a forked walker. Memoized PER parsed spec document (keyed on the
|
|
740
|
+
// cached doc promise `loadSpecDoc` returns): `tools/list` and `tools/call` both hit this on every
|
|
741
|
+
// request, so recomputing `collectOperations` + the reserved-namespace filter (and re-emitting its
|
|
742
|
+
// warn) each time is avoidable overhead and log spam. When the spec fails to load `loadSpecDoc`
|
|
743
|
+
// resets its promise, so the key changes and the projection is recomputed on the retry.
|
|
744
|
+
let projectedOps: { docKey: Promise<SpecLoad>; ops: Promise<OperationInfo[]> } | undefined;
|
|
745
|
+
const loadProjectedOps = (): Promise<OperationInfo[]> => {
|
|
746
|
+
const docKey = loadSpecDoc();
|
|
747
|
+
if (projectedOps?.docKey !== docKey) {
|
|
748
|
+
const ops = docKey.then(({ doc }) => {
|
|
749
|
+
if (!doc) return [];
|
|
750
|
+
const projected = collectOperations(doc).filter((op) => !op.mcpExcluded);
|
|
751
|
+
// The `urban_debug_` namespace is framework-reserved (see DEBUG_PREFIX). A `urban_debug_*`
|
|
752
|
+
// operationId is a legal safe path segment, so an app could declare one — but `CallTool`
|
|
753
|
+
// resolves `debugByName` before app ops, so such an op would be shadowed by the framework
|
|
754
|
+
// tool AND surface a duplicate name in `tools/list`. Drop reserved-namespace app ops here
|
|
755
|
+
// (the single projection source both `tools/list` and `CallTool` read), warning per drop,
|
|
756
|
+
// so the collision is impossible by construction rather than order-dependent.
|
|
757
|
+
return projected.filter((op) => {
|
|
758
|
+
if (op.operationId.startsWith(DEBUG_PREFIX)) {
|
|
759
|
+
ctx.host.log("warn", "mcp: dropped app operation using reserved framework tool namespace", {
|
|
760
|
+
operationId: op.operationId,
|
|
761
|
+
reservedPrefix: DEBUG_PREFIX,
|
|
762
|
+
});
|
|
763
|
+
return false;
|
|
764
|
+
}
|
|
765
|
+
return true;
|
|
766
|
+
});
|
|
767
|
+
});
|
|
768
|
+
projectedOps = { docKey, ops };
|
|
769
|
+
}
|
|
770
|
+
return projectedOps.ops;
|
|
771
|
+
};
|
|
772
|
+
|
|
773
|
+
const debugTools = buildDebugTools(app);
|
|
774
|
+
const debugByName = new Map(debugTools.map((t) => [t.name, t]));
|
|
775
|
+
|
|
776
|
+
// A minimal `OperationInfo` that requires ONLY the app's shared-secret scheme, so the framework
|
|
777
|
+
// mutating tools reuse `evaluateSecurity` VERBATIM (constant-time compare, misconfig→500, absent
|
|
778
|
+
// credential→401) rather than a bespoke comparison — the same enforcement path an app operation's
|
|
779
|
+
// own `security` takes. It is never routed; only its `security` field is read.
|
|
780
|
+
const sharedSecretOp = (schemeName: string): OperationInfo => ({
|
|
781
|
+
operationId: "__mcp_mutation_guard__",
|
|
782
|
+
method: "post",
|
|
783
|
+
path: "/__mcp_mutation_guard__",
|
|
784
|
+
parameters: [],
|
|
785
|
+
requestBodyRequired: false,
|
|
786
|
+
responseSchemas: [],
|
|
787
|
+
security: [{ [schemeName]: [] }],
|
|
788
|
+
eject: false,
|
|
789
|
+
mcpExcluded: false,
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
// Decide whether a mutating tool call is authorized. Either an explicit runtime opt-in
|
|
793
|
+
// (`mcp.allowMutations` / `URBAN_MCP_ALLOW_MUTATIONS`) opens mutations credential-free — but only
|
|
794
|
+
// LOOPBACK-ONLY (`!allowRemote`), so exposing the surface to non-loopback callers can never
|
|
795
|
+
// silently drop the guard — OR the client presented the app's shared secret (the apiKey header
|
|
796
|
+
// scheme, via `x-nano-secret-env`). With no shared-secret scheme configured and no (in-scope)
|
|
797
|
+
// opt-in, mutations are refused (fail closed).
|
|
798
|
+
const NO_SHARED_SECRET_SCHEME_ERROR =
|
|
799
|
+
"mutating MCP tools require the app's shared secret (an apiKey header security scheme with x-nano-secret-env), or the explicit mcp.allowMutations opt-in (honoured loopback-only, i.e. when mcp.allowRemote is off)";
|
|
800
|
+
const authorizeMutation = async (
|
|
801
|
+
headers: Record<string, string | string[] | undefined>,
|
|
802
|
+
): Promise<SecurityDecision> => {
|
|
803
|
+
// The credential-free opt-in is loopback-only: with `allowRemote` on, a non-loopback caller
|
|
804
|
+
// could otherwise reach mutating `urban_debug_*` tools with no shared-secret guard at all, which
|
|
805
|
+
// contradicts the "trusted, purely local box" intent. When remote exposure is enabled we ignore
|
|
806
|
+
// the bypass and fall through to require the shared secret.
|
|
807
|
+
if (config.allowMutations && !config.allowRemote) return { ok: true };
|
|
808
|
+
const loaded = await loadSpecDoc();
|
|
809
|
+
if (!loaded.doc && loaded.failed) {
|
|
810
|
+
// A broken/unparseable spec is a server misconfiguration (500) — already logged by
|
|
811
|
+
// `loadSpecDoc` — not a missing credential (401): with no parsed spec we cannot even tell
|
|
812
|
+
// whether a shared-secret scheme exists to authorize against, so mirror `mountApi` and surface
|
|
813
|
+
// a 500 (the CallTool handler logs it) rather than a misleading 401.
|
|
814
|
+
return {
|
|
815
|
+
ok: false,
|
|
816
|
+
status: 500,
|
|
817
|
+
error:
|
|
818
|
+
"mutating MCP tools cannot be authorized: the app OpenAPI spec failed to load (server misconfiguration — see the logged 'mcp: failed to load the app OpenAPI spec' error)",
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
// A misconfigured spec — more than one candidate shared-secret scheme, so which one guards
|
|
822
|
+
// mutations would depend on authoring order — is a 500 (see `sharedSecretSchemeName`), not a
|
|
823
|
+
// missing/bad credential. Surface it like the other misconfiguration branches above.
|
|
824
|
+
let schemeName: string | undefined;
|
|
825
|
+
try {
|
|
826
|
+
schemeName = loaded.doc ? sharedSecretSchemeName(loaded.doc) : undefined;
|
|
827
|
+
} catch (e) {
|
|
828
|
+
return { ok: false, status: 500, error: errorMessage(e) };
|
|
829
|
+
}
|
|
830
|
+
if (!loaded.doc || !schemeName) {
|
|
831
|
+
return { ok: false, status: 401, error: NO_SHARED_SECRET_SCHEME_ERROR };
|
|
832
|
+
}
|
|
833
|
+
return evaluateSecurity(
|
|
834
|
+
loaded.doc,
|
|
835
|
+
sharedSecretOp(schemeName),
|
|
836
|
+
(name) => readHeader(headers, name),
|
|
837
|
+
() => undefined,
|
|
838
|
+
(envVar) => ctx.host.env(envVar),
|
|
839
|
+
);
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
const dispatchOperationTool = async (
|
|
843
|
+
op: OperationInfo,
|
|
844
|
+
args: Record<string, unknown>,
|
|
845
|
+
headers: Record<string, string | string[] | undefined>,
|
|
846
|
+
): Promise<{ content: { type: "text"; text: string }[]; isError?: boolean }> => {
|
|
847
|
+
const missing = missingPathParams(op, args);
|
|
848
|
+
if (missing.length > 0) {
|
|
849
|
+
// Surface as an error tool result (like a reused-validator failure), not a silent 404 from an
|
|
850
|
+
// empty-string substitution — the calling agent sees exactly which required params it omitted.
|
|
851
|
+
return textResult(
|
|
852
|
+
{ error: `validation failed: missing required path parameter(s): ${missing.join(", ")}` },
|
|
853
|
+
true,
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
// Forward the MCP connection's headers so the reused router enforces the operation's own
|
|
857
|
+
// OpenAPI `security` (a mutating — or secured read — app op is guarded exactly as its HTTP
|
|
858
|
+
// route). A `401`/`403`/`500` from that enforcement flows back as an error tool result.
|
|
859
|
+
const res = await apiRouter(toHttpRequest(op, args, headers));
|
|
860
|
+
const status = res.status ?? 200;
|
|
861
|
+
return textResult(res.body ?? "", status >= 400);
|
|
862
|
+
};
|
|
863
|
+
|
|
864
|
+
const readSystemBrief = async (): Promise<string> => {
|
|
865
|
+
const path = resolveAppPath(ctx.root, `${GENERATED_DIR}/${SYSTEM_BRIEF_JSON}`);
|
|
866
|
+
if (!(await ctx.host.exists(path))) {
|
|
867
|
+
throw new McpError(ErrorCode.InvalidParams, "system brief not generated — run `urban gen`");
|
|
868
|
+
}
|
|
869
|
+
return ctx.host.readTextFile(path);
|
|
870
|
+
};
|
|
871
|
+
|
|
872
|
+
const orientationText = (): string =>
|
|
873
|
+
[
|
|
874
|
+
`# ${ctx.manifest.name ?? "Urban app"} — MCP orientation`,
|
|
875
|
+
"",
|
|
876
|
+
"This endpoint is the app's runtime-served MCP surface (ADR 0067).",
|
|
877
|
+
"",
|
|
878
|
+
`- Read the \`${SYSTEM_BRIEF_URI}\` resource first for the app's system model (processes, service-task call graph, decisions, ownership).`,
|
|
879
|
+
"- App tools mirror the app's HTTP operations one-to-one: a tool call is equivalent to the corresponding `/app/api` HTTP call, validated identically and authorized exactly as that HTTP route by the operation's own OpenAPI `security` (an operation with no `security` is open — there is no extra MCP-level guard). An operation is exposed unless it opts out with the `x-mcp` extension (a security-relevant authoring switch).",
|
|
880
|
+
`- \`${DEBUG_PREFIX}*\` READ tools are framework-owned process-debugging tools: engine truth (process instances, wait states, element instances, user tasks, incidents) and the ADR 0065 projection stores (instance state, open user tasks). They are unauthenticated on loopback.`,
|
|
881
|
+
`- \`${DEBUG_PREFIX}*\` MUTATING tools (cancel_instance, resolve_incident, retry_job, set_variables) are framework-GUARDED: present the app's shared secret as its apiKey header on the MCP connection, or the operator must set the mcp.allowMutations opt-in (honoured loopback-only — when mcp.allowRemote is enabled, mutations always require the shared secret). A mutating call without the credential is refused. (App operation tools are NOT covered by this guard — they are authorized by their own OpenAPI \`security\`, above.)`,
|
|
882
|
+
].join("\n");
|
|
883
|
+
|
|
884
|
+
/** Build a fresh low-level MCP `Server` with this app's tools/resources/prompts wired. One is
|
|
885
|
+
* created per session and connected to that session's transport. */
|
|
886
|
+
const buildServer = (): Server => {
|
|
887
|
+
const server = new Server(
|
|
888
|
+
{ name: "urban", version: "1.0.0" },
|
|
889
|
+
{ capabilities: { tools: {}, resources: {}, prompts: {} } },
|
|
890
|
+
);
|
|
891
|
+
|
|
892
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
893
|
+
const ops = await loadProjectedOps();
|
|
894
|
+
const appTools = ops.map((op) => {
|
|
895
|
+
const mutating = isMutatingOperation(op);
|
|
896
|
+
return {
|
|
897
|
+
name: op.operationId,
|
|
898
|
+
description: op.summary ?? `${op.method.toUpperCase()} ${op.path}`,
|
|
899
|
+
inputSchema: toolInputSchema(op),
|
|
900
|
+
annotations: { readOnlyHint: !mutating, destructiveHint: mutating },
|
|
901
|
+
};
|
|
902
|
+
});
|
|
903
|
+
const frameworkTools = debugTools.map((t) => ({
|
|
904
|
+
name: t.name,
|
|
905
|
+
description: t.description,
|
|
906
|
+
inputSchema: t.inputSchema,
|
|
907
|
+
annotations: { readOnlyHint: !t.mutating, destructiveHint: t.mutating === true },
|
|
908
|
+
}));
|
|
909
|
+
return { tools: [...appTools, ...frameworkTools] };
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
913
|
+
const { name } = request.params;
|
|
914
|
+
const args = isRecord(request.params.arguments) ? request.params.arguments : {};
|
|
915
|
+
// The client presents its shared-secret credential as a header on the MCP connection; the SDK
|
|
916
|
+
// surfaces the request's headers here via `requestInfo`. Empty when a host cannot supply them
|
|
917
|
+
// (then a mutating call falls back to refuse unless the runtime opt-in is set).
|
|
918
|
+
const headers = extra.requestInfo?.headers ?? {};
|
|
919
|
+
const debug = debugByName.get(name);
|
|
920
|
+
if (debug) {
|
|
921
|
+
if (debug.mutating) {
|
|
922
|
+
const authz = await authorizeMutation(headers);
|
|
923
|
+
if (!authz.ok) {
|
|
924
|
+
const status = authz.status ?? 401;
|
|
925
|
+
// Mirror `mountApi`'s security handling: a `500` refusal means the shared-secret scheme
|
|
926
|
+
// is misconfigured (unknown/unsupported scheme, or an unset secret env var) rather than a
|
|
927
|
+
// bad/absent credential. Log it — as `mountApi` does for its OpenAPI security — so an
|
|
928
|
+
// operator can diagnose the misconfiguration, and carry the status in the error payload
|
|
929
|
+
// so a client can distinguish a `500` misconfig from a `401` unauthorized.
|
|
930
|
+
if (status === 500) {
|
|
931
|
+
ctx.host.log("error", "mcp mutating tool security is misconfigured (ADR 0059)", {
|
|
932
|
+
tool: name,
|
|
933
|
+
reason: authz.error,
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
return textResult({ error: authz.error ?? "unauthorized", status }, true);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
const missing = missingRequiredArgs(debug.inputSchema, args);
|
|
940
|
+
if (missing.length > 0) {
|
|
941
|
+
// Fail fast like an operation-tool validation error, instead of substituting an empty
|
|
942
|
+
// string that makes an invalid call look like a legitimate "not found" result.
|
|
943
|
+
return textResult(
|
|
944
|
+
{ error: `validation failed: missing required parameter(s): ${missing.join(", ")}` },
|
|
945
|
+
true,
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
try {
|
|
949
|
+
return textResult(await debug.run(args));
|
|
950
|
+
} catch (e) {
|
|
951
|
+
return textResult({ error: errorMessage(e) }, true);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
const ops = await loadProjectedOps();
|
|
955
|
+
const op = ops.find((o) => o.operationId === name);
|
|
956
|
+
if (!op) {
|
|
957
|
+
throw new McpError(ErrorCode.MethodNotFound, `no such tool: ${name}`);
|
|
958
|
+
}
|
|
959
|
+
// App operation tools inherit their OpenAPI `security` — the forwarded headers let the reused
|
|
960
|
+
// router enforce it, so a mutating (or secured read) app op is guarded exactly as its HTTP route.
|
|
961
|
+
return dispatchOperationTool(op, args, headers);
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
|
965
|
+
resources: [
|
|
966
|
+
{
|
|
967
|
+
uri: SYSTEM_BRIEF_URI,
|
|
968
|
+
name: "system-brief",
|
|
969
|
+
title: "App system brief",
|
|
970
|
+
description: "The app's derived institutional-memory brief (the /app/agent.json content).",
|
|
971
|
+
mimeType: "application/json",
|
|
972
|
+
},
|
|
973
|
+
],
|
|
974
|
+
}));
|
|
975
|
+
|
|
976
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
977
|
+
const { uri } = request.params;
|
|
978
|
+
if (uri !== SYSTEM_BRIEF_URI) {
|
|
979
|
+
throw new McpError(ErrorCode.InvalidParams, `no such resource: ${uri}`);
|
|
980
|
+
}
|
|
981
|
+
const text = await readSystemBrief();
|
|
982
|
+
return { contents: [{ uri, mimeType: "application/json", text }] };
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
server.setRequestHandler(ListPromptsRequestSchema, async () => ({
|
|
986
|
+
prompts: [
|
|
987
|
+
{
|
|
988
|
+
name: ORIENTATION_PROMPT,
|
|
989
|
+
title: "Orient on this app",
|
|
990
|
+
description: "How to use this app's MCP surface: its read tools, debug tools, and system-brief resource.",
|
|
991
|
+
arguments: [],
|
|
992
|
+
},
|
|
993
|
+
],
|
|
994
|
+
}));
|
|
995
|
+
|
|
996
|
+
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
|
|
997
|
+
if (request.params.name !== ORIENTATION_PROMPT) {
|
|
998
|
+
throw new McpError(ErrorCode.InvalidParams, `no such prompt: ${request.params.name}`);
|
|
999
|
+
}
|
|
1000
|
+
return {
|
|
1001
|
+
description: "Orientation for working against this app over MCP.",
|
|
1002
|
+
messages: [{ role: "user", content: { type: "text", text: orientationText() } }],
|
|
1003
|
+
};
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
return server;
|
|
1007
|
+
};
|
|
1008
|
+
|
|
1009
|
+
interface Session {
|
|
1010
|
+
readonly server: Server;
|
|
1011
|
+
readonly transport: WebStandardStreamableHTTPServerTransport;
|
|
1012
|
+
}
|
|
1013
|
+
const sessions = new Map<string, Session>();
|
|
1014
|
+
|
|
1015
|
+
const createSession = async (): Promise<Session> => {
|
|
1016
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
1017
|
+
sessionIdGenerator: () => newSessionId(),
|
|
1018
|
+
enableJsonResponse: true,
|
|
1019
|
+
onsessionclosed: (id) => {
|
|
1020
|
+
sessions.delete(id);
|
|
1021
|
+
},
|
|
1022
|
+
});
|
|
1023
|
+
transport.onclose = () => {
|
|
1024
|
+
if (transport.sessionId) sessions.delete(transport.sessionId);
|
|
1025
|
+
};
|
|
1026
|
+
const server = buildServer();
|
|
1027
|
+
await server.connect(transport);
|
|
1028
|
+
return { server, transport };
|
|
1029
|
+
};
|
|
1030
|
+
|
|
1031
|
+
/** Convert the host request into the Web-Standard `Request` the SDK transport consumes. The body
|
|
1032
|
+
* is passed separately (as `parsedBody`) so the transport never re-reads it; the Request carries
|
|
1033
|
+
* the method + headers (Accept, content-type, `mcp-session-id`, protocol version) it inspects. */
|
|
1034
|
+
const toWebRequest = (req: HttpRequest): Request => {
|
|
1035
|
+
const search = req.query.toString();
|
|
1036
|
+
const url = `http://localhost${req.path}${search ? `?${search}` : ""}`;
|
|
1037
|
+
const headers = new Headers();
|
|
1038
|
+
for (const [key, value] of req.headers) headers.set(key, value);
|
|
1039
|
+
return new Request(url, { method: req.method, headers });
|
|
1040
|
+
};
|
|
1041
|
+
|
|
1042
|
+
const toHttpResponse = async (res: Response): Promise<HttpResponse> => {
|
|
1043
|
+
const headers: Record<string, string> = {};
|
|
1044
|
+
for (const [key, value] of res.headers) headers[key] = value;
|
|
1045
|
+
return { status: res.status, headers, body: await res.text() };
|
|
1046
|
+
};
|
|
1047
|
+
|
|
1048
|
+
const methodNotAllowed = (): HttpResponse => ({
|
|
1049
|
+
status: 405,
|
|
1050
|
+
headers: { allow: "POST, DELETE", "content-type": "application/json" },
|
|
1051
|
+
body: JSON.stringify({
|
|
1052
|
+
jsonrpc: "2.0",
|
|
1053
|
+
error: { code: -32000, message: "Method Not Allowed: the MCP endpoint accepts POST (and DELETE to end a session)." },
|
|
1054
|
+
id: null,
|
|
1055
|
+
}),
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
const handle = async (req: HttpRequest): Promise<HttpResponse> => {
|
|
1059
|
+
if (!config.enabled) return json({ error: "MCP surface is disabled" }, 404);
|
|
1060
|
+
if (!config.allowRemote) {
|
|
1061
|
+
// Bound to all interfaces, `Host` can't be trusted as a loopback proof: refuse everyone
|
|
1062
|
+
// unless remote access is explicitly opted in (mcp.allowRemote / URBAN_MCP_ALLOW_REMOTE).
|
|
1063
|
+
if (bindMode === "all") {
|
|
1064
|
+
return json(
|
|
1065
|
+
{ error: "MCP surface is loopback-only, but the app is bound to all interfaces; set mcp.allowRemote to expose it" },
|
|
1066
|
+
403,
|
|
1067
|
+
);
|
|
1068
|
+
}
|
|
1069
|
+
if (!isLoopbackRequest(req)) {
|
|
1070
|
+
return json({ error: "MCP surface is loopback-only" }, 403);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
const method = req.method.toUpperCase();
|
|
1074
|
+
// The string-body host seam cannot hold open an SSE stream, so the optional GET server→client
|
|
1075
|
+
// stream is declined with 405 (spec-compliant): clients fall back to POST request/response.
|
|
1076
|
+
if (method !== "POST" && method !== "DELETE") return methodNotAllowed();
|
|
1077
|
+
|
|
1078
|
+
const rawBody = method === "POST" ? await req.text() : "";
|
|
1079
|
+
let parsedBody: unknown;
|
|
1080
|
+
if (rawBody) {
|
|
1081
|
+
try {
|
|
1082
|
+
parsedBody = JSON.parse(rawBody);
|
|
1083
|
+
} catch {
|
|
1084
|
+
return json(
|
|
1085
|
+
{ jsonrpc: "2.0", error: { code: ErrorCode.ParseError, message: "request body is not valid JSON" }, id: null },
|
|
1086
|
+
400,
|
|
1087
|
+
);
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
const sessionId = req.headers.get(SESSION_HEADER) ?? undefined;
|
|
1092
|
+
let session = sessionId ? sessions.get(sessionId) : undefined;
|
|
1093
|
+
// Refresh an existing session's recency so an actively-used session is never the LRU victim.
|
|
1094
|
+
if (session && sessionId) touchSession(sessions, sessionId, session);
|
|
1095
|
+
if (!session) {
|
|
1096
|
+
const messages = Array.isArray(parsedBody) ? parsedBody : [parsedBody];
|
|
1097
|
+
if (!messages.some((m) => isInitializeRequest(m))) {
|
|
1098
|
+
return json(
|
|
1099
|
+
{
|
|
1100
|
+
jsonrpc: "2.0",
|
|
1101
|
+
error: { code: -32000, message: "Bad Request: no valid session id, and not an initialize request." },
|
|
1102
|
+
id: null,
|
|
1103
|
+
},
|
|
1104
|
+
400,
|
|
1105
|
+
);
|
|
1106
|
+
}
|
|
1107
|
+
session = await createSession();
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
const webResponse = await session.transport.handleRequest(toWebRequest(req), { parsedBody });
|
|
1111
|
+
// A newly-initialized session assigns its id during handleRequest — register it so the client's
|
|
1112
|
+
// follow-up requests (carrying `mcp-session-id`) resolve back to this same server/transport.
|
|
1113
|
+
const assignedId = session.transport.sessionId;
|
|
1114
|
+
if (assignedId && !sessions.has(assignedId)) {
|
|
1115
|
+
sessions.set(assignedId, session);
|
|
1116
|
+
// Enforce the hard cap once the freshly-initialized session is registered, evicting the
|
|
1117
|
+
// least-recently-used pairs (never this one — it is now the newest) so the map stays bounded.
|
|
1118
|
+
await evictExcessSessions(sessions, MAX_SESSIONS, (s) => s.server.close());
|
|
1119
|
+
}
|
|
1120
|
+
return toHttpResponse(webResponse);
|
|
1121
|
+
};
|
|
1122
|
+
|
|
1123
|
+
const routes: Route[] = [
|
|
1124
|
+
{ method: "*", path: MCP_PATH, source: "surface:mcp", handler: handle },
|
|
1125
|
+
];
|
|
1126
|
+
ctx.host.log("info", "mcp surface mounted", {
|
|
1127
|
+
path: MCP_PATH,
|
|
1128
|
+
enabled: config.enabled,
|
|
1129
|
+
loopbackOnly: !config.allowRemote,
|
|
1130
|
+
});
|
|
1131
|
+
return {
|
|
1132
|
+
name: "mcp",
|
|
1133
|
+
routes,
|
|
1134
|
+
enabled: config.enabled,
|
|
1135
|
+
describe: () => ({
|
|
1136
|
+
mcp: {
|
|
1137
|
+
path: MCP_PATH,
|
|
1138
|
+
enabled: config.enabled,
|
|
1139
|
+
loopbackOnly: !config.allowRemote,
|
|
1140
|
+
allowMutations: config.allowMutations,
|
|
1141
|
+
},
|
|
1142
|
+
}),
|
|
1143
|
+
};
|
|
1144
|
+
}
|