@questpie/mcp 3.16.0 → 3.18.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/README.md +150 -6
- package/dist/create-server-CEWFf041.mjs +2394 -0
- package/dist/{index-Bt_uk798.d.mts → index-DlBFWTIg.d.mts} +28 -23
- package/dist/index.d.mts +91 -47
- package/dist/index.mjs +92 -5
- package/dist/mcp-CuJEhynp.mjs +350 -0
- package/dist/modules/mcp.d.mts +1 -1
- package/dist/modules/mcp.mjs +1 -1
- package/dist/{plugin-BJ6gELRj.mjs → plugin-BZQe1pji.mjs} +1 -1
- package/dist/plugin.mjs +1 -1
- package/dist/{stdio-qyQFVEyU.mjs → stdio-CgIPiPUb.mjs} +1 -1
- package/dist/stdio-YoM70-42.d.mts +323 -0
- package/dist/stdio.d.mts +1 -1
- package/dist/stdio.mjs +1 -1
- package/package.json +5 -4
- package/dist/create-server-DIvtVlma.mjs +0 -1014
- package/dist/mcp-snyVygDQ.mjs +0 -168
- package/dist/stdio-t_9rKayH.d.mts +0 -154
|
@@ -0,0 +1,2394 @@
|
|
|
1
|
+
import { createAdapterContext, createContextFactory, defaultOperationScope, evaluateRouteAccess, executeJsonRoute, extractAppServices, introspectCollection, introspectGlobal, introspectRoutes, isJsonRoute, umbrellaScopeFor } from "questpie";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { types } from "node:util";
|
|
4
|
+
import { snapshotBoundedBrokerValue } from "questpie/executor";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
|
+
import { ListToolsRequestSchema, ToolSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
8
|
+
|
|
9
|
+
//#region src/server/mcp-tool.ts
|
|
10
|
+
var McpToolBuilder = class {
|
|
11
|
+
constructor(name, config) {
|
|
12
|
+
this.name = name;
|
|
13
|
+
this.config = config;
|
|
14
|
+
}
|
|
15
|
+
handler(handler) {
|
|
16
|
+
return Object.freeze({
|
|
17
|
+
__brand: "mcpTool",
|
|
18
|
+
name: this.name,
|
|
19
|
+
config: this.config,
|
|
20
|
+
handler
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Define a custom MCP tool, contributed by a module the same way collections and
|
|
26
|
+
* routes are (via `ModuleDefinition.mcpTools`). `mcpTool(name, config).handler(fn)`
|
|
27
|
+
* returns a frozen, branded {@link McpToolDefinition}; the CRUD/route tools the
|
|
28
|
+
* framework auto-generates are the same shape.
|
|
29
|
+
*
|
|
30
|
+
* `config` mirrors the MCP tool contract: `inputSchema`/`outputSchema` (Zod),
|
|
31
|
+
* `title`/`description`/`annotations` for discovery, plus the two QUESTPIE
|
|
32
|
+
* authorization hooks that compose exactly like the built-in tools:
|
|
33
|
+
* - `access` — required explicit opt-in and an {@link McpAccessRule} run against the caller's transport /
|
|
34
|
+
* accessMode / session (and, for an OAuth caller, its scopes). Same RBAC-style
|
|
35
|
+
* gate the CRUD tools use.
|
|
36
|
+
* - `scopes` — the OAuth scopes an `oauth` caller must hold ({@link McpToolConfig.scopes}).
|
|
37
|
+
* Custom tools have no default scope mapping (unlike CRUD, there is no
|
|
38
|
+
* resource/operation to derive one from), so it is required; use `false` for
|
|
39
|
+
* an explicit no-OAuth-scope policy. The shipped scope gate ({@link scopeGateAllows}) enforces this at both
|
|
40
|
+
* `tools/list` (hidden) and `tools/call` (denied); `system`/`user` callers
|
|
41
|
+
* carry no scopes and skip it, so the effective bound is always `scopes ∩ RBAC`.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```ts
|
|
45
|
+
* export const ping = mcpTool("ops.ping", {
|
|
46
|
+
* access: ({ session }) => !!session,
|
|
47
|
+
* description: "Health probe",
|
|
48
|
+
* inputSchema: z.object({}),
|
|
49
|
+
* scopes: "routes:ops/ping:invoke", // only OAuth callers holding this scope
|
|
50
|
+
* }).handler(async () => ({ content: [{ type: "text", text: "pong" }] }));
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
function mcpTool(name, config) {
|
|
54
|
+
return new McpToolBuilder(name, config);
|
|
55
|
+
}
|
|
56
|
+
function isMcpTool(value) {
|
|
57
|
+
return !!value && typeof value === "object" && value["__brand"] === "mcpTool" && typeof value.handler === "function";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
//#endregion
|
|
61
|
+
//#region src/server/principal.ts
|
|
62
|
+
const hasOwnData = (descriptors, key) => {
|
|
63
|
+
const descriptor = descriptors[key];
|
|
64
|
+
return !!descriptor && "value" in descriptor;
|
|
65
|
+
};
|
|
66
|
+
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
67
|
+
const nonEmptyString = (value) => typeof value === "string" && value.length > 0;
|
|
68
|
+
const isString = (value) => typeof value === "string";
|
|
69
|
+
function hasOwnString(value, key) {
|
|
70
|
+
try {
|
|
71
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
72
|
+
return !!descriptor && "value" in descriptor && nonEmptyString(descriptor.value);
|
|
73
|
+
} catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Validate the complete MCP-facing principal discriminant without invoking
|
|
79
|
+
* getters or reading inherited properties.
|
|
80
|
+
*/
|
|
81
|
+
function validateMcpPrincipal(value) {
|
|
82
|
+
if (!isObject(value)) return null;
|
|
83
|
+
try {
|
|
84
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
85
|
+
if (!hasOwnData(descriptors, "kind")) return null;
|
|
86
|
+
if (descriptors.kind.value === "system") return Object.keys(descriptors).length === 1 ? { kind: "system" } : null;
|
|
87
|
+
if (descriptors.kind.value === "user") {
|
|
88
|
+
if (Object.keys(descriptors).some((key) => ![
|
|
89
|
+
"kind",
|
|
90
|
+
"user",
|
|
91
|
+
"session"
|
|
92
|
+
].includes(key)) || !hasOwnData(descriptors, "user") || !isObject(descriptors.user.value) || !hasOwnString(descriptors.user.value, "id") || !hasOwnData(descriptors, "session") || !isObject(descriptors.session.value) || !hasOwnString(descriptors.session.value, "id")) return null;
|
|
93
|
+
return { kind: "user" };
|
|
94
|
+
}
|
|
95
|
+
if (descriptors.kind.value === "oauth") {
|
|
96
|
+
if (Object.keys(descriptors).some((key) => ![
|
|
97
|
+
"kind",
|
|
98
|
+
"user",
|
|
99
|
+
"clientId",
|
|
100
|
+
"scopes",
|
|
101
|
+
"tokenId"
|
|
102
|
+
].includes(key)) || !hasOwnData(descriptors, "user") || !isObject(descriptors.user.value) || !hasOwnString(descriptors.user.value, "id") || !hasOwnData(descriptors, "clientId") || !nonEmptyString(descriptors.clientId.value) || !hasOwnData(descriptors, "tokenId") || !isString(descriptors.tokenId.value) || !hasOwnData(descriptors, "scopes") || !Array.isArray(descriptors.scopes.value) || !descriptors.scopes.value.every(nonEmptyString)) return null;
|
|
103
|
+
return {
|
|
104
|
+
kind: "oauth",
|
|
105
|
+
scopes: [...descriptors.scopes.value]
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
//#endregion
|
|
115
|
+
//#region src/server/policy.ts
|
|
116
|
+
const DEFAULT_MCP_CONFIG = {
|
|
117
|
+
crud: {
|
|
118
|
+
maxLimit: 100,
|
|
119
|
+
collections: {},
|
|
120
|
+
globals: {}
|
|
121
|
+
},
|
|
122
|
+
routes: { routes: {} },
|
|
123
|
+
resources: {},
|
|
124
|
+
http: {
|
|
125
|
+
accessMode: "user",
|
|
126
|
+
enableJsonResponse: true
|
|
127
|
+
},
|
|
128
|
+
stdio: { trustedMaintenance: false },
|
|
129
|
+
execution: {}
|
|
130
|
+
};
|
|
131
|
+
function isPlainObject(value) {
|
|
132
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
133
|
+
}
|
|
134
|
+
const UNSAFE_CONFIG_KEYS = new Set([
|
|
135
|
+
"__proto__",
|
|
136
|
+
"prototype",
|
|
137
|
+
"constructor"
|
|
138
|
+
]);
|
|
139
|
+
function ownDataEntries(value) {
|
|
140
|
+
if (!isPlainObject(value)) return [];
|
|
141
|
+
try {
|
|
142
|
+
return Object.entries(Object.getOwnPropertyDescriptors(value)).filter(([key, descriptor]) => !UNSAFE_CONFIG_KEYS.has(key) && "value" in descriptor).map(([key, descriptor]) => [key, descriptor.value]);
|
|
143
|
+
} catch {
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function safeCloneConfigValue(value) {
|
|
148
|
+
if (Array.isArray(value)) return value.map((entry) => safeCloneConfigValue(entry));
|
|
149
|
+
if (!isPlainObject(value)) return value;
|
|
150
|
+
const clone = Object.create(null);
|
|
151
|
+
for (const [key, entry] of ownDataEntries(value)) clone[key] = safeCloneConfigValue(entry);
|
|
152
|
+
return clone;
|
|
153
|
+
}
|
|
154
|
+
function mergeDeep(base, override) {
|
|
155
|
+
const out = safeCloneConfigValue(base);
|
|
156
|
+
for (const [key, value] of ownDataEntries(override)) if (isPlainObject(value) && isPlainObject(out[key])) out[key] = mergeDeep(out[key], value);
|
|
157
|
+
else if (value !== void 0) out[key] = safeCloneConfigValue(value);
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
function ownValue(record, key) {
|
|
161
|
+
if (!record || !Object.prototype.hasOwnProperty.call(record, key)) return;
|
|
162
|
+
return record[key];
|
|
163
|
+
}
|
|
164
|
+
function replaceOwnEntries(base, override) {
|
|
165
|
+
const next = safeCloneConfigValue(base ?? {});
|
|
166
|
+
for (const [key, value] of ownDataEntries(override)) next[key] = safeCloneConfigValue(value);
|
|
167
|
+
return next;
|
|
168
|
+
}
|
|
169
|
+
function resolveMcpConfig(app, override) {
|
|
170
|
+
const appConfig = app.state?.config?.mcp ?? {};
|
|
171
|
+
const safeOverride = safeCloneConfigValue(override);
|
|
172
|
+
const resolved = mergeDeep(mergeDeep(DEFAULT_MCP_CONFIG, appConfig), safeOverride);
|
|
173
|
+
if (safeOverride?.crud?.collections) resolved.crud = {
|
|
174
|
+
...resolved.crud,
|
|
175
|
+
collections: replaceOwnEntries(resolved.crud?.collections, safeOverride.crud.collections)
|
|
176
|
+
};
|
|
177
|
+
if (safeOverride?.crud?.globals) resolved.crud = {
|
|
178
|
+
...resolved.crud,
|
|
179
|
+
globals: replaceOwnEntries(resolved.crud?.globals, safeOverride.crud.globals)
|
|
180
|
+
};
|
|
181
|
+
if (safeOverride?.routes?.routes) resolved.routes = {
|
|
182
|
+
...resolved.routes,
|
|
183
|
+
routes: replaceOwnEntries(resolved.routes?.routes, safeOverride.routes.routes)
|
|
184
|
+
};
|
|
185
|
+
return resolved;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Resolve workload authority maps atomically. A caller-provided map replaces
|
|
189
|
+
* the corresponding app map instead of inheriting entries or nested authority.
|
|
190
|
+
*/
|
|
191
|
+
function resolveWorkloadMcpConfig(app, override) {
|
|
192
|
+
const resolved = resolveMcpConfig(app, override);
|
|
193
|
+
if (!override) return resolved;
|
|
194
|
+
const safeOverride = safeCloneConfigValue(override);
|
|
195
|
+
if (Object.prototype.hasOwnProperty.call(safeOverride.crud ?? {}, "collections")) resolved.crud = {
|
|
196
|
+
...resolved.crud,
|
|
197
|
+
collections: safeCloneConfigValue(safeOverride.crud?.collections ?? {})
|
|
198
|
+
};
|
|
199
|
+
if (Object.prototype.hasOwnProperty.call(safeOverride.crud ?? {}, "globals")) resolved.crud = {
|
|
200
|
+
...resolved.crud,
|
|
201
|
+
globals: safeCloneConfigValue(safeOverride.crud?.globals ?? {})
|
|
202
|
+
};
|
|
203
|
+
if (Object.prototype.hasOwnProperty.call(safeOverride.routes ?? {}, "routes")) resolved.routes = {
|
|
204
|
+
...resolved.routes,
|
|
205
|
+
routes: safeCloneConfigValue(safeOverride.routes?.routes ?? {})
|
|
206
|
+
};
|
|
207
|
+
for (const kind of [
|
|
208
|
+
"collections",
|
|
209
|
+
"globals",
|
|
210
|
+
"routes"
|
|
211
|
+
]) if (Object.prototype.hasOwnProperty.call(safeOverride.resources ?? {}, kind)) resolved.resources = {
|
|
212
|
+
...resolved.resources,
|
|
213
|
+
[kind]: safeCloneConfigValue(safeOverride.resources?.[kind] ?? {})
|
|
214
|
+
};
|
|
215
|
+
return resolved;
|
|
216
|
+
}
|
|
217
|
+
function resolveEntityPolicy(config, kind, name, _transport = "http") {
|
|
218
|
+
const crud = ownValue(config, "crud");
|
|
219
|
+
const routes = ownValue(config, "routes");
|
|
220
|
+
if (kind === "route") return normalizePolicy({ expose: false }, ownValue(ownValue(routes, "routes"), name));
|
|
221
|
+
if (kind === "global") return normalizePolicy({ expose: false }, ownValue(ownValue(crud, "globals"), name));
|
|
222
|
+
return normalizePolicy({ expose: false }, ownValue(ownValue(crud, "collections"), name));
|
|
223
|
+
}
|
|
224
|
+
function normalizePolicy(defaults, override) {
|
|
225
|
+
override = safeCloneConfigValue(override);
|
|
226
|
+
if (override === false) return {
|
|
227
|
+
expose: false,
|
|
228
|
+
operations: {},
|
|
229
|
+
operationScopes: {},
|
|
230
|
+
operationWorkloads: {}
|
|
231
|
+
};
|
|
232
|
+
const base = {
|
|
233
|
+
expose: defaults.expose ?? true,
|
|
234
|
+
operations: { ...defaults.operations ?? {} },
|
|
235
|
+
requiredScopes: defaults.requiredScopes,
|
|
236
|
+
operationScopes: { ...defaults.operationScopes ?? {} },
|
|
237
|
+
workload: defaults.workload,
|
|
238
|
+
operationWorkloads: { ...defaults.operationWorkloads ?? {} },
|
|
239
|
+
fields: defaults.fields,
|
|
240
|
+
description: defaults.description
|
|
241
|
+
};
|
|
242
|
+
if (override === void 0) return base;
|
|
243
|
+
if (override === true) return {
|
|
244
|
+
...base,
|
|
245
|
+
expose: true
|
|
246
|
+
};
|
|
247
|
+
return {
|
|
248
|
+
...base,
|
|
249
|
+
...override,
|
|
250
|
+
expose: override.expose ?? true,
|
|
251
|
+
operations: {
|
|
252
|
+
...base.operations,
|
|
253
|
+
...override.operations ?? {}
|
|
254
|
+
},
|
|
255
|
+
requiredScopes: override.requiredScopes ?? base.requiredScopes,
|
|
256
|
+
operationScopes: {
|
|
257
|
+
...base.operationScopes,
|
|
258
|
+
...override.operationScopes ?? {}
|
|
259
|
+
},
|
|
260
|
+
workload: override.workload ?? base.workload,
|
|
261
|
+
operationWorkloads: {
|
|
262
|
+
...base.operationWorkloads,
|
|
263
|
+
...override.operationWorkloads ?? {}
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
function workloadRequirementForOperation(policy, operation) {
|
|
268
|
+
return policy.operationWorkloads[operation] ?? policy.workload;
|
|
269
|
+
}
|
|
270
|
+
function operationRule(policy, operation) {
|
|
271
|
+
return policy.operations[operation];
|
|
272
|
+
}
|
|
273
|
+
/** Normalize an {@link McpRequiredScopes} declaration to a scope list. */
|
|
274
|
+
function normalizeRequiredScopes(required) {
|
|
275
|
+
if (required === void 0 || required === false) return [];
|
|
276
|
+
return typeof required === "string" ? [required] : [...required];
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Resolve the scopes an OAuth caller must hold for a given entity operation.
|
|
280
|
+
*
|
|
281
|
+
* Precedence (most specific wins), each level is declarative data:
|
|
282
|
+
* 1. `policy.operationScopes[operationName]` — per-operation override.
|
|
283
|
+
* 2. `policy.requiredScopes` — entity-level requirement (all operations).
|
|
284
|
+
* 3. the default `<resource>:<name>:<verb>` mapping derived from the kind.
|
|
285
|
+
*
|
|
286
|
+
* A level set to `false` explicitly requires no scope (stops the fallback).
|
|
287
|
+
* Returns a (possibly empty) scope list; empty means "no scope required". This
|
|
288
|
+
* resolves the requirement; the deny/register decision that consumes it is
|
|
289
|
+
* {@link scopeGateAllows}.
|
|
290
|
+
*/
|
|
291
|
+
function requiredScopesForOperation(policy, kind, name, operationName, operationKind) {
|
|
292
|
+
const perOperation = policy.operationScopes[operationName];
|
|
293
|
+
if (perOperation !== void 0) return normalizeRequiredScopes(perOperation);
|
|
294
|
+
if (policy.requiredScopes !== void 0) return normalizeRequiredScopes(policy.requiredScopes);
|
|
295
|
+
return [defaultOperationScope(kind, name, operationKind)];
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* The consented scopes carried by the request, or `undefined` when the caller
|
|
299
|
+
* is not OAuth-authenticated. Reads `ctx.principal` (populated by MO6): only the
|
|
300
|
+
* `oauth` principal carries `scopes`; `user` and `system` do not.
|
|
301
|
+
*/
|
|
302
|
+
function scopesFromContext(ctx) {
|
|
303
|
+
const principal = ctx.principal;
|
|
304
|
+
if (principal === void 0 || principal === null) return void 0;
|
|
305
|
+
const validated = validateMcpPrincipal(principal);
|
|
306
|
+
if (!validated) return null;
|
|
307
|
+
return validated.kind === "oauth" ? validated.scopes : void 0;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* The OAuth scope gate (MO8, extended by the umbrella model — LOCKED #2).
|
|
311
|
+
*
|
|
312
|
+
* Given the scopes the caller holds (from {@link scopesFromContext}) and the
|
|
313
|
+
* scopes an operation requires (from {@link requiredScopesForOperation} or a
|
|
314
|
+
* custom tool's `config.scopes`), decide whether the scope gate passes.
|
|
315
|
+
*
|
|
316
|
+
* Semantics:
|
|
317
|
+
* - `heldScopes === undefined` → the caller is not OAuth-authenticated (`user`,
|
|
318
|
+
* `system`, or unauthenticated). The scope gate does not apply and PASSES;
|
|
319
|
+
* authorization is left entirely to RBAC / MCP rules. This is what keeps the
|
|
320
|
+
* first-party admin (`user`) and stdio (`system`) paths unchanged.
|
|
321
|
+
* - Otherwise the caller is an `oauth` principal: the gate PASSES iff EVERY
|
|
322
|
+
* required scope is satisfied — a required scope is satisfied when the caller
|
|
323
|
+
* holds it directly OR holds its applicable coarse umbrella
|
|
324
|
+
* ({@link umbrellaScopeFor}). An empty `required` needs nothing, so it passes
|
|
325
|
+
* for everyone.
|
|
326
|
+
*
|
|
327
|
+
* The umbrella widening is deliberately narrow — it can only satisfy a
|
|
328
|
+
* granular `<resource>:<name>:read|write` with the matching `<resource>:read`
|
|
329
|
+
* or `<resource>:write` umbrella. `read` and `write` are independent (holding
|
|
330
|
+
* `collections:write` never satisfies a `<name>:read` requirement, and vice
|
|
331
|
+
* versa), and there is NO umbrella for `:delete` or `routes:…:invoke`. So the
|
|
332
|
+
* umbrella grants convenience without ever over-granting across verb, resource
|
|
333
|
+
* kind, or into delete/invoke.
|
|
334
|
+
*
|
|
335
|
+
* This gate is **additive** — it can only ever REMOVE access from an `oauth`
|
|
336
|
+
* caller. It never grants access on its own: callers still run the MCP rule and
|
|
337
|
+
* the collection/global `.access()` RBAC independently, and all must pass. The
|
|
338
|
+
* effective `oauth` permission is therefore `scopes ∩ RBAC`.
|
|
339
|
+
*/
|
|
340
|
+
function scopeGateAllows(heldScopes, required) {
|
|
341
|
+
if (heldScopes === null) return false;
|
|
342
|
+
if (heldScopes === void 0) return true;
|
|
343
|
+
if (required.length === 0) return true;
|
|
344
|
+
const held = new Set(heldScopes);
|
|
345
|
+
return required.every((scope) => {
|
|
346
|
+
if (held.has(scope)) return true;
|
|
347
|
+
const umbrella = umbrellaScopeFor(scope);
|
|
348
|
+
return umbrella !== void 0 && held.has(umbrella);
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
async function evaluateMcpRule(rule, options) {
|
|
352
|
+
const scopes = scopesFromContext(options.ctx);
|
|
353
|
+
if (scopes === null) return false;
|
|
354
|
+
if (rule === void 0) return true;
|
|
355
|
+
if (typeof rule === "boolean") return rule;
|
|
356
|
+
return rule({
|
|
357
|
+
transport: options.transport,
|
|
358
|
+
accessMode: options.accessMode,
|
|
359
|
+
session: options.ctx.session,
|
|
360
|
+
scopes,
|
|
361
|
+
ctx: options.ctx
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
//#endregion
|
|
366
|
+
//#region src/server/catalog.ts
|
|
367
|
+
const COLLECTION_OPERATION_KINDS = {
|
|
368
|
+
list: "read",
|
|
369
|
+
count: "read",
|
|
370
|
+
get: "read",
|
|
371
|
+
create: "write",
|
|
372
|
+
update: "write",
|
|
373
|
+
delete: "delete"
|
|
374
|
+
};
|
|
375
|
+
const GLOBAL_OPERATION_KINDS = {
|
|
376
|
+
get: "read",
|
|
377
|
+
update: "write"
|
|
378
|
+
};
|
|
379
|
+
const sanitizeRouteKey = (key) => key.replace(/[:/[\].]+/g, ".").replace(/\.+/g, ".").replace(/^\./, "").replace(/\.$/, "");
|
|
380
|
+
const operationIsExplicitlyEnabled = (policy, operation) => {
|
|
381
|
+
const rule = operationRule(policy, operation);
|
|
382
|
+
return rule !== void 0 && rule !== false;
|
|
383
|
+
};
|
|
384
|
+
const addRequiredScopes = (target, required) => {
|
|
385
|
+
if (required === void 0 || required === false) return;
|
|
386
|
+
const scopes = typeof required === "string" ? [required] : required;
|
|
387
|
+
for (const scope of scopes) if (!target.includes(scope)) target.push(scope);
|
|
388
|
+
};
|
|
389
|
+
const hasOwn$1 = (record, key) => !!record && Object.prototype.hasOwnProperty.call(record, key);
|
|
390
|
+
function ownSection(record, key) {
|
|
391
|
+
return hasOwn$1(record, key) ? record[key] : void 0;
|
|
392
|
+
}
|
|
393
|
+
function snapshotWorkload(requirement) {
|
|
394
|
+
if (!requirement) return void 0;
|
|
395
|
+
return Object.freeze({
|
|
396
|
+
capabilities: Object.freeze([...requirement.capabilities]),
|
|
397
|
+
...requirement.handoff ? { handoff: requirement.handoff } : {}
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
function snapshotPolicy(policy) {
|
|
401
|
+
const operations = Object.create(null);
|
|
402
|
+
for (const [name, rule] of Object.entries(policy.operations)) operations[name] = rule;
|
|
403
|
+
const operationScopes = Object.create(null);
|
|
404
|
+
for (const [name, scopes] of Object.entries(policy.operationScopes)) operationScopes[name] = Array.isArray(scopes) ? Object.freeze([...scopes]) : scopes;
|
|
405
|
+
const operationWorkloads = Object.create(null);
|
|
406
|
+
for (const [name, workload] of Object.entries(policy.operationWorkloads)) {
|
|
407
|
+
const snapshot = snapshotWorkload(workload);
|
|
408
|
+
if (snapshot) operationWorkloads[name] = snapshot;
|
|
409
|
+
}
|
|
410
|
+
return Object.freeze({
|
|
411
|
+
...policy,
|
|
412
|
+
operations: Object.freeze(operations),
|
|
413
|
+
requiredScopes: Array.isArray(policy.requiredScopes) ? Object.freeze([...policy.requiredScopes]) : policy.requiredScopes,
|
|
414
|
+
operationScopes: Object.freeze(operationScopes),
|
|
415
|
+
workload: snapshotWorkload(policy.workload),
|
|
416
|
+
operationWorkloads: Object.freeze(operationWorkloads),
|
|
417
|
+
fields: policy.fields ? Object.freeze({
|
|
418
|
+
include: policy.fields.include ? Object.freeze([...policy.fields.include]) : void 0,
|
|
419
|
+
exclude: policy.fields.exclude ? Object.freeze([...policy.fields.exclude]) : void 0
|
|
420
|
+
}) : void 0
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
function snapshotTool(candidate) {
|
|
424
|
+
const config = Object.freeze({
|
|
425
|
+
...candidate.config,
|
|
426
|
+
scopes: Array.isArray(candidate.config.scopes) ? Object.freeze([...candidate.config.scopes]) : candidate.config.scopes,
|
|
427
|
+
workload: snapshotWorkload(candidate.config.workload),
|
|
428
|
+
annotations: candidate.config.annotations ? Object.freeze({ ...candidate.config.annotations }) : void 0,
|
|
429
|
+
_meta: candidate.config["_meta"] ? Object.freeze({ ...candidate.config["_meta"] }) : void 0
|
|
430
|
+
});
|
|
431
|
+
return Object.freeze({
|
|
432
|
+
__brand: "mcpTool",
|
|
433
|
+
name: candidate.name,
|
|
434
|
+
config,
|
|
435
|
+
handler: candidate.handler
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
function readonlyMap(source) {
|
|
439
|
+
return Object.freeze({
|
|
440
|
+
get size() {
|
|
441
|
+
return source.size;
|
|
442
|
+
},
|
|
443
|
+
get: (key) => source.get(key),
|
|
444
|
+
has: (key) => source.has(key),
|
|
445
|
+
entries: () => source.entries(),
|
|
446
|
+
keys: () => source.keys(),
|
|
447
|
+
values: () => source.values(),
|
|
448
|
+
forEach: (callback, thisArg) => {
|
|
449
|
+
const view = readonlyMap(source);
|
|
450
|
+
source.forEach((value, key) => callback.call(thisArg, value, key, view));
|
|
451
|
+
},
|
|
452
|
+
[Symbol.iterator]: () => source[Symbol.iterator](),
|
|
453
|
+
[Symbol.toStringTag]: "Map"
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
function readonlySet(source) {
|
|
457
|
+
return Object.freeze({
|
|
458
|
+
get size() {
|
|
459
|
+
return source.size;
|
|
460
|
+
},
|
|
461
|
+
has: (value) => source.has(value),
|
|
462
|
+
entries: () => source.entries(),
|
|
463
|
+
keys: () => source.keys(),
|
|
464
|
+
values: () => source.values(),
|
|
465
|
+
forEach: (callback, thisArg) => {
|
|
466
|
+
const view = readonlySet(source);
|
|
467
|
+
source.forEach((value) => callback.call(thisArg, value, value, view));
|
|
468
|
+
},
|
|
469
|
+
[Symbol.iterator]: () => source[Symbol.iterator](),
|
|
470
|
+
[Symbol.toStringTag]: "Set"
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
function resolveMcpCatalog(app, config) {
|
|
474
|
+
const collections = /* @__PURE__ */ new Map();
|
|
475
|
+
const globals = /* @__PURE__ */ new Map();
|
|
476
|
+
const routes = /* @__PURE__ */ new Map();
|
|
477
|
+
const customTools = /* @__PURE__ */ new Map();
|
|
478
|
+
const oauthScopes = [];
|
|
479
|
+
const umbrellaScopes = [];
|
|
480
|
+
const releasedToolNames = /* @__PURE__ */ new Set();
|
|
481
|
+
const claimToolName = (name) => {
|
|
482
|
+
if (releasedToolNames.has(name)) throw new Error(`Duplicate MCP tool name: ${name}`);
|
|
483
|
+
releasedToolNames.add(name);
|
|
484
|
+
};
|
|
485
|
+
for (const name of Object.keys(app.getCollections())) {
|
|
486
|
+
const policy = snapshotPolicy(resolveEntityPolicy(config, "collection", name));
|
|
487
|
+
if (!policy.expose) continue;
|
|
488
|
+
const operations = Object.entries(COLLECTION_OPERATION_KINDS).filter(([operation]) => operationIsExplicitlyEnabled(policy, operation)).map(([operation, operationKind]) => {
|
|
489
|
+
const required = requiredScopesForOperation(policy, "collection", name, operation, operationKind);
|
|
490
|
+
addRequiredScopes(oauthScopes, required);
|
|
491
|
+
for (const scope of required) {
|
|
492
|
+
const umbrella = umbrellaScopeFor(scope);
|
|
493
|
+
if (umbrella && !umbrellaScopes.includes(umbrella)) umbrellaScopes.push(umbrella);
|
|
494
|
+
}
|
|
495
|
+
return operation;
|
|
496
|
+
});
|
|
497
|
+
if (operations.length > 0) {
|
|
498
|
+
for (const operation of operations) claimToolName(`collections.${name}.${operation}`);
|
|
499
|
+
collections.set(name, Object.freeze({
|
|
500
|
+
policy,
|
|
501
|
+
operations: Object.freeze([...operations])
|
|
502
|
+
}));
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
for (const name of Object.keys(app.getGlobals())) {
|
|
506
|
+
const policy = snapshotPolicy(resolveEntityPolicy(config, "global", name));
|
|
507
|
+
if (!policy.expose) continue;
|
|
508
|
+
const operations = Object.entries(GLOBAL_OPERATION_KINDS).filter(([operation]) => operationIsExplicitlyEnabled(policy, operation)).map(([operation, operationKind]) => {
|
|
509
|
+
const required = requiredScopesForOperation(policy, "global", name, operation, operationKind);
|
|
510
|
+
addRequiredScopes(oauthScopes, required);
|
|
511
|
+
for (const scope of required) {
|
|
512
|
+
const umbrella = umbrellaScopeFor(scope);
|
|
513
|
+
if (umbrella && !umbrellaScopes.includes(umbrella)) umbrellaScopes.push(umbrella);
|
|
514
|
+
}
|
|
515
|
+
return operation;
|
|
516
|
+
});
|
|
517
|
+
if (operations.length > 0) {
|
|
518
|
+
for (const operation of operations) claimToolName(`globals.${name}.${operation}`);
|
|
519
|
+
globals.set(name, Object.freeze({
|
|
520
|
+
policy,
|
|
521
|
+
operations: Object.freeze([...operations])
|
|
522
|
+
}));
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
for (const route$1 of introspectRoutes(app)) {
|
|
526
|
+
if (!isJsonRoute(route$1.definition) || route$1.meta?.mcp?.expose !== true) continue;
|
|
527
|
+
const policy = snapshotPolicy(resolveEntityPolicy(config, "route", route$1.key));
|
|
528
|
+
if (!policy.expose || !operationIsExplicitlyEnabled(policy, "execute")) continue;
|
|
529
|
+
addRequiredScopes(oauthScopes, requiredScopesForOperation(policy, "route", route$1.key, "execute", "invoke"));
|
|
530
|
+
const toolName = route$1.meta.mcp.name ?? `routes.${sanitizeRouteKey(route$1.key)}`;
|
|
531
|
+
claimToolName(toolName);
|
|
532
|
+
routes.set(route$1.key, Object.freeze({
|
|
533
|
+
policy,
|
|
534
|
+
operations: Object.freeze(["execute"]),
|
|
535
|
+
route: route$1,
|
|
536
|
+
toolName
|
|
537
|
+
}));
|
|
538
|
+
}
|
|
539
|
+
const configuredTools = app.state?.mcpTools ?? {};
|
|
540
|
+
for (const [key, candidate] of Object.entries(configuredTools)) {
|
|
541
|
+
if (!isMcpTool(candidate) || candidate.config.access === void 0 || candidate.config.scopes === void 0) continue;
|
|
542
|
+
if (candidate.config.access === false) continue;
|
|
543
|
+
const tool = snapshotTool(candidate);
|
|
544
|
+
const name = tool.name || key;
|
|
545
|
+
claimToolName(name);
|
|
546
|
+
customTools.set(name, Object.freeze({ tool }));
|
|
547
|
+
addRequiredScopes(oauthScopes, tool.config.scopes);
|
|
548
|
+
}
|
|
549
|
+
const resourceConfig = ownSection(config, "resources");
|
|
550
|
+
const configuredResourceCollections = ownSection(resourceConfig, "collections");
|
|
551
|
+
const configuredResourceGlobals = ownSection(resourceConfig, "globals");
|
|
552
|
+
const configuredResourceRoutes = ownSection(resourceConfig, "routes");
|
|
553
|
+
const resourceCollections = new Set([...collections.entries()].filter(([name, entry]) => hasOwn$1(configuredResourceCollections, name) && configuredResourceCollections?.[name] === true && entry.operations.some((operation) => [
|
|
554
|
+
"list",
|
|
555
|
+
"count",
|
|
556
|
+
"get"
|
|
557
|
+
].includes(operation))).map(([name]) => name));
|
|
558
|
+
const resourceGlobals = new Set([...globals.entries()].filter(([name, entry]) => hasOwn$1(configuredResourceGlobals, name) && configuredResourceGlobals?.[name] === true && entry.operations.includes("get")).map(([name]) => name));
|
|
559
|
+
const resourceRoutes = new Set([...routes.keys()].filter((name) => hasOwn$1(configuredResourceRoutes, name) && configuredResourceRoutes?.[name] === true));
|
|
560
|
+
return Object.freeze({
|
|
561
|
+
collections: readonlyMap(collections),
|
|
562
|
+
globals: readonlyMap(globals),
|
|
563
|
+
routes: readonlyMap(routes),
|
|
564
|
+
customTools: readonlyMap(customTools),
|
|
565
|
+
resources: Object.freeze({
|
|
566
|
+
collections: readonlySet(resourceCollections),
|
|
567
|
+
globals: readonlySet(resourceGlobals),
|
|
568
|
+
routes: readonlySet(resourceRoutes)
|
|
569
|
+
}),
|
|
570
|
+
oauth: Object.freeze({
|
|
571
|
+
scopes: Object.freeze([...oauthScopes, ...umbrellaScopes]),
|
|
572
|
+
scopesSupported: Object.freeze([...umbrellaScopes])
|
|
573
|
+
})
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
//#endregion
|
|
578
|
+
//#region src/server/execution-boundary.ts
|
|
579
|
+
const DEFAULT_MCP_EXECUTION_LIMITS = Object.freeze({
|
|
580
|
+
maxInputBytes: 64 * 1024,
|
|
581
|
+
maxInputDepth: 16,
|
|
582
|
+
maxOutputDepth: 64,
|
|
583
|
+
maxValueNodes: 1e4,
|
|
584
|
+
maxOutputBytes: 1024 * 1024,
|
|
585
|
+
timeoutMs: 3e4,
|
|
586
|
+
maxConcurrency: 64,
|
|
587
|
+
maxConcurrencyPerPrincipal: 8,
|
|
588
|
+
maxTools: 512,
|
|
589
|
+
maxResources: 512
|
|
590
|
+
});
|
|
591
|
+
const ABSOLUTE_MCP_EXECUTION_LIMITS = Object.freeze({
|
|
592
|
+
maxInputBytes: 1024 * 1024,
|
|
593
|
+
maxInputDepth: 64,
|
|
594
|
+
maxOutputDepth: 64,
|
|
595
|
+
maxValueNodes: 1e5,
|
|
596
|
+
maxOutputBytes: 4 * 1024 * 1024,
|
|
597
|
+
timeoutMs: 5 * 6e4,
|
|
598
|
+
maxConcurrency: 1024,
|
|
599
|
+
maxConcurrencyPerPrincipal: 1024,
|
|
600
|
+
maxTools: 2048,
|
|
601
|
+
maxResources: 2048
|
|
602
|
+
});
|
|
603
|
+
const PUBLIC_ERROR_META_KEY = "questpie/error";
|
|
604
|
+
const PUBLIC_ERROR_CODES = new Set([
|
|
605
|
+
"access_denied",
|
|
606
|
+
"invalid_input",
|
|
607
|
+
"input_too_large",
|
|
608
|
+
"output_too_large",
|
|
609
|
+
"timeout",
|
|
610
|
+
"cancelled",
|
|
611
|
+
"busy",
|
|
612
|
+
"internal"
|
|
613
|
+
]);
|
|
614
|
+
var McpPublicError = class extends Error {
|
|
615
|
+
code;
|
|
616
|
+
correlationId;
|
|
617
|
+
constructor(code, correlationId = randomUUID()) {
|
|
618
|
+
super(`${publicMessage(code)} (${code}; correlationId=${correlationId})`);
|
|
619
|
+
this.name = "McpPublicError";
|
|
620
|
+
this.code = code;
|
|
621
|
+
this.correlationId = correlationId;
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
var McpAccessDeniedError = class extends Error {
|
|
625
|
+
constructor() {
|
|
626
|
+
super("MCP access denied");
|
|
627
|
+
this.name = "McpAccessDeniedError";
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
/** Read a public boundary code without touching an error message or accessor. */
|
|
631
|
+
function mcpPublicErrorCode(error) {
|
|
632
|
+
if (!error || typeof error !== "object" || types.isProxy(error)) return;
|
|
633
|
+
try {
|
|
634
|
+
const descriptor = Object.getOwnPropertyDescriptor(error, "code");
|
|
635
|
+
return descriptor && "value" in descriptor && typeof descriptor.value === "string" && PUBLIC_ERROR_CODES.has(descriptor.value) ? descriptor.value : void 0;
|
|
636
|
+
} catch {
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
function positiveInteger(value, fallback, maximum, name) {
|
|
641
|
+
if (value === void 0) return fallback;
|
|
642
|
+
if (!Number.isSafeInteger(value) || value <= 0 || value > maximum) throw new Error(`Invalid MCP execution limit: ${name}`);
|
|
643
|
+
return value;
|
|
644
|
+
}
|
|
645
|
+
function resolveMcpExecutionLimits(config) {
|
|
646
|
+
const limits = {
|
|
647
|
+
maxInputBytes: positiveInteger(config?.maxInputBytes, DEFAULT_MCP_EXECUTION_LIMITS.maxInputBytes, ABSOLUTE_MCP_EXECUTION_LIMITS.maxInputBytes, "maxInputBytes"),
|
|
648
|
+
maxInputDepth: positiveInteger(config?.maxInputDepth, DEFAULT_MCP_EXECUTION_LIMITS.maxInputDepth, ABSOLUTE_MCP_EXECUTION_LIMITS.maxInputDepth, "maxInputDepth"),
|
|
649
|
+
maxOutputDepth: positiveInteger(config?.maxOutputDepth, DEFAULT_MCP_EXECUTION_LIMITS.maxOutputDepth, ABSOLUTE_MCP_EXECUTION_LIMITS.maxOutputDepth, "maxOutputDepth"),
|
|
650
|
+
maxValueNodes: positiveInteger(config?.maxValueNodes, DEFAULT_MCP_EXECUTION_LIMITS.maxValueNodes, ABSOLUTE_MCP_EXECUTION_LIMITS.maxValueNodes, "maxValueNodes"),
|
|
651
|
+
maxOutputBytes: positiveInteger(config?.maxOutputBytes, DEFAULT_MCP_EXECUTION_LIMITS.maxOutputBytes, ABSOLUTE_MCP_EXECUTION_LIMITS.maxOutputBytes, "maxOutputBytes"),
|
|
652
|
+
timeoutMs: positiveInteger(config?.timeoutMs, DEFAULT_MCP_EXECUTION_LIMITS.timeoutMs, ABSOLUTE_MCP_EXECUTION_LIMITS.timeoutMs, "timeoutMs"),
|
|
653
|
+
maxConcurrency: positiveInteger(config?.maxConcurrency, DEFAULT_MCP_EXECUTION_LIMITS.maxConcurrency, ABSOLUTE_MCP_EXECUTION_LIMITS.maxConcurrency, "maxConcurrency"),
|
|
654
|
+
maxConcurrencyPerPrincipal: positiveInteger(config?.maxConcurrencyPerPrincipal, DEFAULT_MCP_EXECUTION_LIMITS.maxConcurrencyPerPrincipal, ABSOLUTE_MCP_EXECUTION_LIMITS.maxConcurrencyPerPrincipal, "maxConcurrencyPerPrincipal"),
|
|
655
|
+
maxTools: positiveInteger(config?.maxTools, DEFAULT_MCP_EXECUTION_LIMITS.maxTools, ABSOLUTE_MCP_EXECUTION_LIMITS.maxTools, "maxTools"),
|
|
656
|
+
maxResources: positiveInteger(config?.maxResources, DEFAULT_MCP_EXECUTION_LIMITS.maxResources, ABSOLUTE_MCP_EXECUTION_LIMITS.maxResources, "maxResources")
|
|
657
|
+
};
|
|
658
|
+
if (limits.maxConcurrencyPerPrincipal > limits.maxConcurrency) throw new Error("Invalid MCP execution limit: maxConcurrencyPerPrincipal");
|
|
659
|
+
return Object.freeze(limits);
|
|
660
|
+
}
|
|
661
|
+
function jsonStringBytes(value, maximum) {
|
|
662
|
+
if (maximum < 0) return 1;
|
|
663
|
+
if (maximum < 2 || value.length + 2 > maximum) return maximum + 1;
|
|
664
|
+
let bytes = 2;
|
|
665
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
666
|
+
const code = value.charCodeAt(index);
|
|
667
|
+
if (code === 34 || code === 92) bytes += 2;
|
|
668
|
+
else if (code <= 31) bytes += code === 8 || code === 9 || code === 10 || code === 12 || code === 13 ? 2 : 6;
|
|
669
|
+
else if (code <= 127) bytes += 1;
|
|
670
|
+
else if (code <= 2047) bytes += 2;
|
|
671
|
+
else if (code >= 55296 && code <= 56319 && index + 1 < value.length && value.charCodeAt(index + 1) >= 56320 && value.charCodeAt(index + 1) <= 57343) {
|
|
672
|
+
bytes += 4;
|
|
673
|
+
index += 1;
|
|
674
|
+
} else if (code >= 55296 && code <= 57343) bytes += 6;
|
|
675
|
+
else bytes += 3;
|
|
676
|
+
if (bytes > maximum) return maximum + 1;
|
|
677
|
+
}
|
|
678
|
+
return bytes;
|
|
679
|
+
}
|
|
680
|
+
function isJsonContainer(value) {
|
|
681
|
+
if (!value || typeof value !== "object" || types.isProxy(value)) return false;
|
|
682
|
+
try {
|
|
683
|
+
const prototype = Object.getPrototypeOf(value);
|
|
684
|
+
return Array.isArray(value) ? prototype === Array.prototype : prototype === Object.prototype || prototype === null;
|
|
685
|
+
} catch {
|
|
686
|
+
return false;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
function isJsonDate(value) {
|
|
690
|
+
if (!(value instanceof Date) || types.isProxy(value)) return false;
|
|
691
|
+
try {
|
|
692
|
+
return Object.getPrototypeOf(value) === Date.prototype && Number.isFinite(Date.prototype.getTime.call(value));
|
|
693
|
+
} catch {
|
|
694
|
+
return false;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
function isArrayIndex(key, length) {
|
|
698
|
+
const index = Number(key);
|
|
699
|
+
return Number.isSafeInteger(index) && index >= 0 && index < length && String(index) === key;
|
|
700
|
+
}
|
|
701
|
+
function assertBoundedMcpValue(value, limits, code, correlationId = randomUUID()) {
|
|
702
|
+
const pending = [{
|
|
703
|
+
value,
|
|
704
|
+
depth: 0
|
|
705
|
+
}];
|
|
706
|
+
let nodes = 0;
|
|
707
|
+
let bytes = 0;
|
|
708
|
+
while (pending.length > 0) {
|
|
709
|
+
const next = pending.pop();
|
|
710
|
+
nodes += 1;
|
|
711
|
+
if (nodes > limits.maxValueNodes || next.depth > limits.maxValueDepth) throw new McpPublicError(code, correlationId);
|
|
712
|
+
const current = next.value;
|
|
713
|
+
if (current === null || typeof current === "boolean") bytes += 5;
|
|
714
|
+
else if (typeof current === "number") {
|
|
715
|
+
if (typeof current === "number" && !Number.isFinite(current)) throw new McpPublicError(code, correlationId);
|
|
716
|
+
bytes += 32;
|
|
717
|
+
} else if (typeof current === "string") bytes += jsonStringBytes(current, limits.maxBytes - bytes);
|
|
718
|
+
else if (typeof current === "bigint") bytes += jsonStringBytes(current.toString(), limits.maxBytes - bytes);
|
|
719
|
+
else if (isJsonDate(current)) bytes += 32;
|
|
720
|
+
else if (isJsonContainer(current)) {
|
|
721
|
+
let descriptors;
|
|
722
|
+
try {
|
|
723
|
+
descriptors = Object.getOwnPropertyDescriptors(current);
|
|
724
|
+
} catch {
|
|
725
|
+
throw new McpPublicError(code, correlationId);
|
|
726
|
+
}
|
|
727
|
+
const arrayLength = Array.isArray(current) ? current.length : void 0;
|
|
728
|
+
let arrayIndexes = 0;
|
|
729
|
+
bytes += 2;
|
|
730
|
+
for (const [key, descriptor] of Object.entries(descriptors)) {
|
|
731
|
+
if (key === "toJSON") throw new McpPublicError(code, correlationId);
|
|
732
|
+
if (arrayLength !== void 0 && isArrayIndex(key, arrayLength)) {
|
|
733
|
+
arrayIndexes += 1;
|
|
734
|
+
if (!("value" in descriptor)) throw new McpPublicError(code, correlationId);
|
|
735
|
+
pending.push({
|
|
736
|
+
value: descriptor.value,
|
|
737
|
+
depth: next.depth + 1
|
|
738
|
+
});
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
if (!descriptor.enumerable) continue;
|
|
742
|
+
if (!("value" in descriptor)) throw new McpPublicError(code, correlationId);
|
|
743
|
+
bytes += jsonStringBytes(key, limits.maxBytes - bytes) + 4;
|
|
744
|
+
pending.push({
|
|
745
|
+
value: descriptor.value,
|
|
746
|
+
depth: next.depth + 1
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
if (arrayLength !== void 0) {
|
|
750
|
+
if (arrayLength > limits.maxValueNodes) throw new McpPublicError(code, correlationId);
|
|
751
|
+
const holes = arrayLength - arrayIndexes;
|
|
752
|
+
nodes += holes;
|
|
753
|
+
bytes += Math.max(0, arrayLength - 1) + holes * 4;
|
|
754
|
+
}
|
|
755
|
+
} else if (current !== void 0) throw new McpPublicError(code, correlationId);
|
|
756
|
+
if (nodes > limits.maxValueNodes || bytes > limits.maxBytes) throw new McpPublicError(code, correlationId);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
function snapshotBoundedMcpValue(value, limits, code, correlationId = randomUUID()) {
|
|
760
|
+
const snapshot = snapshotBoundedBrokerValue(value === void 0 ? null : value, limits.maxBytes, limits.maxValueDepth, limits.maxValueNodes);
|
|
761
|
+
if (!snapshot.ok) throw new McpPublicError(code, correlationId);
|
|
762
|
+
return snapshot.value;
|
|
763
|
+
}
|
|
764
|
+
function publicMessage(code) {
|
|
765
|
+
return code === "access_denied" ? "MCP access denied" : "MCP operation failed";
|
|
766
|
+
}
|
|
767
|
+
function classifyError(error, correlationId) {
|
|
768
|
+
if (!!error && (typeof error === "object" || typeof error === "function") && types.isProxy(error)) return new McpPublicError("internal", correlationId);
|
|
769
|
+
if (error instanceof McpPublicError) return error;
|
|
770
|
+
if (error instanceof McpAccessDeniedError || isLegacyAccessDenied(error)) return new McpPublicError("access_denied", correlationId);
|
|
771
|
+
if (error instanceof z.ZodError) return new McpPublicError("invalid_input", correlationId);
|
|
772
|
+
return new McpPublicError("internal", correlationId);
|
|
773
|
+
}
|
|
774
|
+
function isLegacyAccessDenied(error) {
|
|
775
|
+
if (!error || typeof error !== "object") return false;
|
|
776
|
+
try {
|
|
777
|
+
const descriptor = Object.getOwnPropertyDescriptor(error, "message");
|
|
778
|
+
return !!descriptor && "value" in descriptor && descriptor.value === "MCP access denied";
|
|
779
|
+
} catch {
|
|
780
|
+
return false;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
function diagnosticError(error) {
|
|
784
|
+
try {
|
|
785
|
+
return Object.freeze({ kind: error instanceof Error ? "Error" : "Unknown" });
|
|
786
|
+
} catch {
|
|
787
|
+
return Object.freeze({ kind: "Unknown" });
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
function dataProperty(value, key) {
|
|
791
|
+
if (!value || typeof value !== "object" || types.isProxy(value)) return;
|
|
792
|
+
try {
|
|
793
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
794
|
+
return descriptor && "value" in descriptor ? descriptor.value : void 0;
|
|
795
|
+
} catch {
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
function principalKey(ctx, transport, accessMode, workloadConcurrencyKey) {
|
|
800
|
+
if (accessMode === "system") return `${transport}:system`;
|
|
801
|
+
if (transport === "workload" && typeof workloadConcurrencyKey === "string" && workloadConcurrencyKey.length > 0) return `workload:${workloadConcurrencyKey.slice(0, 256)}`;
|
|
802
|
+
const principal = dataProperty(ctx, "principal");
|
|
803
|
+
const kind = dataProperty(principal, "kind");
|
|
804
|
+
if (kind === "oauth") {
|
|
805
|
+
const tokenId = dataProperty(principal, "tokenId");
|
|
806
|
+
if (typeof tokenId === "string" && tokenId.length > 0) return `oauth:${tokenId.slice(0, 256)}`;
|
|
807
|
+
const clientId = dataProperty(principal, "clientId");
|
|
808
|
+
const userId = dataProperty(dataProperty(principal, "user"), "id");
|
|
809
|
+
return `oauth:${String(clientId).slice(0, 128)}:${String(userId).slice(0, 128)}`;
|
|
810
|
+
}
|
|
811
|
+
if (kind === "user") {
|
|
812
|
+
const sessionId = dataProperty(dataProperty(principal, "session"), "id");
|
|
813
|
+
if (typeof sessionId === "string" && sessionId.length > 0) return `user:${sessionId.slice(0, 256)}`;
|
|
814
|
+
}
|
|
815
|
+
return `${transport}:anonymous`;
|
|
816
|
+
}
|
|
817
|
+
function safeRequestId(value) {
|
|
818
|
+
if (typeof value === "number") return Number.isSafeInteger(value) ? value : void 0;
|
|
819
|
+
return /^[\w.:-]{1,128}$/.test(value) ? value : void 0;
|
|
820
|
+
}
|
|
821
|
+
function toMcpToolError(error) {
|
|
822
|
+
const resolved = error instanceof McpPublicError ? error : classifyError(error, randomUUID());
|
|
823
|
+
return {
|
|
824
|
+
isError: true,
|
|
825
|
+
content: [{
|
|
826
|
+
type: "text",
|
|
827
|
+
text: publicMessage(resolved.code)
|
|
828
|
+
}],
|
|
829
|
+
_meta: { [PUBLIC_ERROR_META_KEY]: {
|
|
830
|
+
code: resolved.code,
|
|
831
|
+
correlationId: resolved.correlationId
|
|
832
|
+
} }
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
var McpExecutionGate = class {
|
|
836
|
+
active = 0;
|
|
837
|
+
activeByPrincipal = /* @__PURE__ */ new Map();
|
|
838
|
+
diagnosticActive = false;
|
|
839
|
+
};
|
|
840
|
+
var McpExecutionBoundary = class {
|
|
841
|
+
limits;
|
|
842
|
+
constructor(config = {}, gate = new McpExecutionGate()) {
|
|
843
|
+
this.config = config;
|
|
844
|
+
this.gate = gate;
|
|
845
|
+
this.limits = resolveMcpExecutionLimits(config);
|
|
846
|
+
}
|
|
847
|
+
emitDiagnostic(event) {
|
|
848
|
+
if (!this.config.onDiagnostic || this.gate.diagnosticActive) return;
|
|
849
|
+
this.gate.diagnosticActive = true;
|
|
850
|
+
Promise.resolve().then(() => this.config.onDiagnostic(Object.freeze(event))).catch(() => void 0).finally(() => {
|
|
851
|
+
this.gate.diagnosticActive = false;
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
acquireGlobal(correlationId) {
|
|
855
|
+
if (this.gate.active >= this.limits.maxConcurrency) throw new McpPublicError("busy", correlationId);
|
|
856
|
+
this.gate.active += 1;
|
|
857
|
+
return () => {
|
|
858
|
+
this.gate.active -= 1;
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
acquirePrincipal(key, correlationId) {
|
|
862
|
+
const active = this.gate.activeByPrincipal.get(key) ?? 0;
|
|
863
|
+
if (active >= this.limits.maxConcurrencyPerPrincipal) throw new McpPublicError("busy", correlationId);
|
|
864
|
+
this.gate.activeByPrincipal.set(key, active + 1);
|
|
865
|
+
return () => {
|
|
866
|
+
const next = (this.gate.activeByPrincipal.get(key) ?? 1) - 1;
|
|
867
|
+
if (next === 0) this.gate.activeByPrincipal.delete(key);
|
|
868
|
+
else this.gate.activeByPrincipal.set(key, next);
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
async execute(options) {
|
|
872
|
+
const startedAt = Date.now();
|
|
873
|
+
const correlationId = randomUUID();
|
|
874
|
+
const requestId = safeRequestId(options.extra.requestId);
|
|
875
|
+
let rawError;
|
|
876
|
+
try {
|
|
877
|
+
assertBoundedMcpValue(options.input, {
|
|
878
|
+
maxBytes: this.limits.maxInputBytes,
|
|
879
|
+
maxValueDepth: this.limits.maxInputDepth,
|
|
880
|
+
maxValueNodes: this.limits.maxValueNodes
|
|
881
|
+
}, "input_too_large", correlationId);
|
|
882
|
+
if (options.extra.signal.aborted) throw new McpPublicError("cancelled", correlationId);
|
|
883
|
+
const releaseGlobal = this.acquireGlobal(correlationId);
|
|
884
|
+
const controller = new AbortController();
|
|
885
|
+
let timedOut = false;
|
|
886
|
+
const cancel = () => controller.abort(options.extra.signal.reason);
|
|
887
|
+
options.extra.signal.addEventListener("abort", cancel, { once: true });
|
|
888
|
+
const timer = setTimeout(() => {
|
|
889
|
+
timedOut = true;
|
|
890
|
+
controller.abort(/* @__PURE__ */ new Error("MCP execution deadline exceeded"));
|
|
891
|
+
}, this.limits.timeoutMs);
|
|
892
|
+
let releasePrincipal;
|
|
893
|
+
const settled = Promise.resolve().then(async () => {
|
|
894
|
+
const ctx = await options.authorize({
|
|
895
|
+
signal: controller.signal,
|
|
896
|
+
requestId: options.extra.requestId,
|
|
897
|
+
correlationId
|
|
898
|
+
});
|
|
899
|
+
if (controller.signal.aborted) throw new McpPublicError(timedOut ? "timeout" : "cancelled", correlationId);
|
|
900
|
+
releasePrincipal = this.acquirePrincipal(principalKey(ctx, options.transport, options.accessMode, options.concurrencyKey?.()), correlationId);
|
|
901
|
+
return snapshotBoundedMcpValue(await options.invoke({
|
|
902
|
+
ctx,
|
|
903
|
+
signal: controller.signal,
|
|
904
|
+
requestId: options.extra.requestId,
|
|
905
|
+
correlationId
|
|
906
|
+
}), {
|
|
907
|
+
maxBytes: this.limits.maxOutputBytes,
|
|
908
|
+
maxValueDepth: this.limits.maxOutputDepth,
|
|
909
|
+
maxValueNodes: this.limits.maxValueNodes
|
|
910
|
+
}, "output_too_large", correlationId);
|
|
911
|
+
}).finally(() => {
|
|
912
|
+
releasePrincipal?.();
|
|
913
|
+
releaseGlobal();
|
|
914
|
+
});
|
|
915
|
+
settled.catch(() => void 0);
|
|
916
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
917
|
+
const onAbort = () => reject(new McpPublicError(timedOut ? "timeout" : "cancelled", correlationId));
|
|
918
|
+
if (controller.signal.aborted) onAbort();
|
|
919
|
+
else controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
920
|
+
});
|
|
921
|
+
try {
|
|
922
|
+
const result = await Promise.race([settled, aborted]);
|
|
923
|
+
this.emitDiagnostic({
|
|
924
|
+
correlationId,
|
|
925
|
+
...requestId !== void 0 ? { requestId } : {},
|
|
926
|
+
transport: options.transport,
|
|
927
|
+
operation: options.operation.slice(0, 256),
|
|
928
|
+
durationMs: Date.now() - startedAt,
|
|
929
|
+
outcome: "completed"
|
|
930
|
+
});
|
|
931
|
+
return result;
|
|
932
|
+
} finally {
|
|
933
|
+
clearTimeout(timer);
|
|
934
|
+
options.extra.signal.removeEventListener("abort", cancel);
|
|
935
|
+
}
|
|
936
|
+
} catch (error) {
|
|
937
|
+
rawError = error;
|
|
938
|
+
const publicError = classifyError(error, correlationId);
|
|
939
|
+
this.emitDiagnostic({
|
|
940
|
+
correlationId: publicError.correlationId,
|
|
941
|
+
...requestId !== void 0 ? { requestId } : {},
|
|
942
|
+
transport: options.transport,
|
|
943
|
+
operation: options.operation.slice(0, 256),
|
|
944
|
+
durationMs: Date.now() - startedAt,
|
|
945
|
+
outcome: "rejected",
|
|
946
|
+
code: publicError.code,
|
|
947
|
+
...publicError.code === "internal" ? { internalError: diagnosticError(rawError) } : {}
|
|
948
|
+
});
|
|
949
|
+
throw publicError;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
};
|
|
953
|
+
|
|
954
|
+
//#endregion
|
|
955
|
+
//#region src/server/release.ts
|
|
956
|
+
const releases = /* @__PURE__ */ new WeakMap();
|
|
957
|
+
const executionGates = /* @__PURE__ */ new WeakMap();
|
|
958
|
+
function executionGate(app) {
|
|
959
|
+
const key = app;
|
|
960
|
+
const existing = executionGates.get(key);
|
|
961
|
+
if (existing) return existing;
|
|
962
|
+
const created = new McpExecutionGate();
|
|
963
|
+
executionGates.set(key, created);
|
|
964
|
+
return created;
|
|
965
|
+
}
|
|
966
|
+
function freezeConfig(value, seen = /* @__PURE__ */ new WeakSet()) {
|
|
967
|
+
if (!value || typeof value !== "object" || seen.has(value)) return value;
|
|
968
|
+
seen.add(value);
|
|
969
|
+
for (const entry of Object.values(value)) freezeConfig(entry, seen);
|
|
970
|
+
return Object.freeze(value);
|
|
971
|
+
}
|
|
972
|
+
function createRelease(app, config, options) {
|
|
973
|
+
const frozenConfig = freezeConfig(options?.resources === false ? {
|
|
974
|
+
...config,
|
|
975
|
+
resources: {
|
|
976
|
+
collections: Object.create(null),
|
|
977
|
+
globals: Object.create(null),
|
|
978
|
+
routes: Object.create(null)
|
|
979
|
+
}
|
|
980
|
+
} : config);
|
|
981
|
+
const catalog = resolveMcpCatalog(app, frozenConfig);
|
|
982
|
+
const execution = new McpExecutionBoundary(frozenConfig.execution, executionGate(app));
|
|
983
|
+
const toolCount = [...catalog.collections.values()].reduce((total, entry) => total + entry.operations.length, 0) + [...catalog.globals.values()].reduce((total, entry) => total + entry.operations.length, 0) + catalog.routes.size + catalog.customTools.size;
|
|
984
|
+
const resourceCount = catalog.resources.collections.size + catalog.resources.globals.size + catalog.resources.routes.size;
|
|
985
|
+
if (toolCount > execution.limits.maxTools) throw new Error("MCP released tool count exceeds configured limit");
|
|
986
|
+
if (resourceCount > execution.limits.maxResources) throw new Error("MCP released resource count exceeds configured limit");
|
|
987
|
+
return Object.freeze({
|
|
988
|
+
config: frozenConfig,
|
|
989
|
+
catalog,
|
|
990
|
+
execution
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
/** App-owned release snapshot shared by OAuth advertisement and default servers. */
|
|
994
|
+
function getAppMcpRelease(app) {
|
|
995
|
+
const key = app;
|
|
996
|
+
const existing = releases.get(key);
|
|
997
|
+
if (existing) return existing;
|
|
998
|
+
const created = createRelease(app, resolveMcpConfig(app));
|
|
999
|
+
releases.set(key, created);
|
|
1000
|
+
return created;
|
|
1001
|
+
}
|
|
1002
|
+
/** Explicit programmatic overrides are isolated snapshots, never OAuth state. */
|
|
1003
|
+
function createIsolatedMcpRelease(app, override) {
|
|
1004
|
+
return createRelease(app, resolveMcpConfig(app, override));
|
|
1005
|
+
}
|
|
1006
|
+
/** Workload authority is isolated and resources remain unavailable. */
|
|
1007
|
+
function createWorkloadMcpRelease(app, override) {
|
|
1008
|
+
return createRelease(app, resolveWorkloadMcpConfig(app, override), { resources: false });
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
//#endregion
|
|
1012
|
+
//#region src/server/zod-json-schema.ts
|
|
1013
|
+
function jsonSchemaCompatibleSchema(schema) {
|
|
1014
|
+
if (!schema) return void 0;
|
|
1015
|
+
if (canConvertToJsonSchema(schema)) return schema;
|
|
1016
|
+
const def = schema._def;
|
|
1017
|
+
switch (def?.type) {
|
|
1018
|
+
case "date": return z.string().describe("ISO date or date-time string");
|
|
1019
|
+
case "optional": return jsonSchemaCompatibleSchema(def.innerType)?.optional() ?? z.unknown();
|
|
1020
|
+
case "nullable": return jsonSchemaCompatibleSchema(def.innerType)?.nullable() ?? z.unknown();
|
|
1021
|
+
case "default":
|
|
1022
|
+
case "prefault":
|
|
1023
|
+
case "catch": return jsonSchemaCompatibleSchema(def.innerType)?.optional() ?? z.unknown();
|
|
1024
|
+
case "array": return z.array(jsonSchemaCompatibleSchema(def.element) ?? z.unknown());
|
|
1025
|
+
case "record": {
|
|
1026
|
+
const keySchema = jsonSchemaCompatibleSchema(def.keyType);
|
|
1027
|
+
return z.record(keySchema instanceof z.ZodString ? keySchema : z.string(), jsonSchemaCompatibleSchema(def.valueType) ?? z.unknown());
|
|
1028
|
+
}
|
|
1029
|
+
case "union": {
|
|
1030
|
+
const options = Array.isArray(def.options) ? def.options.map((option) => jsonSchemaCompatibleSchema(option) ?? z.unknown()) : [];
|
|
1031
|
+
if (options.length === 0) return z.unknown();
|
|
1032
|
+
if (options.length === 1) return options[0];
|
|
1033
|
+
return z.union(options);
|
|
1034
|
+
}
|
|
1035
|
+
case "object": {
|
|
1036
|
+
const shape = schema.shape;
|
|
1037
|
+
const nextShape = {};
|
|
1038
|
+
for (const [key, value] of Object.entries(shape)) nextShape[key] = jsonSchemaCompatibleSchema(value) ?? z.unknown();
|
|
1039
|
+
const objectSchema = z.object(nextShape);
|
|
1040
|
+
if (def.catchall && def.catchall._def?.type !== "never") return objectSchema.catchall(jsonSchemaCompatibleSchema(def.catchall) ?? z.unknown());
|
|
1041
|
+
return objectSchema;
|
|
1042
|
+
}
|
|
1043
|
+
default: return z.unknown();
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
function toJsonSchema(schema) {
|
|
1047
|
+
const compatible = jsonSchemaCompatibleSchema(schema);
|
|
1048
|
+
if (!compatible) return void 0;
|
|
1049
|
+
try {
|
|
1050
|
+
return z.toJSONSchema(compatible, { reused: "ref" });
|
|
1051
|
+
} catch {
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
function toToolInputJsonSchema(schema) {
|
|
1056
|
+
const parsed = ToolSchema.shape.inputSchema.safeParse(toJsonSchema(schema));
|
|
1057
|
+
return parsed.success ? parsed.data : {
|
|
1058
|
+
type: "object",
|
|
1059
|
+
properties: {}
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
function toToolOutputJsonSchema(schema) {
|
|
1063
|
+
const parsed = ToolSchema.shape.outputSchema.safeParse(toJsonSchema(schema));
|
|
1064
|
+
return parsed.success ? parsed.data : void 0;
|
|
1065
|
+
}
|
|
1066
|
+
function canConvertToJsonSchema(schema) {
|
|
1067
|
+
try {
|
|
1068
|
+
z.toJSONSchema(schema);
|
|
1069
|
+
return true;
|
|
1070
|
+
} catch {
|
|
1071
|
+
return false;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
//#endregion
|
|
1076
|
+
//#region src/server/field-policy.ts
|
|
1077
|
+
const SYSTEM_FIELD_NAMES = [
|
|
1078
|
+
"id",
|
|
1079
|
+
"createdAt",
|
|
1080
|
+
"updatedAt",
|
|
1081
|
+
"deletedAt",
|
|
1082
|
+
"_status"
|
|
1083
|
+
];
|
|
1084
|
+
const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
|
|
1085
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1086
|
+
const hasFieldPolicy = (policy) => (policy.fields?.include?.length ?? 0) > 0 || (policy.fields?.exclude?.length ?? 0) > 0;
|
|
1087
|
+
const allowedKeysFor = (keys, policy) => {
|
|
1088
|
+
const initial = policy.fields?.include?.length ? policy.fields.include : Array.from(keys);
|
|
1089
|
+
const allowed = new Set(initial);
|
|
1090
|
+
for (const key of policy.fields?.exclude ?? []) allowed.delete(key);
|
|
1091
|
+
return allowed;
|
|
1092
|
+
};
|
|
1093
|
+
function filterRecordFields(value, policy) {
|
|
1094
|
+
if (!hasFieldPolicy(policy) || !isRecord(value)) return value;
|
|
1095
|
+
const allowed = allowedKeysFor(Object.keys(value), policy);
|
|
1096
|
+
const next = {};
|
|
1097
|
+
for (const key of allowed) if (hasOwn(value, key)) next[key] = value[key];
|
|
1098
|
+
return next;
|
|
1099
|
+
}
|
|
1100
|
+
function filterCrudResultFields(value, policy) {
|
|
1101
|
+
if (!hasFieldPolicy(policy)) return value;
|
|
1102
|
+
if (Array.isArray(value)) return value.map((item) => filterCrudResultFields(item, policy));
|
|
1103
|
+
if (!isRecord(value)) return value;
|
|
1104
|
+
if (Array.isArray(value.docs)) return {
|
|
1105
|
+
...value,
|
|
1106
|
+
docs: value.docs.map((doc) => filterRecordFields(doc, policy))
|
|
1107
|
+
};
|
|
1108
|
+
if (isRecord(value.data)) return {
|
|
1109
|
+
...value,
|
|
1110
|
+
data: filterRecordFields(value.data, policy)
|
|
1111
|
+
};
|
|
1112
|
+
return filterRecordFields(value, policy);
|
|
1113
|
+
}
|
|
1114
|
+
function filterEntitySchemaFields(schema, policy) {
|
|
1115
|
+
if (!hasFieldPolicy(policy) || !isRecord(schema.fields)) return schema;
|
|
1116
|
+
const allowed = allowedKeysFor(Object.keys(schema.fields), policy);
|
|
1117
|
+
const fields = {};
|
|
1118
|
+
for (const key of allowed) if (hasOwn(schema.fields, key)) fields[key] = schema.fields[key];
|
|
1119
|
+
const next = {
|
|
1120
|
+
...schema,
|
|
1121
|
+
fields
|
|
1122
|
+
};
|
|
1123
|
+
if (isRecord(schema.validation)) {
|
|
1124
|
+
const validation = schema.validation;
|
|
1125
|
+
next.validation = filterValidationSchemas(validation, allowed);
|
|
1126
|
+
}
|
|
1127
|
+
return next;
|
|
1128
|
+
}
|
|
1129
|
+
function filterValidationSchemas(validation, allowed) {
|
|
1130
|
+
const next = { ...validation };
|
|
1131
|
+
for (const [key, schema] of Object.entries(validation)) next[key] = filterJsonSchemaProperties(schema, allowed);
|
|
1132
|
+
return next;
|
|
1133
|
+
}
|
|
1134
|
+
function filterJsonSchemaProperties(schema, allowed) {
|
|
1135
|
+
if (!isRecord(schema) || !isRecord(schema.properties)) return schema;
|
|
1136
|
+
const jsonSchema = schema;
|
|
1137
|
+
const properties = {};
|
|
1138
|
+
for (const key of allowed) if (hasOwn(jsonSchema.properties, key)) properties[key] = jsonSchema.properties[key];
|
|
1139
|
+
return {
|
|
1140
|
+
...jsonSchema,
|
|
1141
|
+
properties,
|
|
1142
|
+
required: Array.isArray(jsonSchema.required) ? jsonSchema.required.filter((key) => allowed.has(key)) : jsonSchema.required
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
function createCollectionDataSchema(collection, operation, policy) {
|
|
1146
|
+
const validation = collection.state?.validation;
|
|
1147
|
+
return filterZodObjectSchema(operation === "create" ? validation?.insertSchema : validation?.updateSchema, policy, entityFieldNames(collection), entityFieldDefinitions(collection));
|
|
1148
|
+
}
|
|
1149
|
+
function createGlobalDataSchema(global, policy) {
|
|
1150
|
+
const validation = global.state?.validation;
|
|
1151
|
+
return filterZodObjectSchema(validation?.updateSchema, policy, entityFieldNames(global), entityFieldDefinitions(global));
|
|
1152
|
+
}
|
|
1153
|
+
function filterZodObjectSchema(schema, policy, knownFields, fieldDefinitions) {
|
|
1154
|
+
if (!schema || !(schema instanceof z.ZodObject)) {
|
|
1155
|
+
const allowed$1 = allowedKeysFor(knownFields ?? [], policy);
|
|
1156
|
+
return z.object(Object.fromEntries([...allowed$1].map((key) => [key, z.unknown().optional()]))).strict();
|
|
1157
|
+
}
|
|
1158
|
+
const shape = schema.shape;
|
|
1159
|
+
if (!(hasFieldPolicy(policy) || !!knownFields?.length)) return schema;
|
|
1160
|
+
const allowed = allowedKeysFor(knownFields ?? Object.keys(shape), policy);
|
|
1161
|
+
const nextShape = {};
|
|
1162
|
+
for (const key of allowed) if (hasOwn(shape, key)) nextShape[key] = jsonCompatibleFieldSchema(fieldDefinitions?.[key], shape[key]) ?? z.unknown();
|
|
1163
|
+
return z.object(nextShape).strict();
|
|
1164
|
+
}
|
|
1165
|
+
function jsonCompatibleFieldSchema(fieldDefinition, schema) {
|
|
1166
|
+
if (!isRecord(fieldDefinition) || !isRecord(fieldDefinition._state)) return jsonSchemaCompatibleSchema(schema);
|
|
1167
|
+
const state = fieldDefinition._state;
|
|
1168
|
+
const type = state.type;
|
|
1169
|
+
let external;
|
|
1170
|
+
if (type === "datetime" || type === "date") {
|
|
1171
|
+
const item = type === "datetime" ? z.iso.datetime({ offset: true }) : z.iso.date();
|
|
1172
|
+
if (state.isArray === true) {
|
|
1173
|
+
let arraySchema = z.array(item);
|
|
1174
|
+
if (typeof state.minItems === "number") arraySchema = arraySchema.min(state.minItems);
|
|
1175
|
+
if (typeof state.maxItems === "number") arraySchema = arraySchema.max(state.maxItems);
|
|
1176
|
+
external = arraySchema;
|
|
1177
|
+
} else external = item;
|
|
1178
|
+
} else if (type === "object" && isRecord(state.nestedFields)) {
|
|
1179
|
+
const nestedShape = {};
|
|
1180
|
+
for (const [key, nestedDefinition] of Object.entries(state.nestedFields)) {
|
|
1181
|
+
if (!isRecord(nestedDefinition) || typeof nestedDefinition.toZodSchema !== "function") continue;
|
|
1182
|
+
nestedShape[key] = jsonCompatibleFieldSchema(nestedDefinition, nestedDefinition.toZodSchema()) ?? z.unknown();
|
|
1183
|
+
}
|
|
1184
|
+
external = z.object(nestedShape);
|
|
1185
|
+
} else return jsonSchemaCompatibleSchema(schema);
|
|
1186
|
+
if (!external) return void 0;
|
|
1187
|
+
if (schema.isNullable()) external = external.nullable();
|
|
1188
|
+
if (schema.isOptional()) external = external.optional();
|
|
1189
|
+
return external;
|
|
1190
|
+
}
|
|
1191
|
+
function entityFieldNames(entity) {
|
|
1192
|
+
const fields = entity.state?.fields;
|
|
1193
|
+
if (!isRecord(fields)) return void 0;
|
|
1194
|
+
return Object.keys(fields);
|
|
1195
|
+
}
|
|
1196
|
+
function entityFieldDefinitions(entity) {
|
|
1197
|
+
const fieldDefinitions = entity.state?.fieldDefinitions;
|
|
1198
|
+
return isRecord(fieldDefinitions) ? fieldDefinitions : void 0;
|
|
1199
|
+
}
|
|
1200
|
+
function allowedEntityFieldNames(entity, policy) {
|
|
1201
|
+
const entityFields = entityFieldNames(entity) ?? [];
|
|
1202
|
+
return [...allowedKeysFor(policy.fields?.include?.length ? entityFields : [...new Set([...entityFields, ...SYSTEM_FIELD_NAMES])], policy)];
|
|
1203
|
+
}
|
|
1204
|
+
function allowedEntityRelationNames(entity, policy) {
|
|
1205
|
+
const fields = entity.state?.fields;
|
|
1206
|
+
if (!isRecord(fields)) return [];
|
|
1207
|
+
const allowed = new Set(allowedEntityFieldNames(entity, policy));
|
|
1208
|
+
return Object.entries(fields).filter(([name, field]) => {
|
|
1209
|
+
if (!allowed.has(name) || !isRecord(field)) return false;
|
|
1210
|
+
const fieldState = field["_state"];
|
|
1211
|
+
const state = isRecord(fieldState) ? fieldState : void 0;
|
|
1212
|
+
return state?.type === "relation" || state?.type === "upload";
|
|
1213
|
+
}).map(([name]) => name);
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
//#endregion
|
|
1217
|
+
//#region src/server/runtime.ts
|
|
1218
|
+
function createRuntimeScope(app, options, execution, workload) {
|
|
1219
|
+
const transport = options.transport ?? "http";
|
|
1220
|
+
const accessMode = options.accessMode ?? "user";
|
|
1221
|
+
const createContext = createContextFactory(app);
|
|
1222
|
+
let requestContextPromise;
|
|
1223
|
+
return {
|
|
1224
|
+
app,
|
|
1225
|
+
transport,
|
|
1226
|
+
accessMode,
|
|
1227
|
+
request: options.request,
|
|
1228
|
+
workload,
|
|
1229
|
+
execution,
|
|
1230
|
+
async getContext() {
|
|
1231
|
+
if (options.ctx) return options.ctx;
|
|
1232
|
+
if (options.request) {
|
|
1233
|
+
requestContextPromise ??= createAdapterContext(app, options.request, { accessMode }).then((adapterContext) => ({
|
|
1234
|
+
...adapterContext.appContext,
|
|
1235
|
+
request: options.request
|
|
1236
|
+
}));
|
|
1237
|
+
return requestContextPromise;
|
|
1238
|
+
}
|
|
1239
|
+
return createContext({ accessMode });
|
|
1240
|
+
}
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
function toRequestContext(ctx, accessMode) {
|
|
1244
|
+
return {
|
|
1245
|
+
session: ctx.session,
|
|
1246
|
+
principal: ctx.principal,
|
|
1247
|
+
locale: ctx.locale,
|
|
1248
|
+
defaultLocale: ctx.defaultLocale,
|
|
1249
|
+
localeFallback: ctx.localeFallback,
|
|
1250
|
+
accessMode: ctx.accessMode ?? accessMode,
|
|
1251
|
+
stage: ctx.stage,
|
|
1252
|
+
requestId: ctx.requestId,
|
|
1253
|
+
traceId: ctx.traceId,
|
|
1254
|
+
db: ctx.db,
|
|
1255
|
+
"~contextExtensions": ctx["~contextExtensions"]
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
1258
|
+
const MAX_MCP_RUNTIME_JSON_BYTES = 4 * 1024 * 1024;
|
|
1259
|
+
const UTF8_ENCODER = new TextEncoder();
|
|
1260
|
+
function runtimeSnapshot(value) {
|
|
1261
|
+
return snapshotBoundedMcpValue(value, {
|
|
1262
|
+
maxBytes: MAX_MCP_RUNTIME_JSON_BYTES,
|
|
1263
|
+
maxValueDepth: 64,
|
|
1264
|
+
maxValueNodes: 1e5
|
|
1265
|
+
}, "output_too_large");
|
|
1266
|
+
}
|
|
1267
|
+
function stringifyRuntimeSnapshot(value) {
|
|
1268
|
+
const serialized = JSON.stringify(value, null, 2);
|
|
1269
|
+
if (UTF8_ENCODER.encode(serialized).byteLength > MAX_MCP_RUNTIME_JSON_BYTES) throw new McpPublicError("output_too_large");
|
|
1270
|
+
return serialized;
|
|
1271
|
+
}
|
|
1272
|
+
function safeJsonStringify(value) {
|
|
1273
|
+
return stringifyRuntimeSnapshot(runtimeSnapshot(value));
|
|
1274
|
+
}
|
|
1275
|
+
function toToolResult(value) {
|
|
1276
|
+
const snapshot = runtimeSnapshot(value);
|
|
1277
|
+
return {
|
|
1278
|
+
structuredContent: snapshot && typeof snapshot === "object" && !Array.isArray(snapshot) ? snapshot : { value: snapshot },
|
|
1279
|
+
content: [{
|
|
1280
|
+
type: "text",
|
|
1281
|
+
text: stringifyRuntimeSnapshot(snapshot)
|
|
1282
|
+
}]
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
function toToolError(error) {
|
|
1286
|
+
return toMcpToolError(error);
|
|
1287
|
+
}
|
|
1288
|
+
function jsonResource(uri, value) {
|
|
1289
|
+
return { contents: [{
|
|
1290
|
+
uri,
|
|
1291
|
+
mimeType: "application/json",
|
|
1292
|
+
text: safeJsonStringify(value)
|
|
1293
|
+
}] };
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
//#endregion
|
|
1297
|
+
//#region src/server/workload-boundary.ts
|
|
1298
|
+
const PUBLIC_ACCESS_DENIED = "MCP access denied";
|
|
1299
|
+
function isProxy(value) {
|
|
1300
|
+
return !!value && (typeof value === "object" || typeof value === "function") && types.isProxy(value);
|
|
1301
|
+
}
|
|
1302
|
+
function readMethod(owner, name) {
|
|
1303
|
+
if (!owner || typeof owner !== "object" && typeof owner !== "function") return null;
|
|
1304
|
+
if (isProxy(owner)) return null;
|
|
1305
|
+
try {
|
|
1306
|
+
let candidate = owner;
|
|
1307
|
+
while (candidate) {
|
|
1308
|
+
if (isProxy(candidate)) return null;
|
|
1309
|
+
const descriptor = Object.getOwnPropertyDescriptor(candidate, name);
|
|
1310
|
+
if (descriptor) {
|
|
1311
|
+
if (!("value" in descriptor) || typeof descriptor.value !== "function") return null;
|
|
1312
|
+
return descriptor.value.bind(owner);
|
|
1313
|
+
}
|
|
1314
|
+
candidate = Object.getPrototypeOf(candidate);
|
|
1315
|
+
}
|
|
1316
|
+
return null;
|
|
1317
|
+
} catch {
|
|
1318
|
+
return null;
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
function createWorkloadMcpBoundary(options) {
|
|
1322
|
+
try {
|
|
1323
|
+
if (!options || typeof options !== "object" || isProxy(options) || !("envelope" in options) || options.envelope === void 0) throw new Error(PUBLIC_ACCESS_DENIED);
|
|
1324
|
+
const concurrencyKey = options.concurrencyKey === void 0 ? `boundary:${randomUUID()}` : typeof options.concurrencyKey === "string" && /^[\w.:-]{1,256}$/.test(options.concurrencyKey) ? `consumer:${options.concurrencyKey}` : null;
|
|
1325
|
+
if (!concurrencyKey) throw new Error(PUBLIC_ACCESS_DENIED);
|
|
1326
|
+
const authorize = readMethod(options.authorizer, "authorize");
|
|
1327
|
+
const bindContext = readMethod(options.contextBinder, "bind");
|
|
1328
|
+
if (!authorize || !bindContext) throw new Error(PUBLIC_ACCESS_DENIED);
|
|
1329
|
+
let executeHandoff;
|
|
1330
|
+
if (options.handoff) {
|
|
1331
|
+
const method = readMethod(options.handoff, "execute");
|
|
1332
|
+
if (!method) throw new Error(PUBLIC_ACCESS_DENIED);
|
|
1333
|
+
executeHandoff = method;
|
|
1334
|
+
}
|
|
1335
|
+
return {
|
|
1336
|
+
envelope: options.envelope,
|
|
1337
|
+
concurrencyKey,
|
|
1338
|
+
authorize,
|
|
1339
|
+
bindContext,
|
|
1340
|
+
audit: options.audit,
|
|
1341
|
+
executeHandoff,
|
|
1342
|
+
discoveryTools: []
|
|
1343
|
+
};
|
|
1344
|
+
} catch {
|
|
1345
|
+
throw new Error(PUBLIC_ACCESS_DENIED);
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
function snapshotAuthorization(value) {
|
|
1349
|
+
try {
|
|
1350
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || isProxy(value)) return null;
|
|
1351
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
1352
|
+
const context = descriptors.context;
|
|
1353
|
+
const attribution = descriptors.attribution;
|
|
1354
|
+
if (!context || !("value" in context)) return null;
|
|
1355
|
+
if (attribution && !("value" in attribution)) return null;
|
|
1356
|
+
if (isProxy(context.value) || attribution && isProxy(attribution.value)) return null;
|
|
1357
|
+
return Object.freeze({
|
|
1358
|
+
context: context.value,
|
|
1359
|
+
...attribution ? { attribution: attribution.value } : {}
|
|
1360
|
+
});
|
|
1361
|
+
} catch {
|
|
1362
|
+
return null;
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
function isBoundContext(value) {
|
|
1366
|
+
try {
|
|
1367
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || isProxy(value)) return false;
|
|
1368
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
1369
|
+
const accessMode = descriptors.accessMode;
|
|
1370
|
+
const db = descriptors.db;
|
|
1371
|
+
const principal = descriptors.principal;
|
|
1372
|
+
return !!accessMode && "value" in accessMode && accessMode.value === "user" && !!db && "value" in db && !!db.value && (!principal || "value" in principal && principal.value?.kind !== "system");
|
|
1373
|
+
} catch {
|
|
1374
|
+
return false;
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
function normalizeRequirement(requirement) {
|
|
1378
|
+
try {
|
|
1379
|
+
if (!requirement || typeof requirement !== "object" || isProxy(requirement) || !Array.isArray(requirement.capabilities) || requirement.capabilities.length === 0 || requirement.capabilities.length > 64 || requirement.capabilities.some((capability) => isProxy(capability)) || !requirement.capabilities.every((capability) => typeof capability === "string" && capability.length > 0 && capability.length <= 256) || requirement.handoff !== void 0 && (typeof requirement.handoff !== "string" || requirement.handoff.length === 0 || requirement.handoff.length > 256)) return null;
|
|
1380
|
+
return Object.freeze({
|
|
1381
|
+
capabilities: Object.freeze([...requirement.capabilities]),
|
|
1382
|
+
...requirement.handoff ? { handoff: requirement.handoff } : {}
|
|
1383
|
+
});
|
|
1384
|
+
} catch {
|
|
1385
|
+
return null;
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
function normalizeTool(identity, requirement) {
|
|
1389
|
+
return Object.freeze({
|
|
1390
|
+
kind: identity.kind,
|
|
1391
|
+
name: identity.name,
|
|
1392
|
+
operation: identity.operation,
|
|
1393
|
+
intent: identity.intent,
|
|
1394
|
+
transport: "workload",
|
|
1395
|
+
capabilities: Object.freeze([...requirement.capabilities]),
|
|
1396
|
+
...requirement.handoff ? { handoff: requirement.handoff } : {}
|
|
1397
|
+
});
|
|
1398
|
+
}
|
|
1399
|
+
async function auditDecision(boundary, event) {
|
|
1400
|
+
try {
|
|
1401
|
+
await boundary.audit?.(event);
|
|
1402
|
+
} catch {
|
|
1403
|
+
throw new Error(PUBLIC_ACCESS_DENIED);
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
async function authorizeWorkload(boundary, phase, identity, requirement, control) {
|
|
1407
|
+
const cancelled = () => control?.signal.aborted === true;
|
|
1408
|
+
if (cancelled()) return null;
|
|
1409
|
+
const normalized = normalizeRequirement(requirement);
|
|
1410
|
+
if (!normalized || normalized.handoff && !boundary.executeHandoff) {
|
|
1411
|
+
await auditDecision(boundary, {
|
|
1412
|
+
phase,
|
|
1413
|
+
decision: "denied",
|
|
1414
|
+
reason: "authorization_denied"
|
|
1415
|
+
});
|
|
1416
|
+
return null;
|
|
1417
|
+
}
|
|
1418
|
+
const tool = normalizeTool(identity, normalized);
|
|
1419
|
+
let result;
|
|
1420
|
+
try {
|
|
1421
|
+
result = await boundary.authorize({
|
|
1422
|
+
phase,
|
|
1423
|
+
envelope: boundary.envelope,
|
|
1424
|
+
tool
|
|
1425
|
+
}, control);
|
|
1426
|
+
} catch {
|
|
1427
|
+
result = null;
|
|
1428
|
+
}
|
|
1429
|
+
if (cancelled()) return null;
|
|
1430
|
+
const authorization = snapshotAuthorization(result);
|
|
1431
|
+
if (!authorization) {
|
|
1432
|
+
await auditDecision(boundary, {
|
|
1433
|
+
phase,
|
|
1434
|
+
decision: "denied",
|
|
1435
|
+
reason: "authorization_invalid"
|
|
1436
|
+
});
|
|
1437
|
+
return null;
|
|
1438
|
+
}
|
|
1439
|
+
if (cancelled()) return null;
|
|
1440
|
+
let context;
|
|
1441
|
+
try {
|
|
1442
|
+
context = await boundary.bindContext({
|
|
1443
|
+
authorizationContext: authorization.context,
|
|
1444
|
+
attribution: authorization.attribution,
|
|
1445
|
+
tool
|
|
1446
|
+
}, control);
|
|
1447
|
+
} catch {
|
|
1448
|
+
context = null;
|
|
1449
|
+
}
|
|
1450
|
+
if (cancelled()) return null;
|
|
1451
|
+
if (!isBoundContext(context)) {
|
|
1452
|
+
await auditDecision(boundary, {
|
|
1453
|
+
phase,
|
|
1454
|
+
decision: "denied",
|
|
1455
|
+
reason: "context_invalid"
|
|
1456
|
+
});
|
|
1457
|
+
return null;
|
|
1458
|
+
}
|
|
1459
|
+
if (cancelled()) return null;
|
|
1460
|
+
await auditDecision(boundary, {
|
|
1461
|
+
phase,
|
|
1462
|
+
decision: "allowed",
|
|
1463
|
+
toolName: tool.name,
|
|
1464
|
+
attribution: authorization.attribution
|
|
1465
|
+
});
|
|
1466
|
+
if (cancelled()) return null;
|
|
1467
|
+
return Object.freeze({
|
|
1468
|
+
authorization,
|
|
1469
|
+
concurrencyKey: boundary.concurrencyKey,
|
|
1470
|
+
context,
|
|
1471
|
+
tool
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1474
|
+
function registerWorkloadDiscoveryTool(boundary, tool, identity, requirement, allows) {
|
|
1475
|
+
const normalized = normalizeRequirement(requirement);
|
|
1476
|
+
if (!normalized) return;
|
|
1477
|
+
boundary.discoveryTools.push({
|
|
1478
|
+
tool: Object.freeze({ ...tool }),
|
|
1479
|
+
identity: Object.freeze({ ...identity }),
|
|
1480
|
+
requirement: normalized,
|
|
1481
|
+
allows
|
|
1482
|
+
});
|
|
1483
|
+
}
|
|
1484
|
+
async function listWorkloadTools(boundary, execution, extra) {
|
|
1485
|
+
const tools = [];
|
|
1486
|
+
const controller = new AbortController();
|
|
1487
|
+
let timedOut = false;
|
|
1488
|
+
const cancel = () => controller.abort(extra.signal.reason);
|
|
1489
|
+
if (extra.signal.aborted) cancel();
|
|
1490
|
+
else extra.signal.addEventListener("abort", cancel, { once: true });
|
|
1491
|
+
const timer = setTimeout(() => {
|
|
1492
|
+
timedOut = true;
|
|
1493
|
+
controller.abort(/* @__PURE__ */ new Error("MCP discovery deadline exceeded"));
|
|
1494
|
+
}, execution.limits.timeoutMs);
|
|
1495
|
+
const boundedExtra = {
|
|
1496
|
+
...extra,
|
|
1497
|
+
signal: controller.signal
|
|
1498
|
+
};
|
|
1499
|
+
try {
|
|
1500
|
+
for (const candidate of boundary.discoveryTools) try {
|
|
1501
|
+
let workloadAuthorization;
|
|
1502
|
+
const tool = await execution.execute({
|
|
1503
|
+
operation: "tools/list",
|
|
1504
|
+
transport: "workload",
|
|
1505
|
+
accessMode: "user",
|
|
1506
|
+
input: { name: candidate.tool.name },
|
|
1507
|
+
extra: boundedExtra,
|
|
1508
|
+
authorize: async (control) => {
|
|
1509
|
+
workloadAuthorization = await authorizeWorkload(boundary, "discovery", candidate.identity, candidate.requirement, control);
|
|
1510
|
+
if (!workloadAuthorization || !await candidate.allows(workloadAuthorization.context)) throw new McpAccessDeniedError();
|
|
1511
|
+
return workloadAuthorization.context;
|
|
1512
|
+
},
|
|
1513
|
+
concurrencyKey: () => workloadAuthorization?.concurrencyKey,
|
|
1514
|
+
invoke: async () => candidate.tool
|
|
1515
|
+
});
|
|
1516
|
+
tools.push(tool);
|
|
1517
|
+
} catch (error) {
|
|
1518
|
+
const code = mcpPublicErrorCode(error);
|
|
1519
|
+
if (code === "access_denied" || code === "internal") continue;
|
|
1520
|
+
if (timedOut && code === "cancelled") throw new McpPublicError("timeout");
|
|
1521
|
+
throw error;
|
|
1522
|
+
}
|
|
1523
|
+
assertBoundedMcpValue({ tools }, {
|
|
1524
|
+
maxBytes: execution.limits.maxOutputBytes,
|
|
1525
|
+
maxValueDepth: execution.limits.maxOutputDepth,
|
|
1526
|
+
maxValueNodes: execution.limits.maxValueNodes
|
|
1527
|
+
}, "output_too_large");
|
|
1528
|
+
return { tools };
|
|
1529
|
+
} finally {
|
|
1530
|
+
clearTimeout(timer);
|
|
1531
|
+
extra.signal.removeEventListener("abort", cancel);
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
async function executeWorkloadTool(boundary, authorized, metadata, execution, invoke) {
|
|
1535
|
+
if (!authorized.tool.handoff) return invoke();
|
|
1536
|
+
if (!boundary.executeHandoff) throw new Error(PUBLIC_ACCESS_DENIED);
|
|
1537
|
+
try {
|
|
1538
|
+
return await boundary.executeHandoff({
|
|
1539
|
+
authorizationContext: authorized.authorization.context,
|
|
1540
|
+
attribution: authorized.authorization.attribution,
|
|
1541
|
+
toolName: authorized.tool.name,
|
|
1542
|
+
capability: authorized.tool.handoff,
|
|
1543
|
+
tool: authorized.tool,
|
|
1544
|
+
metadata,
|
|
1545
|
+
signal: execution.signal,
|
|
1546
|
+
requestId: execution.requestId,
|
|
1547
|
+
correlationId: execution.correlationId,
|
|
1548
|
+
invoke
|
|
1549
|
+
});
|
|
1550
|
+
} catch {
|
|
1551
|
+
throw new Error(PUBLIC_ACCESS_DENIED);
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
//#endregion
|
|
1556
|
+
//#region src/server/crud-tools.ts
|
|
1557
|
+
const idSchema = z.union([z.string(), z.number()]);
|
|
1558
|
+
const deleteSchema = z.object({ id: idSchema }).strict();
|
|
1559
|
+
const MAX_QUERY_FIELDS = 64;
|
|
1560
|
+
const MAX_WHERE_DEPTH = 3;
|
|
1561
|
+
const MAX_LOGICAL_CLAUSES = 16;
|
|
1562
|
+
const MAX_OPERATOR_VALUES = 100;
|
|
1563
|
+
const MAX_OFFSET = 1e4;
|
|
1564
|
+
const scalarQueryValue = z.union([
|
|
1565
|
+
z.string().max(4096),
|
|
1566
|
+
z.number().finite(),
|
|
1567
|
+
z.boolean(),
|
|
1568
|
+
z.null()
|
|
1569
|
+
]);
|
|
1570
|
+
const queryValue = z.union([scalarQueryValue, z.array(scalarQueryValue).max(MAX_OPERATOR_VALUES)]);
|
|
1571
|
+
const operatorSchema = z.object(Object.fromEntries([
|
|
1572
|
+
"eq",
|
|
1573
|
+
"ne",
|
|
1574
|
+
"not",
|
|
1575
|
+
"gt",
|
|
1576
|
+
"gte",
|
|
1577
|
+
"lt",
|
|
1578
|
+
"lte",
|
|
1579
|
+
"in",
|
|
1580
|
+
"notIn",
|
|
1581
|
+
"like",
|
|
1582
|
+
"ilike",
|
|
1583
|
+
"notLike",
|
|
1584
|
+
"notIlike",
|
|
1585
|
+
"contains",
|
|
1586
|
+
"startsWith",
|
|
1587
|
+
"endsWith",
|
|
1588
|
+
"isNull",
|
|
1589
|
+
"isNotNull",
|
|
1590
|
+
"arrayOverlaps",
|
|
1591
|
+
"arrayContained",
|
|
1592
|
+
"arrayContains"
|
|
1593
|
+
].map((operator) => [operator, queryValue.optional()]))).strict();
|
|
1594
|
+
const fieldCondition = z.union([queryValue, operatorSchema]).meta({ id: "QuestpieMcpFieldCondition" });
|
|
1595
|
+
function boundedFields(fields) {
|
|
1596
|
+
return fields.slice(0, MAX_QUERY_FIELDS);
|
|
1597
|
+
}
|
|
1598
|
+
function whereSchema(fields, depth = 0) {
|
|
1599
|
+
const shape = Object.fromEntries(boundedFields(fields).map((field) => [field, fieldCondition.optional()]));
|
|
1600
|
+
if (depth < MAX_WHERE_DEPTH) {
|
|
1601
|
+
const nested = whereSchema(fields, depth + 1);
|
|
1602
|
+
shape.AND = z.array(nested).max(MAX_LOGICAL_CLAUSES).optional();
|
|
1603
|
+
shape.OR = z.array(nested).max(MAX_LOGICAL_CLAUSES).optional();
|
|
1604
|
+
shape.NOT = nested.optional();
|
|
1605
|
+
}
|
|
1606
|
+
return z.object(shape).strict();
|
|
1607
|
+
}
|
|
1608
|
+
function columnsSchema(fields) {
|
|
1609
|
+
return z.object(Object.fromEntries(boundedFields(fields).map((field) => [field, z.boolean().optional()]))).strict();
|
|
1610
|
+
}
|
|
1611
|
+
function sortSchema(fields) {
|
|
1612
|
+
return z.object(Object.fromEntries(boundedFields(fields).map((field) => [field, z.enum(["asc", "desc"]).optional()]))).strict();
|
|
1613
|
+
}
|
|
1614
|
+
function relationLoadSchema(relations) {
|
|
1615
|
+
return z.object(Object.fromEntries(boundedFields(relations).map((field) => [field, z.literal(true).optional()]))).strict();
|
|
1616
|
+
}
|
|
1617
|
+
function collectionReadSchemas(collection, policy) {
|
|
1618
|
+
const fields = allowedEntityFieldNames(collection, policy);
|
|
1619
|
+
const common = {
|
|
1620
|
+
with: relationLoadSchema(allowedEntityRelationNames(collection, policy)).optional(),
|
|
1621
|
+
columns: columnsSchema(fields).optional(),
|
|
1622
|
+
locale: z.string().max(64).optional(),
|
|
1623
|
+
includeDeleted: z.boolean().optional(),
|
|
1624
|
+
stage: z.string().max(64).optional()
|
|
1625
|
+
};
|
|
1626
|
+
return {
|
|
1627
|
+
list: z.object({
|
|
1628
|
+
where: whereSchema(fields).optional(),
|
|
1629
|
+
sort: sortSchema(fields).optional(),
|
|
1630
|
+
limit: z.number().int().positive().max(1e4).optional(),
|
|
1631
|
+
page: z.number().int().positive().max(1e3).optional(),
|
|
1632
|
+
offset: z.number().int().nonnegative().max(MAX_OFFSET).optional(),
|
|
1633
|
+
...common
|
|
1634
|
+
}).strict(),
|
|
1635
|
+
count: z.object({
|
|
1636
|
+
where: whereSchema(fields).optional(),
|
|
1637
|
+
includeDeleted: z.boolean().optional()
|
|
1638
|
+
}).strict(),
|
|
1639
|
+
get: z.object({
|
|
1640
|
+
id: idSchema,
|
|
1641
|
+
...common
|
|
1642
|
+
}).strict()
|
|
1643
|
+
};
|
|
1644
|
+
}
|
|
1645
|
+
function globalOperationBaseSchema(global, policy) {
|
|
1646
|
+
const fields = allowedEntityFieldNames(global, policy);
|
|
1647
|
+
return {
|
|
1648
|
+
with: relationLoadSchema(allowedEntityRelationNames(global, policy)).optional(),
|
|
1649
|
+
columns: columnsSchema(fields).optional(),
|
|
1650
|
+
locale: z.string().max(64).optional(),
|
|
1651
|
+
stage: z.string().max(64).optional()
|
|
1652
|
+
};
|
|
1653
|
+
}
|
|
1654
|
+
const COLLECTION_OPERATIONS = [
|
|
1655
|
+
{
|
|
1656
|
+
name: "list",
|
|
1657
|
+
kind: "read",
|
|
1658
|
+
description: (name) => `List ${name} records.`,
|
|
1659
|
+
execute: (crud, input, ctx, maxLimit) => crud.find({
|
|
1660
|
+
...input,
|
|
1661
|
+
limit: Math.min(input.limit ?? maxLimit, maxLimit)
|
|
1662
|
+
}, ctx)
|
|
1663
|
+
},
|
|
1664
|
+
{
|
|
1665
|
+
name: "count",
|
|
1666
|
+
kind: "read",
|
|
1667
|
+
description: (name) => `Count ${name} records.`,
|
|
1668
|
+
execute: (crud, input, ctx) => crud.count(input, ctx)
|
|
1669
|
+
},
|
|
1670
|
+
{
|
|
1671
|
+
name: "get",
|
|
1672
|
+
kind: "read",
|
|
1673
|
+
description: (name) => `Get one ${name} record by id.`,
|
|
1674
|
+
execute: (crud, input, ctx) => {
|
|
1675
|
+
const { id, ...options } = input;
|
|
1676
|
+
return crud.findOne({
|
|
1677
|
+
...options,
|
|
1678
|
+
where: { id }
|
|
1679
|
+
}, ctx);
|
|
1680
|
+
}
|
|
1681
|
+
},
|
|
1682
|
+
{
|
|
1683
|
+
name: "create",
|
|
1684
|
+
kind: "write",
|
|
1685
|
+
description: (name) => `Create a ${name} record.`,
|
|
1686
|
+
execute: (crud, input, ctx) => crud.create(input.data, ctx)
|
|
1687
|
+
},
|
|
1688
|
+
{
|
|
1689
|
+
name: "update",
|
|
1690
|
+
kind: "write",
|
|
1691
|
+
description: (name) => `Update a ${name} record by id.`,
|
|
1692
|
+
execute: (crud, input, ctx) => crud.updateById({
|
|
1693
|
+
id: input.id,
|
|
1694
|
+
data: input.data
|
|
1695
|
+
}, ctx)
|
|
1696
|
+
},
|
|
1697
|
+
{
|
|
1698
|
+
name: "delete",
|
|
1699
|
+
kind: "delete",
|
|
1700
|
+
description: (name) => `Delete a ${name} record by id.`,
|
|
1701
|
+
execute: (crud, input, ctx) => crud.deleteById({ id: input.id }, ctx)
|
|
1702
|
+
}
|
|
1703
|
+
];
|
|
1704
|
+
const GLOBAL_OPERATIONS = [{
|
|
1705
|
+
name: "get",
|
|
1706
|
+
kind: "read",
|
|
1707
|
+
description: (name) => `Get the ${name} global.`,
|
|
1708
|
+
execute: (crud, input, ctx) => crud.get(input, ctx)
|
|
1709
|
+
}, {
|
|
1710
|
+
name: "update",
|
|
1711
|
+
kind: "write",
|
|
1712
|
+
description: (name) => `Update the ${name} global.`,
|
|
1713
|
+
execute: (crud, input, ctx) => {
|
|
1714
|
+
const { data, ...options } = input;
|
|
1715
|
+
return crud.update(data, ctx, options);
|
|
1716
|
+
}
|
|
1717
|
+
}];
|
|
1718
|
+
function collectionOperationSchema(operationName, collection, policy) {
|
|
1719
|
+
if (operationName === "create") return z.object({ data: createCollectionDataSchema(collection, "create", policy) }).strict();
|
|
1720
|
+
if (operationName === "update") return z.object({
|
|
1721
|
+
id: idSchema,
|
|
1722
|
+
data: createCollectionDataSchema(collection, "update", policy)
|
|
1723
|
+
}).strict();
|
|
1724
|
+
return collectionReadSchemas(collection, policy)[operationName] ?? deleteSchema;
|
|
1725
|
+
}
|
|
1726
|
+
function globalOperationSchema(operationName, global, policy) {
|
|
1727
|
+
if (operationName === "update") return z.object({
|
|
1728
|
+
data: createGlobalDataSchema(global, policy),
|
|
1729
|
+
...globalOperationBaseSchema(global, policy)
|
|
1730
|
+
}).strict();
|
|
1731
|
+
return z.object(globalOperationBaseSchema(global, policy)).strict();
|
|
1732
|
+
}
|
|
1733
|
+
async function questpieAllows(scope, ctx, kind, name, operationName) {
|
|
1734
|
+
if (scope.accessMode === "system") return true;
|
|
1735
|
+
const crudContext = {
|
|
1736
|
+
db: ctx.db ?? scope.app.db,
|
|
1737
|
+
session: ctx.session,
|
|
1738
|
+
locale: ctx.locale,
|
|
1739
|
+
accessMode: scope.accessMode,
|
|
1740
|
+
stage: ctx.stage
|
|
1741
|
+
};
|
|
1742
|
+
if (kind === "collection") {
|
|
1743
|
+
const collection = scope.app.getCollections()[name];
|
|
1744
|
+
const schema$1 = await introspectCollection(collection, crudContext, scope.app);
|
|
1745
|
+
if (!schema$1.access.visible) return false;
|
|
1746
|
+
const mappedOperation$1 = operationName === "create" || operationName === "update" || operationName === "delete" ? operationName : "read";
|
|
1747
|
+
const result$1 = schema$1.access.operations[mappedOperation$1];
|
|
1748
|
+
return !!result$1 && result$1.allowed !== false;
|
|
1749
|
+
}
|
|
1750
|
+
const global = scope.app.getGlobals()[name];
|
|
1751
|
+
const schema = await introspectGlobal(global, crudContext, scope.app);
|
|
1752
|
+
if (!schema.access.visible) return false;
|
|
1753
|
+
const mappedOperation = operationName === "update" ? "update" : "read";
|
|
1754
|
+
const result = schema.access.operations[mappedOperation];
|
|
1755
|
+
return !!result && result.allowed !== false;
|
|
1756
|
+
}
|
|
1757
|
+
async function allowsOperation(scope, ctx, policy, kind, entityName, operationName, operationKind) {
|
|
1758
|
+
if (!await evaluateMcpRule(operationRule(policy, operationName), {
|
|
1759
|
+
transport: scope.transport,
|
|
1760
|
+
accessMode: scope.accessMode,
|
|
1761
|
+
ctx
|
|
1762
|
+
})) return false;
|
|
1763
|
+
if (!scopeGateAllows(scopesFromContext(ctx), requiredScopesForOperation(policy, kind, entityName, operationName, operationKind))) return false;
|
|
1764
|
+
return questpieAllows(scope, ctx, kind, entityName, operationName);
|
|
1765
|
+
}
|
|
1766
|
+
async function shouldRegister(scope, policy, kind, entityName, operationName, operationKind) {
|
|
1767
|
+
if (!policy.expose) return false;
|
|
1768
|
+
if (scope.workload && !workloadRequirementForOperation(policy, operationName)) return false;
|
|
1769
|
+
if (scope.workload) return true;
|
|
1770
|
+
return allowsOperation(scope, await scope.getContext(), policy, kind, entityName, operationName, operationKind);
|
|
1771
|
+
}
|
|
1772
|
+
async function registerCrudTools(server, scope, config, catalog) {
|
|
1773
|
+
const maxLimit = config.crud?.maxLimit ?? 100;
|
|
1774
|
+
const collections = scope.app.getCollections();
|
|
1775
|
+
const globals = scope.app.getGlobals();
|
|
1776
|
+
for (const [name, catalogEntry] of catalog.collections) {
|
|
1777
|
+
const collection = collections[name];
|
|
1778
|
+
const policy = catalogEntry.policy;
|
|
1779
|
+
const crud = scope.app.collections[name];
|
|
1780
|
+
for (const operation of COLLECTION_OPERATIONS) {
|
|
1781
|
+
if (!catalogEntry.operations.includes(operation.name)) continue;
|
|
1782
|
+
const toolName = `collections.${name}.${operation.name}`;
|
|
1783
|
+
const inputSchema = collectionOperationSchema(operation.name, collection, policy);
|
|
1784
|
+
const annotations = {
|
|
1785
|
+
readOnlyHint: operation.kind === "read",
|
|
1786
|
+
destructiveHint: operation.kind === "delete",
|
|
1787
|
+
idempotentHint: operation.name === "update" || operation.name === "delete"
|
|
1788
|
+
};
|
|
1789
|
+
const workloadRequirement = workloadRequirementForOperation(policy, operation.name);
|
|
1790
|
+
const workloadIdentity = {
|
|
1791
|
+
kind: "collection",
|
|
1792
|
+
name: toolName,
|
|
1793
|
+
operation: operation.name,
|
|
1794
|
+
intent: operation.kind === "read" && !workloadRequirement?.handoff ? "read" : "effect"
|
|
1795
|
+
};
|
|
1796
|
+
const allows = (ctx) => allowsOperation(scope, ctx, policy, "collection", name, operation.name, operation.kind);
|
|
1797
|
+
if (!await shouldRegister(scope, policy, "collection", name, operation.name, operation.kind)) continue;
|
|
1798
|
+
server.registerTool(toolName, {
|
|
1799
|
+
description: policy.description ?? operation.description(name),
|
|
1800
|
+
inputSchema,
|
|
1801
|
+
annotations
|
|
1802
|
+
}, async (input, extra) => {
|
|
1803
|
+
try {
|
|
1804
|
+
let workloadAuthorization;
|
|
1805
|
+
return await scope.execution.execute({
|
|
1806
|
+
operation: toolName,
|
|
1807
|
+
transport: scope.transport,
|
|
1808
|
+
accessMode: scope.accessMode,
|
|
1809
|
+
input,
|
|
1810
|
+
extra,
|
|
1811
|
+
authorize: async (control) => {
|
|
1812
|
+
workloadAuthorization = scope.workload ? await authorizeWorkload(scope.workload, "call", workloadIdentity, workloadRequirement, control) : void 0;
|
|
1813
|
+
if (scope.workload && !workloadAuthorization) throw new McpAccessDeniedError();
|
|
1814
|
+
const ctx = workloadAuthorization?.context ?? await scope.getContext();
|
|
1815
|
+
if (!await allows(ctx)) throw new McpAccessDeniedError();
|
|
1816
|
+
return ctx;
|
|
1817
|
+
},
|
|
1818
|
+
concurrencyKey: () => workloadAuthorization?.concurrencyKey,
|
|
1819
|
+
invoke: async ({ ctx, signal, requestId, correlationId }) => {
|
|
1820
|
+
const invoke = async () => {
|
|
1821
|
+
return toToolResult(filterCrudResultFields(await operation.execute(crud, input, ctx, maxLimit), policy));
|
|
1822
|
+
};
|
|
1823
|
+
if (scope.workload && workloadAuthorization && workloadRequirement) return executeWorkloadTool(scope.workload, workloadAuthorization, void 0, {
|
|
1824
|
+
signal,
|
|
1825
|
+
requestId,
|
|
1826
|
+
correlationId
|
|
1827
|
+
}, invoke);
|
|
1828
|
+
return invoke();
|
|
1829
|
+
}
|
|
1830
|
+
});
|
|
1831
|
+
} catch (error) {
|
|
1832
|
+
return toToolError(error);
|
|
1833
|
+
}
|
|
1834
|
+
});
|
|
1835
|
+
if (scope.workload) registerWorkloadDiscoveryTool(scope.workload, {
|
|
1836
|
+
name: toolName,
|
|
1837
|
+
description: policy.description ?? operation.description(name),
|
|
1838
|
+
inputSchema: toToolInputJsonSchema(inputSchema),
|
|
1839
|
+
annotations
|
|
1840
|
+
}, workloadIdentity, workloadRequirement, allows);
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
for (const [name, catalogEntry] of catalog.globals) {
|
|
1844
|
+
const global = globals[name];
|
|
1845
|
+
const policy = catalogEntry.policy;
|
|
1846
|
+
const crud = scope.app.globals[name];
|
|
1847
|
+
for (const operation of GLOBAL_OPERATIONS) {
|
|
1848
|
+
if (!catalogEntry.operations.includes(operation.name)) continue;
|
|
1849
|
+
const toolName = `globals.${name}.${operation.name}`;
|
|
1850
|
+
const inputSchema = globalOperationSchema(operation.name, global, policy);
|
|
1851
|
+
const annotations = {
|
|
1852
|
+
readOnlyHint: operation.kind === "read",
|
|
1853
|
+
idempotentHint: operation.name === "update"
|
|
1854
|
+
};
|
|
1855
|
+
const workloadRequirement = workloadRequirementForOperation(policy, operation.name);
|
|
1856
|
+
const workloadIdentity = {
|
|
1857
|
+
kind: "global",
|
|
1858
|
+
name: toolName,
|
|
1859
|
+
operation: operation.name,
|
|
1860
|
+
intent: operation.kind === "read" && !workloadRequirement?.handoff ? "read" : "effect"
|
|
1861
|
+
};
|
|
1862
|
+
const allows = (ctx) => allowsOperation(scope, ctx, policy, "global", name, operation.name, operation.kind);
|
|
1863
|
+
if (!await shouldRegister(scope, policy, "global", name, operation.name, operation.kind)) continue;
|
|
1864
|
+
server.registerTool(toolName, {
|
|
1865
|
+
description: policy.description ?? operation.description(name),
|
|
1866
|
+
inputSchema,
|
|
1867
|
+
annotations
|
|
1868
|
+
}, async (input, extra) => {
|
|
1869
|
+
try {
|
|
1870
|
+
let workloadAuthorization;
|
|
1871
|
+
return await scope.execution.execute({
|
|
1872
|
+
operation: toolName,
|
|
1873
|
+
transport: scope.transport,
|
|
1874
|
+
accessMode: scope.accessMode,
|
|
1875
|
+
input,
|
|
1876
|
+
extra,
|
|
1877
|
+
authorize: async (control) => {
|
|
1878
|
+
workloadAuthorization = scope.workload ? await authorizeWorkload(scope.workload, "call", workloadIdentity, workloadRequirement, control) : void 0;
|
|
1879
|
+
if (scope.workload && !workloadAuthorization) throw new McpAccessDeniedError();
|
|
1880
|
+
const ctx = workloadAuthorization?.context ?? await scope.getContext();
|
|
1881
|
+
if (!await allows(ctx)) throw new McpAccessDeniedError();
|
|
1882
|
+
return ctx;
|
|
1883
|
+
},
|
|
1884
|
+
concurrencyKey: () => workloadAuthorization?.concurrencyKey,
|
|
1885
|
+
invoke: async ({ ctx, signal, requestId, correlationId }) => {
|
|
1886
|
+
const invoke = async () => {
|
|
1887
|
+
return toToolResult(filterCrudResultFields(await operation.execute(crud, input, ctx), policy));
|
|
1888
|
+
};
|
|
1889
|
+
if (scope.workload && workloadAuthorization && workloadRequirement) return executeWorkloadTool(scope.workload, workloadAuthorization, void 0, {
|
|
1890
|
+
signal,
|
|
1891
|
+
requestId,
|
|
1892
|
+
correlationId
|
|
1893
|
+
}, invoke);
|
|
1894
|
+
return invoke();
|
|
1895
|
+
}
|
|
1896
|
+
});
|
|
1897
|
+
} catch (error) {
|
|
1898
|
+
return toToolError(error);
|
|
1899
|
+
}
|
|
1900
|
+
});
|
|
1901
|
+
if (scope.workload) registerWorkloadDiscoveryTool(scope.workload, {
|
|
1902
|
+
name: toolName,
|
|
1903
|
+
description: policy.description ?? operation.description(name),
|
|
1904
|
+
inputSchema: toToolInputJsonSchema(inputSchema),
|
|
1905
|
+
annotations
|
|
1906
|
+
}, workloadIdentity, workloadRequirement, allows);
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
//#endregion
|
|
1912
|
+
//#region src/server/custom-tools.ts
|
|
1913
|
+
function customToolWorkloadIdentity(toolName, tool) {
|
|
1914
|
+
return {
|
|
1915
|
+
kind: "custom",
|
|
1916
|
+
name: toolName,
|
|
1917
|
+
operation: "execute",
|
|
1918
|
+
intent: tool.config.workload?.handoff || tool.config.annotations?.readOnlyHint !== true ? "effect" : "read"
|
|
1919
|
+
};
|
|
1920
|
+
}
|
|
1921
|
+
function customToolDescriptor(toolName, tool) {
|
|
1922
|
+
const inputSchema = customToolInputSchema(tool);
|
|
1923
|
+
const discoveryOutputSchema = toToolOutputJsonSchema(jsonSchemaCompatibleSchema(tool.config.outputSchema));
|
|
1924
|
+
return {
|
|
1925
|
+
name: toolName,
|
|
1926
|
+
title: tool.config.title,
|
|
1927
|
+
description: tool.config.description,
|
|
1928
|
+
inputSchema: toToolInputJsonSchema(inputSchema),
|
|
1929
|
+
...discoveryOutputSchema ? { outputSchema: discoveryOutputSchema } : {},
|
|
1930
|
+
annotations: tool.config.annotations,
|
|
1931
|
+
_meta: tool.config["_meta"]
|
|
1932
|
+
};
|
|
1933
|
+
}
|
|
1934
|
+
function customToolInputSchema(tool) {
|
|
1935
|
+
return jsonSchemaCompatibleSchema(tool.config.inputSchema) ?? z.object({}).strict();
|
|
1936
|
+
}
|
|
1937
|
+
function customToolAllows(scope, tool, ctx) {
|
|
1938
|
+
const requiredScopes = normalizeRequiredScopes(tool.config.scopes);
|
|
1939
|
+
return Promise.resolve(evaluateMcpRule(tool.config.access, {
|
|
1940
|
+
transport: scope.transport,
|
|
1941
|
+
accessMode: scope.accessMode,
|
|
1942
|
+
ctx
|
|
1943
|
+
})).then((allowed) => allowed && scopeGateAllows(scopesFromContext(ctx), requiredScopes));
|
|
1944
|
+
}
|
|
1945
|
+
async function validateCustomToolOutput(tool, result) {
|
|
1946
|
+
if (!tool.config.outputSchema || result.isError) return result;
|
|
1947
|
+
const parsed = await tool.config.outputSchema.safeParseAsync(result.structuredContent);
|
|
1948
|
+
if (!parsed.success) throw new Error("MCP custom tool output failed validation");
|
|
1949
|
+
return {
|
|
1950
|
+
...result,
|
|
1951
|
+
structuredContent: parsed.data
|
|
1952
|
+
};
|
|
1953
|
+
}
|
|
1954
|
+
async function executeCustomToolCall(scope, toolName, tool, input, extra) {
|
|
1955
|
+
const workloadIdentity = customToolWorkloadIdentity(toolName, tool);
|
|
1956
|
+
let workloadAuthorization;
|
|
1957
|
+
return scope.execution.execute({
|
|
1958
|
+
operation: toolName,
|
|
1959
|
+
transport: scope.transport,
|
|
1960
|
+
accessMode: scope.accessMode,
|
|
1961
|
+
input,
|
|
1962
|
+
extra,
|
|
1963
|
+
authorize: async (control) => {
|
|
1964
|
+
workloadAuthorization = scope.workload ? await authorizeWorkload(scope.workload, "call", workloadIdentity, tool.config.workload, control) : void 0;
|
|
1965
|
+
if (scope.workload && !workloadAuthorization) throw new McpAccessDeniedError();
|
|
1966
|
+
const callCtx = workloadAuthorization?.context ?? await scope.getContext();
|
|
1967
|
+
if (!await customToolAllows(scope, tool, callCtx)) throw new McpAccessDeniedError();
|
|
1968
|
+
return callCtx;
|
|
1969
|
+
},
|
|
1970
|
+
concurrencyKey: () => workloadAuthorization?.concurrencyKey,
|
|
1971
|
+
invoke: async ({ ctx, signal, requestId, correlationId }) => {
|
|
1972
|
+
const parsedInput = tool.config.inputSchema ? await tool.config.inputSchema.parseAsync(input) : await z.object({}).strict().parseAsync(input);
|
|
1973
|
+
const invoke = () => tool.handler({
|
|
1974
|
+
input: parsedInput,
|
|
1975
|
+
ctx,
|
|
1976
|
+
transport: scope.transport,
|
|
1977
|
+
accessMode: scope.accessMode,
|
|
1978
|
+
request: scope.request,
|
|
1979
|
+
signal,
|
|
1980
|
+
requestId,
|
|
1981
|
+
correlationId
|
|
1982
|
+
});
|
|
1983
|
+
if (scope.workload && workloadAuthorization && tool.config.workload) return validateCustomToolOutput(tool, await executeWorkloadTool(scope.workload, workloadAuthorization, void 0, {
|
|
1984
|
+
signal,
|
|
1985
|
+
requestId,
|
|
1986
|
+
correlationId
|
|
1987
|
+
}, invoke));
|
|
1988
|
+
return validateCustomToolOutput(tool, await invoke());
|
|
1989
|
+
}
|
|
1990
|
+
});
|
|
1991
|
+
}
|
|
1992
|
+
async function registerCustomTools(server, scope, catalog) {
|
|
1993
|
+
for (const [toolName, { tool }] of catalog.customTools) {
|
|
1994
|
+
const workloadIdentity = customToolWorkloadIdentity(toolName, tool);
|
|
1995
|
+
if (scope.workload && !tool.config.workload) continue;
|
|
1996
|
+
const allows = (ctx) => customToolAllows(scope, tool, ctx);
|
|
1997
|
+
if (!scope.workload) {
|
|
1998
|
+
if (!await allows(await scope.getContext())) continue;
|
|
1999
|
+
}
|
|
2000
|
+
const inputSchema = customToolInputSchema(tool);
|
|
2001
|
+
const outputSchema = jsonSchemaCompatibleSchema(tool.config.outputSchema);
|
|
2002
|
+
server.registerTool(toolName, {
|
|
2003
|
+
title: tool.config.title,
|
|
2004
|
+
description: tool.config.description,
|
|
2005
|
+
inputSchema,
|
|
2006
|
+
outputSchema,
|
|
2007
|
+
annotations: tool.config.annotations,
|
|
2008
|
+
_meta: tool.config["_meta"]
|
|
2009
|
+
}, async (input, extra) => {
|
|
2010
|
+
try {
|
|
2011
|
+
return await executeCustomToolCall(scope, toolName, tool, input, extra);
|
|
2012
|
+
} catch (error) {
|
|
2013
|
+
return toToolError(error);
|
|
2014
|
+
}
|
|
2015
|
+
});
|
|
2016
|
+
if (scope.workload) registerWorkloadDiscoveryTool(scope.workload, customToolDescriptor(toolName, tool), workloadIdentity, tool.config.workload, allows);
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
//#endregion
|
|
2021
|
+
//#region src/server/resources.ts
|
|
2022
|
+
function fallbackExtra(requestId) {
|
|
2023
|
+
return {
|
|
2024
|
+
signal: new AbortController().signal,
|
|
2025
|
+
requestId
|
|
2026
|
+
};
|
|
2027
|
+
}
|
|
2028
|
+
function executeResource(scope, operation, input, extra, invoke) {
|
|
2029
|
+
return scope.execution.execute({
|
|
2030
|
+
operation,
|
|
2031
|
+
transport: scope.transport,
|
|
2032
|
+
accessMode: scope.accessMode,
|
|
2033
|
+
input,
|
|
2034
|
+
extra,
|
|
2035
|
+
authorize: scope.getContext,
|
|
2036
|
+
invoke
|
|
2037
|
+
});
|
|
2038
|
+
}
|
|
2039
|
+
async function collectionSchema(scope, name) {
|
|
2040
|
+
const ctx = await scope.getContext();
|
|
2041
|
+
const collection = scope.app.getCollections()[name];
|
|
2042
|
+
return introspectCollection(collection, {
|
|
2043
|
+
db: ctx.db ?? scope.app.db,
|
|
2044
|
+
session: ctx.session,
|
|
2045
|
+
locale: ctx.locale,
|
|
2046
|
+
accessMode: scope.accessMode,
|
|
2047
|
+
stage: ctx.stage
|
|
2048
|
+
}, scope.app);
|
|
2049
|
+
}
|
|
2050
|
+
async function globalSchema(scope, name) {
|
|
2051
|
+
const ctx = await scope.getContext();
|
|
2052
|
+
const global = scope.app.getGlobals()[name];
|
|
2053
|
+
return introspectGlobal(global, {
|
|
2054
|
+
db: ctx.db ?? scope.app.db,
|
|
2055
|
+
session: ctx.session,
|
|
2056
|
+
locale: ctx.locale,
|
|
2057
|
+
accessMode: scope.accessMode,
|
|
2058
|
+
stage: ctx.stage
|
|
2059
|
+
}, scope.app);
|
|
2060
|
+
}
|
|
2061
|
+
async function visibleCollections(scope, catalog) {
|
|
2062
|
+
const entries = [];
|
|
2063
|
+
for (const name of catalog.resources.collections) {
|
|
2064
|
+
const catalogEntry = catalog.collections.get(name);
|
|
2065
|
+
if (!catalogEntry) continue;
|
|
2066
|
+
const policy = catalogEntry.policy;
|
|
2067
|
+
const operation = catalogEntry.operations.find((candidate) => [
|
|
2068
|
+
"list",
|
|
2069
|
+
"count",
|
|
2070
|
+
"get"
|
|
2071
|
+
].includes(candidate)) ?? "list";
|
|
2072
|
+
const ctx = await scope.getContext();
|
|
2073
|
+
if (!await evaluateMcpRule(operationRule(policy, operation), {
|
|
2074
|
+
transport: scope.transport,
|
|
2075
|
+
accessMode: scope.accessMode,
|
|
2076
|
+
ctx
|
|
2077
|
+
}) || !scopeGateAllows(scopesFromContext(ctx), requiredScopesForOperation(policy, "collection", name, operation, "read"))) continue;
|
|
2078
|
+
const schema = await collectionSchema(scope, name);
|
|
2079
|
+
if (scope.accessMode === "system" || schema.access.visible && schema.access.operations.read.allowed !== false) entries.push(filterEntitySchemaFields(schema, policy));
|
|
2080
|
+
}
|
|
2081
|
+
return entries;
|
|
2082
|
+
}
|
|
2083
|
+
async function visibleGlobals(scope, catalog) {
|
|
2084
|
+
const entries = [];
|
|
2085
|
+
for (const name of catalog.resources.globals) {
|
|
2086
|
+
const catalogEntry = catalog.globals.get(name);
|
|
2087
|
+
if (!catalogEntry) continue;
|
|
2088
|
+
const policy = catalogEntry.policy;
|
|
2089
|
+
const ctx = await scope.getContext();
|
|
2090
|
+
if (!await evaluateMcpRule(operationRule(policy, "get"), {
|
|
2091
|
+
transport: scope.transport,
|
|
2092
|
+
accessMode: scope.accessMode,
|
|
2093
|
+
ctx
|
|
2094
|
+
}) || !scopeGateAllows(scopesFromContext(ctx), requiredScopesForOperation(policy, "global", name, "get", "read"))) continue;
|
|
2095
|
+
const schema = await globalSchema(scope, name);
|
|
2096
|
+
if (scope.accessMode === "system" || schema.access.visible && schema.access.operations.read.allowed !== false) entries.push(filterEntitySchemaFields(schema, policy));
|
|
2097
|
+
}
|
|
2098
|
+
return entries;
|
|
2099
|
+
}
|
|
2100
|
+
function routeToolInputSchema(route$1) {
|
|
2101
|
+
if (!isJsonRoute(route$1.definition)) return void 0;
|
|
2102
|
+
const params = route$1.params ?? [];
|
|
2103
|
+
if (params.length === 0) return route$1.definition.schema;
|
|
2104
|
+
const shape = {};
|
|
2105
|
+
for (const param of params) shape[param] = z.string();
|
|
2106
|
+
return z.object({
|
|
2107
|
+
params: z.object(shape),
|
|
2108
|
+
input: route$1.definition.schema
|
|
2109
|
+
});
|
|
2110
|
+
}
|
|
2111
|
+
async function routeCatalog(scope, catalog) {
|
|
2112
|
+
const ctx = await scope.getContext();
|
|
2113
|
+
const services = extractAppServices(scope.app, {
|
|
2114
|
+
db: ctx.db ?? scope.app.db,
|
|
2115
|
+
session: ctx.session,
|
|
2116
|
+
locale: ctx.locale,
|
|
2117
|
+
accessMode: ctx.accessMode,
|
|
2118
|
+
principal: ctx.principal
|
|
2119
|
+
});
|
|
2120
|
+
const routes = [];
|
|
2121
|
+
for (const routeKey of catalog.resources.routes) {
|
|
2122
|
+
const catalogEntry = catalog.routes.get(routeKey);
|
|
2123
|
+
if (!catalogEntry) continue;
|
|
2124
|
+
const route$1 = catalogEntry.route;
|
|
2125
|
+
if (!isJsonRoute(route$1.definition)) continue;
|
|
2126
|
+
const policy = catalogEntry.policy;
|
|
2127
|
+
if (!await evaluateMcpRule(operationRule(policy, "execute"), {
|
|
2128
|
+
transport: scope.transport,
|
|
2129
|
+
accessMode: scope.accessMode,
|
|
2130
|
+
ctx
|
|
2131
|
+
}) || !scopeGateAllows(scopesFromContext(ctx), requiredScopesForOperation(policy, "route", routeKey, "execute", "invoke"))) continue;
|
|
2132
|
+
const params = Object.fromEntries((route$1.params ?? []).map((param) => [param, ""]));
|
|
2133
|
+
if (!await evaluateRouteAccess(route$1.definition.access, {
|
|
2134
|
+
...services,
|
|
2135
|
+
...ctx["~contextExtensions"] ?? {},
|
|
2136
|
+
locale: ctx.locale,
|
|
2137
|
+
request: scope.request,
|
|
2138
|
+
params
|
|
2139
|
+
})) continue;
|
|
2140
|
+
routes.push({
|
|
2141
|
+
key: route$1.key,
|
|
2142
|
+
path: route$1.path,
|
|
2143
|
+
methods: route$1.methods,
|
|
2144
|
+
mode: route$1.mode,
|
|
2145
|
+
params: route$1.params,
|
|
2146
|
+
meta: route$1.meta,
|
|
2147
|
+
hasInputSchema: isJsonRoute(route$1.definition),
|
|
2148
|
+
hasOutputSchema: isJsonRoute(route$1.definition) && !!route$1.definition.outputSchema,
|
|
2149
|
+
inputSchema: toJsonSchema(route$1.definition.schema),
|
|
2150
|
+
outputSchema: toJsonSchema(route$1.definition.outputSchema),
|
|
2151
|
+
toolInputSchema: toJsonSchema(routeToolInputSchema(route$1))
|
|
2152
|
+
});
|
|
2153
|
+
}
|
|
2154
|
+
return routes;
|
|
2155
|
+
}
|
|
2156
|
+
function resourceEntry(uri, name, description) {
|
|
2157
|
+
return {
|
|
2158
|
+
uri,
|
|
2159
|
+
name,
|
|
2160
|
+
mimeType: "application/json",
|
|
2161
|
+
description
|
|
2162
|
+
};
|
|
2163
|
+
}
|
|
2164
|
+
function registerSchemaResources(server, scope, catalog) {
|
|
2165
|
+
if (catalog.resources.collections.size > 0) server.registerResource("questpie-schema-collections", "questpie://schema/collections", {
|
|
2166
|
+
title: "QUESTPIE collections schema",
|
|
2167
|
+
mimeType: "application/json"
|
|
2168
|
+
}, async (uri, extra) => executeResource(scope, "resources.collections.read", { uri: uri.toString() }, extra, async () => jsonResource(uri.toString(), await visibleCollections(scope, catalog))));
|
|
2169
|
+
if (catalog.resources.collections.size > 0) server.registerResource("questpie-schema-collection", new ResourceTemplate("questpie://schema/collections/{name}", {
|
|
2170
|
+
list: async (extra) => executeResource(scope, "resources.collections.list", {}, extra, async () => ({ resources: (await visibleCollections(scope, catalog)).map((schema) => resourceEntry(`questpie://schema/collections/${schema.name}`, `Collection ${schema.name}`, schema.description ? String(schema.description) : void 0)) })),
|
|
2171
|
+
complete: { name: async (value) => executeResource(scope, "resources.collections.complete", { value }, fallbackExtra("resources.collections.complete"), async () => (await visibleCollections(scope, catalog)).map((schema) => schema.name).filter((name) => name.startsWith(value))) }
|
|
2172
|
+
}), {
|
|
2173
|
+
title: "QUESTPIE collection schema",
|
|
2174
|
+
mimeType: "application/json"
|
|
2175
|
+
}, async (uri, variables, extra) => executeResource(scope, "resources.collection.read", {
|
|
2176
|
+
uri: uri.toString(),
|
|
2177
|
+
variables
|
|
2178
|
+
}, extra, async () => {
|
|
2179
|
+
const name = String(variables.name);
|
|
2180
|
+
const schema = (await visibleCollections(scope, catalog)).find((item) => item.name === name);
|
|
2181
|
+
if (!schema) throw new Error("MCP resource is not exposed");
|
|
2182
|
+
return jsonResource(uri.toString(), schema);
|
|
2183
|
+
}));
|
|
2184
|
+
if (catalog.resources.globals.size > 0) server.registerResource("questpie-schema-globals", "questpie://schema/globals", {
|
|
2185
|
+
title: "QUESTPIE globals schema",
|
|
2186
|
+
mimeType: "application/json"
|
|
2187
|
+
}, async (uri, extra) => executeResource(scope, "resources.globals.read", { uri: uri.toString() }, extra, async () => jsonResource(uri.toString(), await visibleGlobals(scope, catalog))));
|
|
2188
|
+
if (catalog.resources.globals.size > 0) server.registerResource("questpie-schema-global", new ResourceTemplate("questpie://schema/globals/{name}", {
|
|
2189
|
+
list: async (extra) => executeResource(scope, "resources.globals.list", {}, extra, async () => ({ resources: (await visibleGlobals(scope, catalog)).map((schema) => resourceEntry(`questpie://schema/globals/${schema.name}`, `Global ${schema.name}`, schema.description ? String(schema.description) : void 0)) })),
|
|
2190
|
+
complete: { name: async (value) => executeResource(scope, "resources.globals.complete", { value }, fallbackExtra("resources.globals.complete"), async () => (await visibleGlobals(scope, catalog)).map((schema) => schema.name).filter((name) => name.startsWith(value))) }
|
|
2191
|
+
}), {
|
|
2192
|
+
title: "QUESTPIE global schema",
|
|
2193
|
+
mimeType: "application/json"
|
|
2194
|
+
}, async (uri, variables, extra) => executeResource(scope, "resources.global.read", {
|
|
2195
|
+
uri: uri.toString(),
|
|
2196
|
+
variables
|
|
2197
|
+
}, extra, async () => {
|
|
2198
|
+
const name = String(variables.name);
|
|
2199
|
+
const schema = (await visibleGlobals(scope, catalog)).find((item) => item.name === name);
|
|
2200
|
+
if (!schema) throw new Error("MCP resource is not exposed");
|
|
2201
|
+
return jsonResource(uri.toString(), schema);
|
|
2202
|
+
}));
|
|
2203
|
+
if (catalog.resources.routes.size > 0) server.registerResource("questpie-schema-routes", "questpie://schema/routes", {
|
|
2204
|
+
title: "QUESTPIE route schema",
|
|
2205
|
+
mimeType: "application/json"
|
|
2206
|
+
}, async (uri, extra) => executeResource(scope, "resources.routes.read", { uri: uri.toString() }, extra, async () => jsonResource(uri.toString(), await routeCatalog(scope, catalog))));
|
|
2207
|
+
if (catalog.resources.routes.size > 0) server.registerResource("questpie-schema-route", new ResourceTemplate("questpie://schema/routes/{key}", {
|
|
2208
|
+
list: async (extra) => executeResource(scope, "resources.routes.list", {}, extra, async () => ({ resources: (await routeCatalog(scope, catalog)).map((route$1) => resourceEntry(`questpie://schema/routes/${encodeURIComponent(route$1.key)}`, `Route ${route$1.key}`, route$1.meta?.description)) })),
|
|
2209
|
+
complete: { key: async (value) => executeResource(scope, "resources.routes.complete", { value }, fallbackExtra("resources.routes.complete"), async () => (await routeCatalog(scope, catalog)).map((route$1) => route$1.key).filter((key) => key.startsWith(value))) }
|
|
2210
|
+
}), {
|
|
2211
|
+
title: "QUESTPIE route schema",
|
|
2212
|
+
mimeType: "application/json"
|
|
2213
|
+
}, async (uri, variables, extra) => executeResource(scope, "resources.route.read", {
|
|
2214
|
+
uri: uri.toString(),
|
|
2215
|
+
variables
|
|
2216
|
+
}, extra, async () => {
|
|
2217
|
+
const key = decodeURIComponent(String(variables.key));
|
|
2218
|
+
const route$1 = (await routeCatalog(scope, catalog)).find((item) => item.key === key);
|
|
2219
|
+
if (!route$1) throw new Error("MCP resource is not exposed");
|
|
2220
|
+
return jsonResource(uri.toString(), route$1);
|
|
2221
|
+
}));
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
//#endregion
|
|
2225
|
+
//#region src/server/route-tools.ts
|
|
2226
|
+
function routeInputSchema(route$1) {
|
|
2227
|
+
if (!isJsonRoute(route$1.definition)) return z.object({});
|
|
2228
|
+
if (route$1.params.length === 0) return jsonSchemaCompatibleSchema(route$1.definition.schema) ?? z.object({});
|
|
2229
|
+
const paramsShape = {};
|
|
2230
|
+
for (const param of route$1.params) paramsShape[param] = z.string();
|
|
2231
|
+
return z.object({
|
|
2232
|
+
params: z.object(paramsShape).strict(),
|
|
2233
|
+
input: jsonSchemaCompatibleSchema(route$1.definition.schema) ?? z.object({})
|
|
2234
|
+
}).strict();
|
|
2235
|
+
}
|
|
2236
|
+
async function registerRouteTools(server, scope, catalog) {
|
|
2237
|
+
for (const [routeKey, catalogEntry] of catalog.routes) {
|
|
2238
|
+
const route$1 = catalogEntry.route;
|
|
2239
|
+
const definition = route$1.definition;
|
|
2240
|
+
if (!isJsonRoute(definition)) continue;
|
|
2241
|
+
const mcpMeta = route$1.meta?.mcp;
|
|
2242
|
+
if (!mcpMeta) continue;
|
|
2243
|
+
const policy = catalogEntry.policy;
|
|
2244
|
+
const name = catalogEntry.toolName;
|
|
2245
|
+
const workloadRequirement = workloadRequirementForOperation(policy, "execute");
|
|
2246
|
+
if (scope.workload && !workloadRequirement) continue;
|
|
2247
|
+
const inputSchema = routeInputSchema(route$1);
|
|
2248
|
+
const outputSchema = jsonSchemaCompatibleSchema(definition.outputSchema);
|
|
2249
|
+
const annotations = mcpMeta.annotations;
|
|
2250
|
+
const workloadIdentity = {
|
|
2251
|
+
kind: "route",
|
|
2252
|
+
name,
|
|
2253
|
+
operation: "execute",
|
|
2254
|
+
intent: workloadRequirement?.handoff || annotations?.readOnlyHint !== true ? "effect" : "read"
|
|
2255
|
+
};
|
|
2256
|
+
const allows = async (ctx) => {
|
|
2257
|
+
if (!(await evaluateMcpRule(operationRule(policy, "execute"), {
|
|
2258
|
+
transport: scope.transport,
|
|
2259
|
+
accessMode: scope.accessMode,
|
|
2260
|
+
ctx
|
|
2261
|
+
}) && scopeGateAllows(scopesFromContext(ctx), requiredScopesForOperation(policy, "route", routeKey, "execute", "invoke")))) return false;
|
|
2262
|
+
const services = extractAppServices(scope.app, {
|
|
2263
|
+
db: ctx.db ?? scope.app.db,
|
|
2264
|
+
session: ctx.session,
|
|
2265
|
+
locale: ctx.locale,
|
|
2266
|
+
accessMode: ctx.accessMode,
|
|
2267
|
+
principal: ctx.principal
|
|
2268
|
+
});
|
|
2269
|
+
const params = Object.fromEntries(route$1.params.map((param) => [param, ""]));
|
|
2270
|
+
return evaluateRouteAccess(definition.access, {
|
|
2271
|
+
...services,
|
|
2272
|
+
...ctx["~contextExtensions"] ?? {},
|
|
2273
|
+
locale: ctx.locale,
|
|
2274
|
+
request: scope.request,
|
|
2275
|
+
params
|
|
2276
|
+
});
|
|
2277
|
+
};
|
|
2278
|
+
if (!scope.workload) {
|
|
2279
|
+
if (!await allows(await scope.getContext())) continue;
|
|
2280
|
+
}
|
|
2281
|
+
server.registerTool(name, {
|
|
2282
|
+
title: mcpMeta.title ?? route$1.meta?.title,
|
|
2283
|
+
description: mcpMeta.description ?? route$1.meta?.description,
|
|
2284
|
+
inputSchema,
|
|
2285
|
+
outputSchema,
|
|
2286
|
+
annotations
|
|
2287
|
+
}, async (input, extra) => {
|
|
2288
|
+
try {
|
|
2289
|
+
let workloadAuthorization;
|
|
2290
|
+
return await scope.execution.execute({
|
|
2291
|
+
operation: name,
|
|
2292
|
+
transport: scope.transport,
|
|
2293
|
+
accessMode: scope.accessMode,
|
|
2294
|
+
input,
|
|
2295
|
+
extra,
|
|
2296
|
+
authorize: async (control) => {
|
|
2297
|
+
workloadAuthorization = scope.workload ? await authorizeWorkload(scope.workload, "call", workloadIdentity, workloadRequirement, control) : void 0;
|
|
2298
|
+
if (scope.workload && !workloadAuthorization) throw new McpAccessDeniedError();
|
|
2299
|
+
const routeCtx = workloadAuthorization?.context ?? await scope.getContext();
|
|
2300
|
+
if (!await allows(routeCtx)) throw new McpAccessDeniedError();
|
|
2301
|
+
return routeCtx;
|
|
2302
|
+
},
|
|
2303
|
+
concurrencyKey: () => workloadAuthorization?.concurrencyKey,
|
|
2304
|
+
invoke: async ({ ctx, signal, requestId, correlationId }) => {
|
|
2305
|
+
const invocation = route$1.params.length > 0 ? z.object({
|
|
2306
|
+
input: z.unknown(),
|
|
2307
|
+
params: z.record(z.string(), z.string())
|
|
2308
|
+
}).parse(input) : {
|
|
2309
|
+
input,
|
|
2310
|
+
params: {}
|
|
2311
|
+
};
|
|
2312
|
+
const requestContext = toRequestContext(ctx, scope.accessMode);
|
|
2313
|
+
const executionRequest = new Request(scope.request?.url ?? "http://questpie.local/mcp", {
|
|
2314
|
+
method: scope.request?.method ?? "POST",
|
|
2315
|
+
headers: scope.request?.headers,
|
|
2316
|
+
signal
|
|
2317
|
+
});
|
|
2318
|
+
const invoke = async () => toToolResult(await executeJsonRoute(scope.app, definition, invocation.input, requestContext, executionRequest, invocation.params));
|
|
2319
|
+
if (scope.workload && workloadAuthorization && workloadRequirement) return executeWorkloadTool(scope.workload, workloadAuthorization, void 0, {
|
|
2320
|
+
signal,
|
|
2321
|
+
requestId,
|
|
2322
|
+
correlationId
|
|
2323
|
+
}, invoke);
|
|
2324
|
+
return invoke();
|
|
2325
|
+
}
|
|
2326
|
+
});
|
|
2327
|
+
} catch (error) {
|
|
2328
|
+
return toToolError(error);
|
|
2329
|
+
}
|
|
2330
|
+
});
|
|
2331
|
+
if (scope.workload) {
|
|
2332
|
+
const discoveryOutputSchema = toToolOutputJsonSchema(outputSchema);
|
|
2333
|
+
registerWorkloadDiscoveryTool(scope.workload, {
|
|
2334
|
+
name,
|
|
2335
|
+
title: mcpMeta.title ?? route$1.meta?.title,
|
|
2336
|
+
description: mcpMeta.description ?? route$1.meta?.description,
|
|
2337
|
+
inputSchema: toToolInputJsonSchema(inputSchema),
|
|
2338
|
+
...discoveryOutputSchema ? { outputSchema: discoveryOutputSchema } : {},
|
|
2339
|
+
annotations
|
|
2340
|
+
}, workloadIdentity, workloadRequirement, allows);
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
//#endregion
|
|
2346
|
+
//#region src/server/create-server.ts
|
|
2347
|
+
async function createServer(app, options, workload) {
|
|
2348
|
+
const release = workload ? createWorkloadMcpRelease(app, options.config) : options.config ? createIsolatedMcpRelease(app, options.config) : getAppMcpRelease(app);
|
|
2349
|
+
const { config, catalog } = release;
|
|
2350
|
+
const transport = options.transport ?? "http";
|
|
2351
|
+
const accessMode = transport === "stdio" ? resolveStdioAccessMode(options, config.stdio?.trustedMaintenance) : "user";
|
|
2352
|
+
const scope = createRuntimeScope(app, {
|
|
2353
|
+
...options,
|
|
2354
|
+
transport,
|
|
2355
|
+
accessMode,
|
|
2356
|
+
config
|
|
2357
|
+
}, release.execution, workload);
|
|
2358
|
+
const server = new McpServer({
|
|
2359
|
+
name: config.name ?? "questpie",
|
|
2360
|
+
version: config.version ?? "0.0.0"
|
|
2361
|
+
}, { capabilities: {
|
|
2362
|
+
tools: {},
|
|
2363
|
+
...catalog.resources.collections.size > 0 || catalog.resources.globals.size > 0 || catalog.resources.routes.size > 0 ? { resources: {} } : {}
|
|
2364
|
+
} });
|
|
2365
|
+
await registerCrudTools(server, scope, config, catalog);
|
|
2366
|
+
await registerRouteTools(server, scope, catalog);
|
|
2367
|
+
registerSchemaResources(server, scope, catalog);
|
|
2368
|
+
await registerCustomTools(server, scope, catalog);
|
|
2369
|
+
if (workload) server.server.setRequestHandler(ListToolsRequestSchema, (_request, extra) => listWorkloadTools(workload, release.execution, extra));
|
|
2370
|
+
else if ([...catalog.collections.values()].every((entry) => entry.operations.length === 0) && [...catalog.globals.values()].every((entry) => entry.operations.length === 0) && catalog.routes.size === 0 && catalog.customTools.size === 0) server.server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: [] }));
|
|
2371
|
+
return server;
|
|
2372
|
+
}
|
|
2373
|
+
function resolveStdioAccessMode(options, trustedMaintenance) {
|
|
2374
|
+
if (trustedMaintenance === true) {
|
|
2375
|
+
if (options.ctx !== void 0 || options.request !== void 0 || options.accessMode === "user") throw new Error("Trusted-maintenance stdio cannot be combined with request authority");
|
|
2376
|
+
return "system";
|
|
2377
|
+
}
|
|
2378
|
+
if (options.ctx?.accessMode === "user" && options.accessMode !== "system") return "user";
|
|
2379
|
+
throw new Error("Stdio MCP requires explicit authority: provide a user-mode ctx or configure stdio.trustedMaintenance");
|
|
2380
|
+
}
|
|
2381
|
+
async function createMcpServer(app, options = {}) {
|
|
2382
|
+
return createServer(app, options);
|
|
2383
|
+
}
|
|
2384
|
+
async function createWorkloadMcpServer(app, options) {
|
|
2385
|
+
const boundary = createWorkloadMcpBoundary(options);
|
|
2386
|
+
return createServer(app, {
|
|
2387
|
+
transport: "workload",
|
|
2388
|
+
accessMode: "user",
|
|
2389
|
+
config: options.config
|
|
2390
|
+
}, boundary);
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
//#endregion
|
|
2394
|
+
export { validateMcpPrincipal as C, scopesFromContext as S, resolveMcpCatalog as _, customToolWorkloadIdentity as a, requiredScopesForOperation as b, createWorkloadMcpBoundary as c, getAppMcpRelease as d, McpAccessDeniedError as f, toMcpToolError as g, mcpPublicErrorCode as h, customToolDescriptor as i, createRuntimeScope as l, assertBoundedMcpValue as m, createWorkloadMcpServer as n, executeCustomToolCall as o, McpPublicError as p, customToolAllows as r, authorizeWorkload as s, createMcpServer as t, createWorkloadMcpRelease as u, defaultOperationScope as v, mcpTool as w, scopeGateAllows as x, normalizeRequiredScopes as y };
|