@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
|
@@ -1,1014 +0,0 @@
|
|
|
1
|
-
import { createAdapterContext, createContextFactory, defaultOperationScope, evaluateRouteAccess, executeJsonRoute, extractAppServices, introspectCollection, introspectGlobal, introspectRoutes, isJsonRoute, umbrellaScopeFor } from "questpie";
|
|
2
|
-
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
-
import { z } from "zod";
|
|
4
|
-
|
|
5
|
-
//#region src/server/zod-json-schema.ts
|
|
6
|
-
function jsonSchemaCompatibleSchema(schema) {
|
|
7
|
-
if (!schema) return void 0;
|
|
8
|
-
if (canConvertToJsonSchema(schema)) return schema;
|
|
9
|
-
const def = schema._def;
|
|
10
|
-
switch (def?.type) {
|
|
11
|
-
case "date": return z.string().describe("ISO date or date-time string");
|
|
12
|
-
case "optional": return jsonSchemaCompatibleSchema(def.innerType)?.optional() ?? z.unknown();
|
|
13
|
-
case "nullable": return jsonSchemaCompatibleSchema(def.innerType)?.nullable() ?? z.unknown();
|
|
14
|
-
case "default":
|
|
15
|
-
case "prefault":
|
|
16
|
-
case "catch": return jsonSchemaCompatibleSchema(def.innerType)?.optional() ?? z.unknown();
|
|
17
|
-
case "array": return z.array(jsonSchemaCompatibleSchema(def.element) ?? z.unknown());
|
|
18
|
-
case "record": {
|
|
19
|
-
const keySchema = jsonSchemaCompatibleSchema(def.keyType);
|
|
20
|
-
return z.record(keySchema instanceof z.ZodString ? keySchema : z.string(), jsonSchemaCompatibleSchema(def.valueType) ?? z.unknown());
|
|
21
|
-
}
|
|
22
|
-
case "union": {
|
|
23
|
-
const options = Array.isArray(def.options) ? def.options.map((option) => jsonSchemaCompatibleSchema(option) ?? z.unknown()) : [];
|
|
24
|
-
if (options.length === 0) return z.unknown();
|
|
25
|
-
if (options.length === 1) return options[0];
|
|
26
|
-
return z.union(options);
|
|
27
|
-
}
|
|
28
|
-
case "object": {
|
|
29
|
-
const shape = schema.shape;
|
|
30
|
-
const nextShape = {};
|
|
31
|
-
for (const [key, value] of Object.entries(shape)) nextShape[key] = jsonSchemaCompatibleSchema(value) ?? z.unknown();
|
|
32
|
-
const objectSchema = z.object(nextShape);
|
|
33
|
-
if (def.catchall && def.catchall._def?.type !== "never") return objectSchema.catchall(jsonSchemaCompatibleSchema(def.catchall) ?? z.unknown());
|
|
34
|
-
return objectSchema;
|
|
35
|
-
}
|
|
36
|
-
default: return z.unknown();
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
function toJsonSchema(schema) {
|
|
40
|
-
const compatible = jsonSchemaCompatibleSchema(schema);
|
|
41
|
-
if (!compatible) return void 0;
|
|
42
|
-
try {
|
|
43
|
-
return z.toJSONSchema(compatible);
|
|
44
|
-
} catch {
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
function canConvertToJsonSchema(schema) {
|
|
49
|
-
try {
|
|
50
|
-
z.toJSONSchema(schema);
|
|
51
|
-
return true;
|
|
52
|
-
} catch {
|
|
53
|
-
return false;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
//#endregion
|
|
58
|
-
//#region src/server/field-policy.ts
|
|
59
|
-
const recordSchema = z.record(z.string(), z.unknown());
|
|
60
|
-
const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
|
|
61
|
-
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
62
|
-
const hasFieldPolicy = (policy) => (policy.fields?.include?.length ?? 0) > 0 || (policy.fields?.exclude?.length ?? 0) > 0;
|
|
63
|
-
const allowedKeysFor = (keys, policy) => {
|
|
64
|
-
const initial = policy.fields?.include?.length ? policy.fields.include : Array.from(keys);
|
|
65
|
-
const allowed = new Set(initial);
|
|
66
|
-
for (const key of policy.fields?.exclude ?? []) allowed.delete(key);
|
|
67
|
-
return allowed;
|
|
68
|
-
};
|
|
69
|
-
function filterRecordFields(value, policy) {
|
|
70
|
-
if (!hasFieldPolicy(policy) || !isRecord(value)) return value;
|
|
71
|
-
const allowed = allowedKeysFor(Object.keys(value), policy);
|
|
72
|
-
const next = {};
|
|
73
|
-
for (const key of allowed) if (hasOwn(value, key)) next[key] = value[key];
|
|
74
|
-
return next;
|
|
75
|
-
}
|
|
76
|
-
function filterCrudResultFields(value, policy) {
|
|
77
|
-
if (!hasFieldPolicy(policy)) return value;
|
|
78
|
-
if (Array.isArray(value)) return value.map((item) => filterCrudResultFields(item, policy));
|
|
79
|
-
if (!isRecord(value)) return value;
|
|
80
|
-
if (Array.isArray(value.docs)) return {
|
|
81
|
-
...value,
|
|
82
|
-
docs: value.docs.map((doc) => filterRecordFields(doc, policy))
|
|
83
|
-
};
|
|
84
|
-
if (isRecord(value.data)) return {
|
|
85
|
-
...value,
|
|
86
|
-
data: filterRecordFields(value.data, policy)
|
|
87
|
-
};
|
|
88
|
-
return filterRecordFields(value, policy);
|
|
89
|
-
}
|
|
90
|
-
function filterEntitySchemaFields(schema, policy) {
|
|
91
|
-
if (!hasFieldPolicy(policy) || !isRecord(schema.fields)) return schema;
|
|
92
|
-
const allowed = allowedKeysFor(Object.keys(schema.fields), policy);
|
|
93
|
-
const fields = {};
|
|
94
|
-
for (const key of allowed) if (hasOwn(schema.fields, key)) fields[key] = schema.fields[key];
|
|
95
|
-
const next = {
|
|
96
|
-
...schema,
|
|
97
|
-
fields
|
|
98
|
-
};
|
|
99
|
-
if (isRecord(schema.validation)) {
|
|
100
|
-
const validation = schema.validation;
|
|
101
|
-
next.validation = filterValidationSchemas(validation, allowed);
|
|
102
|
-
}
|
|
103
|
-
return next;
|
|
104
|
-
}
|
|
105
|
-
function filterValidationSchemas(validation, allowed) {
|
|
106
|
-
const next = { ...validation };
|
|
107
|
-
for (const [key, schema] of Object.entries(validation)) next[key] = filterJsonSchemaProperties(schema, allowed);
|
|
108
|
-
return next;
|
|
109
|
-
}
|
|
110
|
-
function filterJsonSchemaProperties(schema, allowed) {
|
|
111
|
-
if (!isRecord(schema) || !isRecord(schema.properties)) return schema;
|
|
112
|
-
const jsonSchema = schema;
|
|
113
|
-
const properties = {};
|
|
114
|
-
for (const key of allowed) if (hasOwn(jsonSchema.properties, key)) properties[key] = jsonSchema.properties[key];
|
|
115
|
-
return {
|
|
116
|
-
...jsonSchema,
|
|
117
|
-
properties,
|
|
118
|
-
required: Array.isArray(jsonSchema.required) ? jsonSchema.required.filter((key) => allowed.has(key)) : jsonSchema.required
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
function createCollectionDataSchema(collection, operation, policy) {
|
|
122
|
-
const validation = collection.state?.validation;
|
|
123
|
-
return filterZodObjectSchema(operation === "create" ? validation?.insertSchema : validation?.updateSchema, policy, entityFieldNames(collection));
|
|
124
|
-
}
|
|
125
|
-
function createGlobalDataSchema(global, policy) {
|
|
126
|
-
const validation = global.state?.validation;
|
|
127
|
-
return filterZodObjectSchema(validation?.updateSchema, policy, entityFieldNames(global));
|
|
128
|
-
}
|
|
129
|
-
function filterZodObjectSchema(schema, policy, knownFields) {
|
|
130
|
-
if (!schema) return recordSchema;
|
|
131
|
-
if (!(schema instanceof z.ZodObject)) return recordSchema;
|
|
132
|
-
const shape = schema.shape;
|
|
133
|
-
if (!(hasFieldPolicy(policy) || !!knownFields?.length)) return schema;
|
|
134
|
-
const allowed = allowedKeysFor(knownFields ?? Object.keys(shape), policy);
|
|
135
|
-
const nextShape = {};
|
|
136
|
-
for (const key of allowed) if (hasOwn(shape, key)) nextShape[key] = jsonSchemaCompatibleSchema(shape[key]) ?? z.unknown();
|
|
137
|
-
return z.object(nextShape);
|
|
138
|
-
}
|
|
139
|
-
function entityFieldNames(entity) {
|
|
140
|
-
const fields = entity.state?.fields;
|
|
141
|
-
if (!isRecord(fields)) return void 0;
|
|
142
|
-
return Object.keys(fields);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
//#endregion
|
|
146
|
-
//#region src/server/policy.ts
|
|
147
|
-
const DEFAULT_MCP_CONFIG = {
|
|
148
|
-
crud: {
|
|
149
|
-
maxLimit: 100,
|
|
150
|
-
defaults: {},
|
|
151
|
-
collections: {},
|
|
152
|
-
globals: {}
|
|
153
|
-
},
|
|
154
|
-
routes: {
|
|
155
|
-
exposeAnnotated: true,
|
|
156
|
-
routes: {}
|
|
157
|
-
},
|
|
158
|
-
resources: {
|
|
159
|
-
schemas: true,
|
|
160
|
-
routes: true
|
|
161
|
-
},
|
|
162
|
-
http: {
|
|
163
|
-
accessMode: "user",
|
|
164
|
-
enableJsonResponse: true
|
|
165
|
-
},
|
|
166
|
-
stdio: { accessMode: "system" }
|
|
167
|
-
};
|
|
168
|
-
function isPlainObject(value) {
|
|
169
|
-
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
170
|
-
}
|
|
171
|
-
function mergeDeep(base, override) {
|
|
172
|
-
if (!override) return { ...base };
|
|
173
|
-
const out = { ...base };
|
|
174
|
-
for (const [key, value] of Object.entries(override)) if (isPlainObject(value) && isPlainObject(out[key])) out[key] = mergeDeep(out[key], value);
|
|
175
|
-
else if (value !== void 0) out[key] = value;
|
|
176
|
-
return out;
|
|
177
|
-
}
|
|
178
|
-
function resolveMcpConfig(app, override) {
|
|
179
|
-
return mergeDeep(mergeDeep(DEFAULT_MCP_CONFIG, app.state?.config?.mcp ?? {}), override);
|
|
180
|
-
}
|
|
181
|
-
function defaultAccessModeForTransport(config, transport) {
|
|
182
|
-
if (transport === "http") return "user";
|
|
183
|
-
return config.stdio?.accessMode ?? "system";
|
|
184
|
-
}
|
|
185
|
-
function resolveEntityPolicy(config, kind, name, transport = "http") {
|
|
186
|
-
if (kind === "route") {
|
|
187
|
-
const override$1 = config.routes?.routes?.[name];
|
|
188
|
-
return normalizePolicy({
|
|
189
|
-
expose: true,
|
|
190
|
-
read: true
|
|
191
|
-
}, override$1);
|
|
192
|
-
}
|
|
193
|
-
if (kind === "global") {
|
|
194
|
-
const defaults$1 = {
|
|
195
|
-
...transport === "stdio" ? {
|
|
196
|
-
read: true,
|
|
197
|
-
write: true
|
|
198
|
-
} : {
|
|
199
|
-
read: true,
|
|
200
|
-
write: false
|
|
201
|
-
},
|
|
202
|
-
...config.crud?.defaults?.globals ?? {}
|
|
203
|
-
};
|
|
204
|
-
const override$1 = config.crud?.globals?.[name];
|
|
205
|
-
return normalizePolicy({
|
|
206
|
-
expose: true,
|
|
207
|
-
...defaults$1
|
|
208
|
-
}, override$1);
|
|
209
|
-
}
|
|
210
|
-
const defaults = {
|
|
211
|
-
...transport === "stdio" ? {
|
|
212
|
-
read: true,
|
|
213
|
-
write: true,
|
|
214
|
-
delete: true
|
|
215
|
-
} : {
|
|
216
|
-
read: true,
|
|
217
|
-
write: false,
|
|
218
|
-
delete: false
|
|
219
|
-
},
|
|
220
|
-
...config.crud?.defaults?.collections ?? {}
|
|
221
|
-
};
|
|
222
|
-
const override = config.crud?.collections?.[name];
|
|
223
|
-
return normalizePolicy({
|
|
224
|
-
expose: true,
|
|
225
|
-
...defaults
|
|
226
|
-
}, override);
|
|
227
|
-
}
|
|
228
|
-
function normalizePolicy(defaults, override) {
|
|
229
|
-
if (override === false) return {
|
|
230
|
-
expose: false,
|
|
231
|
-
operations: {},
|
|
232
|
-
operationScopes: {}
|
|
233
|
-
};
|
|
234
|
-
const base = {
|
|
235
|
-
expose: defaults.expose ?? true,
|
|
236
|
-
read: defaults.read,
|
|
237
|
-
write: defaults.write,
|
|
238
|
-
delete: defaults.delete,
|
|
239
|
-
operations: { ...defaults.operations ?? {} },
|
|
240
|
-
requiredScopes: defaults.requiredScopes,
|
|
241
|
-
operationScopes: { ...defaults.operationScopes ?? {} },
|
|
242
|
-
fields: defaults.fields,
|
|
243
|
-
description: defaults.description
|
|
244
|
-
};
|
|
245
|
-
if (override === true || override === void 0) return base;
|
|
246
|
-
return {
|
|
247
|
-
...base,
|
|
248
|
-
...override,
|
|
249
|
-
expose: override.expose ?? base.expose,
|
|
250
|
-
operations: {
|
|
251
|
-
...base.operations,
|
|
252
|
-
...override.operations ?? {}
|
|
253
|
-
},
|
|
254
|
-
requiredScopes: override.requiredScopes ?? base.requiredScopes,
|
|
255
|
-
operationScopes: {
|
|
256
|
-
...base.operationScopes,
|
|
257
|
-
...override.operationScopes ?? {}
|
|
258
|
-
}
|
|
259
|
-
};
|
|
260
|
-
}
|
|
261
|
-
function operationRule(policy, operation) {
|
|
262
|
-
if (policy.operations[operation] !== void 0) return policy.operations[operation];
|
|
263
|
-
if (operation === "delete") return policy.delete;
|
|
264
|
-
if (operation === "create" || operation === "update") return policy.write;
|
|
265
|
-
return policy.read;
|
|
266
|
-
}
|
|
267
|
-
/** Normalize an {@link McpRequiredScopes} declaration to a scope list. */
|
|
268
|
-
function normalizeRequiredScopes(required) {
|
|
269
|
-
if (required === void 0 || required === false) return [];
|
|
270
|
-
return typeof required === "string" ? [required] : [...required];
|
|
271
|
-
}
|
|
272
|
-
/**
|
|
273
|
-
* Resolve the scopes an OAuth caller must hold for a given entity operation.
|
|
274
|
-
*
|
|
275
|
-
* Precedence (most specific wins), each level is declarative data:
|
|
276
|
-
* 1. `policy.operationScopes[operationName]` — per-operation override.
|
|
277
|
-
* 2. `policy.requiredScopes` — entity-level requirement (all operations).
|
|
278
|
-
* 3. the default `<resource>:<name>:<verb>` mapping derived from the kind.
|
|
279
|
-
*
|
|
280
|
-
* A level set to `false` explicitly requires no scope (stops the fallback).
|
|
281
|
-
* Returns a (possibly empty) scope list; empty means "no scope required". This
|
|
282
|
-
* resolves the requirement; the deny/register decision that consumes it is
|
|
283
|
-
* {@link scopeGateAllows}.
|
|
284
|
-
*/
|
|
285
|
-
function requiredScopesForOperation(policy, kind, name, operationName, operationKind) {
|
|
286
|
-
const perOperation = policy.operationScopes[operationName];
|
|
287
|
-
if (perOperation !== void 0) return normalizeRequiredScopes(perOperation);
|
|
288
|
-
if (policy.requiredScopes !== void 0) return normalizeRequiredScopes(policy.requiredScopes);
|
|
289
|
-
return [defaultOperationScope(kind, name, operationKind)];
|
|
290
|
-
}
|
|
291
|
-
/**
|
|
292
|
-
* The consented scopes carried by the request, or `undefined` when the caller
|
|
293
|
-
* is not OAuth-authenticated. Reads `ctx.principal` (populated by MO6): only the
|
|
294
|
-
* `oauth` principal carries `scopes`; `user` and `system` do not.
|
|
295
|
-
*/
|
|
296
|
-
function scopesFromContext(ctx) {
|
|
297
|
-
const principal = ctx.principal;
|
|
298
|
-
if (principal && principal.kind === "oauth") return principal.scopes;
|
|
299
|
-
}
|
|
300
|
-
/**
|
|
301
|
-
* The OAuth scope gate (MO8, extended by the umbrella model — LOCKED #2).
|
|
302
|
-
*
|
|
303
|
-
* Given the scopes the caller holds (from {@link scopesFromContext}) and the
|
|
304
|
-
* scopes an operation requires (from {@link requiredScopesForOperation} or a
|
|
305
|
-
* custom tool's `config.scopes`), decide whether the scope gate passes.
|
|
306
|
-
*
|
|
307
|
-
* Semantics:
|
|
308
|
-
* - `heldScopes === undefined` → the caller is not OAuth-authenticated (`user`,
|
|
309
|
-
* `system`, or unauthenticated). The scope gate does not apply and PASSES;
|
|
310
|
-
* authorization is left entirely to RBAC / MCP rules. This is what keeps the
|
|
311
|
-
* first-party admin (`user`) and stdio (`system`) paths unchanged.
|
|
312
|
-
* - Otherwise the caller is an `oauth` principal: the gate PASSES iff EVERY
|
|
313
|
-
* required scope is satisfied — a required scope is satisfied when the caller
|
|
314
|
-
* holds it directly OR holds its applicable coarse umbrella
|
|
315
|
-
* ({@link umbrellaScopeFor}). An empty `required` needs nothing, so it passes
|
|
316
|
-
* for everyone.
|
|
317
|
-
*
|
|
318
|
-
* The umbrella widening is deliberately narrow — it can only satisfy a
|
|
319
|
-
* granular `<resource>:<name>:read|write` with the matching `<resource>:read`
|
|
320
|
-
* or `<resource>:write` umbrella. `read` and `write` are independent (holding
|
|
321
|
-
* `collections:write` never satisfies a `<name>:read` requirement, and vice
|
|
322
|
-
* versa), and there is NO umbrella for `:delete` or `routes:…:invoke`. So the
|
|
323
|
-
* umbrella grants convenience without ever over-granting across verb, resource
|
|
324
|
-
* kind, or into delete/invoke.
|
|
325
|
-
*
|
|
326
|
-
* This gate is **additive** — it can only ever REMOVE access from an `oauth`
|
|
327
|
-
* caller. It never grants access on its own: callers still run the MCP rule and
|
|
328
|
-
* the collection/global `.access()` RBAC independently, and all must pass. The
|
|
329
|
-
* effective `oauth` permission is therefore `scopes ∩ RBAC`.
|
|
330
|
-
*/
|
|
331
|
-
function scopeGateAllows(heldScopes, required) {
|
|
332
|
-
if (heldScopes === void 0) return true;
|
|
333
|
-
if (required.length === 0) return true;
|
|
334
|
-
const held = new Set(heldScopes);
|
|
335
|
-
return required.every((scope) => {
|
|
336
|
-
if (held.has(scope)) return true;
|
|
337
|
-
const umbrella = umbrellaScopeFor(scope);
|
|
338
|
-
return umbrella !== void 0 && held.has(umbrella);
|
|
339
|
-
});
|
|
340
|
-
}
|
|
341
|
-
async function evaluateMcpRule(rule, options) {
|
|
342
|
-
if (rule === void 0) return true;
|
|
343
|
-
if (typeof rule === "boolean") return rule;
|
|
344
|
-
return rule({
|
|
345
|
-
transport: options.transport,
|
|
346
|
-
accessMode: options.accessMode,
|
|
347
|
-
session: options.ctx.session,
|
|
348
|
-
scopes: scopesFromContext(options.ctx),
|
|
349
|
-
ctx: options.ctx
|
|
350
|
-
});
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
//#endregion
|
|
354
|
-
//#region src/server/runtime.ts
|
|
355
|
-
function createRuntimeScope(app, options) {
|
|
356
|
-
const transport = options.transport ?? "http";
|
|
357
|
-
const accessMode = options.accessMode ?? (transport === "stdio" ? "system" : "user");
|
|
358
|
-
const createContext = createContextFactory(app);
|
|
359
|
-
let requestContextPromise;
|
|
360
|
-
return {
|
|
361
|
-
app,
|
|
362
|
-
transport,
|
|
363
|
-
accessMode,
|
|
364
|
-
request: options.request,
|
|
365
|
-
async getContext() {
|
|
366
|
-
if (options.ctx) return options.ctx;
|
|
367
|
-
if (options.request) {
|
|
368
|
-
requestContextPromise ??= createAdapterContext(app, options.request, { accessMode }).then((adapterContext) => ({
|
|
369
|
-
...adapterContext.appContext,
|
|
370
|
-
request: options.request
|
|
371
|
-
}));
|
|
372
|
-
return requestContextPromise;
|
|
373
|
-
}
|
|
374
|
-
return createContext({ accessMode });
|
|
375
|
-
}
|
|
376
|
-
};
|
|
377
|
-
}
|
|
378
|
-
function safeJsonStringify(value) {
|
|
379
|
-
return JSON.stringify(value, (_key, nestedValue) => {
|
|
380
|
-
if (typeof nestedValue === "bigint") return nestedValue.toString();
|
|
381
|
-
if (nestedValue instanceof Date) return nestedValue.toISOString();
|
|
382
|
-
return nestedValue;
|
|
383
|
-
}, 2);
|
|
384
|
-
}
|
|
385
|
-
function toToolResult(value) {
|
|
386
|
-
if (value && typeof value === "object" && ("content" in value || "structuredContent" in value || "isError" in value)) {
|
|
387
|
-
const result = value;
|
|
388
|
-
if (!result.content) return {
|
|
389
|
-
...result,
|
|
390
|
-
content: [{
|
|
391
|
-
type: "text",
|
|
392
|
-
text: safeJsonStringify(result.structuredContent ?? null)
|
|
393
|
-
}]
|
|
394
|
-
};
|
|
395
|
-
return result;
|
|
396
|
-
}
|
|
397
|
-
return {
|
|
398
|
-
structuredContent: value && typeof value === "object" && !Array.isArray(value) ? value : { value },
|
|
399
|
-
content: [{
|
|
400
|
-
type: "text",
|
|
401
|
-
text: safeJsonStringify(value)
|
|
402
|
-
}]
|
|
403
|
-
};
|
|
404
|
-
}
|
|
405
|
-
function toToolError(error) {
|
|
406
|
-
return {
|
|
407
|
-
isError: true,
|
|
408
|
-
content: [{
|
|
409
|
-
type: "text",
|
|
410
|
-
text: error instanceof Error ? error.message : String(error)
|
|
411
|
-
}]
|
|
412
|
-
};
|
|
413
|
-
}
|
|
414
|
-
function jsonResource(uri, value) {
|
|
415
|
-
return { contents: [{
|
|
416
|
-
uri,
|
|
417
|
-
mimeType: "application/json",
|
|
418
|
-
text: safeJsonStringify(value)
|
|
419
|
-
}] };
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
//#endregion
|
|
423
|
-
//#region src/server/crud-tools.ts
|
|
424
|
-
const whereSchema = z.record(z.string(), z.unknown());
|
|
425
|
-
const relationLoadSchema = z.record(z.string(), z.unknown());
|
|
426
|
-
const findOptionsSchema = z.object({
|
|
427
|
-
where: whereSchema.optional(),
|
|
428
|
-
sort: z.record(z.string(), z.union([z.literal("asc"), z.literal("desc")])).optional(),
|
|
429
|
-
limit: z.number().int().positive().optional(),
|
|
430
|
-
page: z.number().int().positive().optional(),
|
|
431
|
-
offset: z.number().int().nonnegative().optional(),
|
|
432
|
-
with: relationLoadSchema.optional(),
|
|
433
|
-
columns: z.record(z.string(), z.boolean()).optional(),
|
|
434
|
-
locale: z.string().optional(),
|
|
435
|
-
includeDeleted: z.boolean().optional(),
|
|
436
|
-
stage: z.string().optional()
|
|
437
|
-
}).passthrough();
|
|
438
|
-
const countOptionsSchema = z.object({
|
|
439
|
-
where: whereSchema.optional(),
|
|
440
|
-
includeDeleted: z.boolean().optional()
|
|
441
|
-
}).passthrough();
|
|
442
|
-
const idSchema = z.union([z.string(), z.number()]);
|
|
443
|
-
const getOptionsSchema = z.object({
|
|
444
|
-
id: idSchema,
|
|
445
|
-
with: relationLoadSchema.optional(),
|
|
446
|
-
columns: z.record(z.string(), z.boolean()).optional(),
|
|
447
|
-
locale: z.string().optional(),
|
|
448
|
-
includeDeleted: z.boolean().optional(),
|
|
449
|
-
stage: z.string().optional()
|
|
450
|
-
}).passthrough();
|
|
451
|
-
const createSchema = z.object({ data: recordSchema });
|
|
452
|
-
const updateSchema = z.object({
|
|
453
|
-
id: idSchema,
|
|
454
|
-
data: recordSchema
|
|
455
|
-
});
|
|
456
|
-
const deleteSchema = z.object({ id: idSchema });
|
|
457
|
-
const globalGetSchema = z.object({
|
|
458
|
-
with: relationLoadSchema.optional(),
|
|
459
|
-
columns: z.record(z.string(), z.boolean()).optional(),
|
|
460
|
-
locale: z.string().optional(),
|
|
461
|
-
stage: z.string().optional()
|
|
462
|
-
}).passthrough();
|
|
463
|
-
const globalUpdateSchema = z.object({
|
|
464
|
-
data: recordSchema,
|
|
465
|
-
with: relationLoadSchema.optional(),
|
|
466
|
-
columns: z.record(z.string(), z.boolean()).optional(),
|
|
467
|
-
locale: z.string().optional(),
|
|
468
|
-
stage: z.string().optional()
|
|
469
|
-
}).passthrough();
|
|
470
|
-
const COLLECTION_OPERATIONS = [
|
|
471
|
-
{
|
|
472
|
-
name: "list",
|
|
473
|
-
kind: "read",
|
|
474
|
-
description: (name) => `List ${name} records.`,
|
|
475
|
-
schema: findOptionsSchema,
|
|
476
|
-
execute: (crud, input, ctx, maxLimit) => crud.find({
|
|
477
|
-
...input,
|
|
478
|
-
limit: Math.min(input.limit ?? maxLimit, maxLimit)
|
|
479
|
-
}, ctx)
|
|
480
|
-
},
|
|
481
|
-
{
|
|
482
|
-
name: "count",
|
|
483
|
-
kind: "read",
|
|
484
|
-
description: (name) => `Count ${name} records.`,
|
|
485
|
-
schema: countOptionsSchema,
|
|
486
|
-
execute: (crud, input, ctx) => crud.count(input, ctx)
|
|
487
|
-
},
|
|
488
|
-
{
|
|
489
|
-
name: "get",
|
|
490
|
-
kind: "read",
|
|
491
|
-
description: (name) => `Get one ${name} record by id.`,
|
|
492
|
-
schema: getOptionsSchema,
|
|
493
|
-
execute: (crud, input, ctx) => {
|
|
494
|
-
const { id, ...options } = input;
|
|
495
|
-
return crud.findOne({
|
|
496
|
-
...options,
|
|
497
|
-
where: { id }
|
|
498
|
-
}, ctx);
|
|
499
|
-
}
|
|
500
|
-
},
|
|
501
|
-
{
|
|
502
|
-
name: "create",
|
|
503
|
-
kind: "write",
|
|
504
|
-
description: (name) => `Create a ${name} record.`,
|
|
505
|
-
schema: createSchema,
|
|
506
|
-
execute: (crud, input, ctx) => crud.create(input.data, ctx)
|
|
507
|
-
},
|
|
508
|
-
{
|
|
509
|
-
name: "update",
|
|
510
|
-
kind: "write",
|
|
511
|
-
description: (name) => `Update a ${name} record by id.`,
|
|
512
|
-
schema: updateSchema,
|
|
513
|
-
execute: (crud, input, ctx) => crud.updateById({
|
|
514
|
-
id: input.id,
|
|
515
|
-
data: input.data
|
|
516
|
-
}, ctx)
|
|
517
|
-
},
|
|
518
|
-
{
|
|
519
|
-
name: "delete",
|
|
520
|
-
kind: "delete",
|
|
521
|
-
description: (name) => `Delete a ${name} record by id.`,
|
|
522
|
-
schema: deleteSchema,
|
|
523
|
-
execute: (crud, input, ctx) => crud.deleteById({ id: input.id }, ctx)
|
|
524
|
-
}
|
|
525
|
-
];
|
|
526
|
-
const GLOBAL_OPERATIONS = [{
|
|
527
|
-
name: "get",
|
|
528
|
-
kind: "read",
|
|
529
|
-
description: (name) => `Get the ${name} global.`,
|
|
530
|
-
schema: globalGetSchema,
|
|
531
|
-
execute: (crud, input, ctx) => crud.get(input, ctx)
|
|
532
|
-
}, {
|
|
533
|
-
name: "update",
|
|
534
|
-
kind: "write",
|
|
535
|
-
description: (name) => `Update the ${name} global.`,
|
|
536
|
-
schema: globalUpdateSchema,
|
|
537
|
-
execute: (crud, input, ctx) => {
|
|
538
|
-
const { data, ...options } = input;
|
|
539
|
-
return crud.update(data, ctx, options);
|
|
540
|
-
}
|
|
541
|
-
}];
|
|
542
|
-
function collectionOperationSchema(operationName, collection, policy) {
|
|
543
|
-
if (operationName === "create") return z.object({ data: createCollectionDataSchema(collection, "create", policy) });
|
|
544
|
-
if (operationName === "update") return z.object({
|
|
545
|
-
id: idSchema,
|
|
546
|
-
data: createCollectionDataSchema(collection, "update", policy)
|
|
547
|
-
});
|
|
548
|
-
return COLLECTION_OPERATIONS.find((operation) => operation.name === operationName)?.schema ?? recordSchema;
|
|
549
|
-
}
|
|
550
|
-
function globalOperationSchema(operationName, global, policy) {
|
|
551
|
-
if (operationName === "update") return globalUpdateSchema.extend({ data: createGlobalDataSchema(global, policy) });
|
|
552
|
-
return GLOBAL_OPERATIONS.find((operation) => operation.name === operationName)?.schema ?? recordSchema;
|
|
553
|
-
}
|
|
554
|
-
function filterOperationInput(operationName, input, policy) {
|
|
555
|
-
if ((operationName === "create" || operationName === "update") && input && typeof input === "object" && "data" in input) return {
|
|
556
|
-
...input,
|
|
557
|
-
data: filterRecordFields(input.data, policy)
|
|
558
|
-
};
|
|
559
|
-
return input;
|
|
560
|
-
}
|
|
561
|
-
async function questpieAllows(scope, kind, name, operation) {
|
|
562
|
-
if (scope.accessMode === "system") return true;
|
|
563
|
-
const ctx = await scope.getContext();
|
|
564
|
-
const crudContext = {
|
|
565
|
-
db: ctx.db ?? scope.app.db,
|
|
566
|
-
session: ctx.session,
|
|
567
|
-
locale: ctx.locale,
|
|
568
|
-
accessMode: scope.accessMode,
|
|
569
|
-
stage: ctx.stage
|
|
570
|
-
};
|
|
571
|
-
if (kind === "collection") {
|
|
572
|
-
const collection = scope.app.getCollections()[name];
|
|
573
|
-
const schema$1 = await introspectCollection(collection, crudContext, scope.app);
|
|
574
|
-
if (!schema$1.access.visible) return false;
|
|
575
|
-
const mappedOperation$1 = operation === "write" ? "update" : operation;
|
|
576
|
-
const result$1 = schema$1.access.operations[mappedOperation$1];
|
|
577
|
-
return !!result$1 && result$1.allowed !== false;
|
|
578
|
-
}
|
|
579
|
-
const global = scope.app.getGlobals()[name];
|
|
580
|
-
const schema = await introspectGlobal(global, crudContext, scope.app);
|
|
581
|
-
if (!schema.access.visible) return false;
|
|
582
|
-
const mappedOperation = operation === "write" ? "update" : "read";
|
|
583
|
-
const result = schema.access.operations[mappedOperation];
|
|
584
|
-
return !!result && result.allowed !== false;
|
|
585
|
-
}
|
|
586
|
-
async function shouldRegister(scope, policy, kind, entityName, operationName, operationKind) {
|
|
587
|
-
if (!policy.expose) return false;
|
|
588
|
-
const ctx = await scope.getContext();
|
|
589
|
-
if (!await evaluateMcpRule(operationRule(policy, operationName) ?? operationRule(policy, operationKind === "write" ? "update" : operationKind), {
|
|
590
|
-
transport: scope.transport,
|
|
591
|
-
accessMode: scope.accessMode,
|
|
592
|
-
ctx
|
|
593
|
-
})) return false;
|
|
594
|
-
if (!scopeGateAllows(scopesFromContext(ctx), requiredScopesForOperation(policy, kind, entityName, operationName, operationKind))) return false;
|
|
595
|
-
return questpieAllows(scope, kind, entityName, operationKind);
|
|
596
|
-
}
|
|
597
|
-
async function registerCrudTools(server, scope, config) {
|
|
598
|
-
const maxLimit = config.crud?.maxLimit ?? 100;
|
|
599
|
-
const collections = scope.app.getCollections();
|
|
600
|
-
const globals = scope.app.getGlobals();
|
|
601
|
-
for (const name of Object.keys(collections)) {
|
|
602
|
-
const collection = collections[name];
|
|
603
|
-
const policy = resolveEntityPolicy(config, "collection", name, scope.transport);
|
|
604
|
-
const crud = scope.app.collections[name];
|
|
605
|
-
for (const operation of COLLECTION_OPERATIONS) {
|
|
606
|
-
if (!await shouldRegister(scope, policy, "collection", name, operation.name, operation.kind)) continue;
|
|
607
|
-
server.registerTool(`collections.${name}.${operation.name}`, {
|
|
608
|
-
description: policy.description ?? operation.description(name),
|
|
609
|
-
inputSchema: collectionOperationSchema(operation.name, collection, policy),
|
|
610
|
-
annotations: {
|
|
611
|
-
readOnlyHint: operation.kind === "read",
|
|
612
|
-
destructiveHint: operation.kind === "delete",
|
|
613
|
-
idempotentHint: operation.name === "update" || operation.name === "delete"
|
|
614
|
-
}
|
|
615
|
-
}, async (input) => {
|
|
616
|
-
try {
|
|
617
|
-
const ctx = await scope.getContext();
|
|
618
|
-
if (!await evaluateMcpRule(operationRule(policy, operation.name) ?? operationRule(policy, operation.kind === "write" ? "update" : operation.kind), {
|
|
619
|
-
transport: scope.transport,
|
|
620
|
-
accessMode: scope.accessMode,
|
|
621
|
-
ctx
|
|
622
|
-
})) throw new Error("MCP access denied");
|
|
623
|
-
if (!scopeGateAllows(scopesFromContext(ctx), requiredScopesForOperation(policy, "collection", name, operation.name, operation.kind))) throw new Error("MCP access denied");
|
|
624
|
-
const nextInput = filterOperationInput(operation.name, input, policy);
|
|
625
|
-
return toToolResult(filterCrudResultFields(await operation.execute(crud, nextInput, ctx, maxLimit), policy));
|
|
626
|
-
} catch (error) {
|
|
627
|
-
return toToolError(error);
|
|
628
|
-
}
|
|
629
|
-
});
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
for (const name of Object.keys(globals)) {
|
|
633
|
-
const global = globals[name];
|
|
634
|
-
const policy = resolveEntityPolicy(config, "global", name, scope.transport);
|
|
635
|
-
const crud = scope.app.globals[name];
|
|
636
|
-
for (const operation of GLOBAL_OPERATIONS) {
|
|
637
|
-
if (!await shouldRegister(scope, policy, "global", name, operation.name, operation.kind)) continue;
|
|
638
|
-
server.registerTool(`globals.${name}.${operation.name}`, {
|
|
639
|
-
description: policy.description ?? operation.description(name),
|
|
640
|
-
inputSchema: globalOperationSchema(operation.name, global, policy),
|
|
641
|
-
annotations: {
|
|
642
|
-
readOnlyHint: operation.kind === "read",
|
|
643
|
-
idempotentHint: operation.name === "update"
|
|
644
|
-
}
|
|
645
|
-
}, async (input) => {
|
|
646
|
-
try {
|
|
647
|
-
const ctx = await scope.getContext();
|
|
648
|
-
if (!await evaluateMcpRule(operationRule(policy, operation.name) ?? operationRule(policy, operation.kind === "write" ? "update" : operation.kind), {
|
|
649
|
-
transport: scope.transport,
|
|
650
|
-
accessMode: scope.accessMode,
|
|
651
|
-
ctx
|
|
652
|
-
})) throw new Error("MCP access denied");
|
|
653
|
-
if (!scopeGateAllows(scopesFromContext(ctx), requiredScopesForOperation(policy, "global", name, operation.name, operation.kind))) throw new Error("MCP access denied");
|
|
654
|
-
const nextInput = filterOperationInput(operation.name, input, policy);
|
|
655
|
-
return toToolResult(filterCrudResultFields(await operation.execute(crud, nextInput, ctx), policy));
|
|
656
|
-
} catch (error) {
|
|
657
|
-
return toToolError(error);
|
|
658
|
-
}
|
|
659
|
-
});
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
//#endregion
|
|
665
|
-
//#region src/server/mcp-tool.ts
|
|
666
|
-
var McpToolBuilder = class {
|
|
667
|
-
constructor(name, config) {
|
|
668
|
-
this.name = name;
|
|
669
|
-
this.config = config;
|
|
670
|
-
}
|
|
671
|
-
handler(handler) {
|
|
672
|
-
return Object.freeze({
|
|
673
|
-
__brand: "mcpTool",
|
|
674
|
-
name: this.name,
|
|
675
|
-
config: this.config,
|
|
676
|
-
handler
|
|
677
|
-
});
|
|
678
|
-
}
|
|
679
|
-
};
|
|
680
|
-
/**
|
|
681
|
-
* Define a custom MCP tool, contributed by a module the same way collections and
|
|
682
|
-
* routes are (via `ModuleDefinition.mcpTools`). `mcpTool(name, config).handler(fn)`
|
|
683
|
-
* returns a frozen, branded {@link McpToolDefinition}; the CRUD/route tools the
|
|
684
|
-
* framework auto-generates are the same shape.
|
|
685
|
-
*
|
|
686
|
-
* `config` mirrors the MCP tool contract: `inputSchema`/`outputSchema` (Zod),
|
|
687
|
-
* `title`/`description`/`annotations` for discovery, plus the two QUESTPIE
|
|
688
|
-
* authorization hooks that compose exactly like the built-in tools:
|
|
689
|
-
* - `access` — an {@link McpAccessRule} run against the caller's transport /
|
|
690
|
-
* accessMode / session (and, for an OAuth caller, its scopes). Same RBAC-style
|
|
691
|
-
* gate the CRUD tools use.
|
|
692
|
-
* - `scopes` — the OAuth scopes an `oauth` caller must hold ({@link McpToolConfig.scopes}).
|
|
693
|
-
* Custom tools have no default scope mapping (unlike CRUD, there is no
|
|
694
|
-
* resource/operation to derive one from), so an omitted value requires no
|
|
695
|
-
* scope. The shipped scope gate ({@link scopeGateAllows}) enforces this at both
|
|
696
|
-
* `tools/list` (hidden) and `tools/call` (denied); `system`/`user` callers
|
|
697
|
-
* carry no scopes and skip it, so the effective bound is always `scopes ∩ RBAC`.
|
|
698
|
-
*
|
|
699
|
-
* @example
|
|
700
|
-
* ```ts
|
|
701
|
-
* export const ping = mcpTool("ops.ping", {
|
|
702
|
-
* description: "Health probe",
|
|
703
|
-
* inputSchema: z.object({}),
|
|
704
|
-
* scopes: "routes:ops/ping:invoke", // only OAuth callers holding this scope
|
|
705
|
-
* }).handler(async () => ({ content: [{ type: "text", text: "pong" }] }));
|
|
706
|
-
* ```
|
|
707
|
-
*/
|
|
708
|
-
function mcpTool(name, config = {}) {
|
|
709
|
-
return new McpToolBuilder(name, config);
|
|
710
|
-
}
|
|
711
|
-
function isMcpTool(value) {
|
|
712
|
-
return !!value && typeof value === "object" && value.__brand === "mcpTool" && typeof value.handler === "function";
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
//#endregion
|
|
716
|
-
//#region src/server/custom-tools.ts
|
|
717
|
-
async function registerCustomTools(server, scope) {
|
|
718
|
-
const tools = scope.app.state?.mcpTools ?? {};
|
|
719
|
-
for (const [key, tool] of Object.entries(tools)) {
|
|
720
|
-
if (!isMcpTool(tool)) continue;
|
|
721
|
-
const ctx = await scope.getContext();
|
|
722
|
-
if (!await evaluateMcpRule(tool.config.access, {
|
|
723
|
-
transport: scope.transport,
|
|
724
|
-
accessMode: scope.accessMode,
|
|
725
|
-
ctx
|
|
726
|
-
})) continue;
|
|
727
|
-
const requiredScopes = normalizeRequiredScopes(tool.config.scopes);
|
|
728
|
-
if (!scopeGateAllows(scopesFromContext(ctx), requiredScopes)) continue;
|
|
729
|
-
server.registerTool(tool.name || key, {
|
|
730
|
-
title: tool.config.title,
|
|
731
|
-
description: tool.config.description,
|
|
732
|
-
inputSchema: jsonSchemaCompatibleSchema(tool.config.inputSchema),
|
|
733
|
-
outputSchema: jsonSchemaCompatibleSchema(tool.config.outputSchema),
|
|
734
|
-
annotations: tool.config.annotations,
|
|
735
|
-
_meta: tool.config._meta
|
|
736
|
-
}, async (input) => {
|
|
737
|
-
try {
|
|
738
|
-
const callCtx = await scope.getContext();
|
|
739
|
-
if (!await evaluateMcpRule(tool.config.access, {
|
|
740
|
-
transport: scope.transport,
|
|
741
|
-
accessMode: scope.accessMode,
|
|
742
|
-
ctx: callCtx
|
|
743
|
-
})) throw new Error("MCP access denied");
|
|
744
|
-
if (!scopeGateAllows(scopesFromContext(callCtx), requiredScopes)) throw new Error("MCP access denied");
|
|
745
|
-
const parsedInput = tool.config.inputSchema ? await tool.config.inputSchema.parseAsync(input) : input;
|
|
746
|
-
return tool.handler({
|
|
747
|
-
input: parsedInput,
|
|
748
|
-
ctx: callCtx,
|
|
749
|
-
transport: scope.transport,
|
|
750
|
-
accessMode: scope.accessMode,
|
|
751
|
-
request: scope.request
|
|
752
|
-
});
|
|
753
|
-
} catch (error) {
|
|
754
|
-
return toToolError(error);
|
|
755
|
-
}
|
|
756
|
-
});
|
|
757
|
-
}
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
//#endregion
|
|
761
|
-
//#region src/server/resources.ts
|
|
762
|
-
async function collectionSchema(scope, name) {
|
|
763
|
-
const ctx = await scope.getContext();
|
|
764
|
-
const collection = scope.app.getCollections()[name];
|
|
765
|
-
return introspectCollection(collection, {
|
|
766
|
-
db: ctx.db ?? scope.app.db,
|
|
767
|
-
session: ctx.session,
|
|
768
|
-
locale: ctx.locale,
|
|
769
|
-
accessMode: scope.accessMode,
|
|
770
|
-
stage: ctx.stage
|
|
771
|
-
}, scope.app);
|
|
772
|
-
}
|
|
773
|
-
async function globalSchema(scope, name) {
|
|
774
|
-
const ctx = await scope.getContext();
|
|
775
|
-
const global = scope.app.getGlobals()[name];
|
|
776
|
-
return introspectGlobal(global, {
|
|
777
|
-
db: ctx.db ?? scope.app.db,
|
|
778
|
-
session: ctx.session,
|
|
779
|
-
locale: ctx.locale,
|
|
780
|
-
accessMode: scope.accessMode,
|
|
781
|
-
stage: ctx.stage
|
|
782
|
-
}, scope.app);
|
|
783
|
-
}
|
|
784
|
-
async function visibleCollections(scope, config) {
|
|
785
|
-
const entries = [];
|
|
786
|
-
for (const name of Object.keys(scope.app.getCollections())) {
|
|
787
|
-
const policy = resolveEntityPolicy(config, "collection", name, scope.transport);
|
|
788
|
-
if (!policy.expose) continue;
|
|
789
|
-
const ctx = await scope.getContext();
|
|
790
|
-
if (!await evaluateMcpRule(operationRule(policy, "read") ?? operationRule(policy, "list"), {
|
|
791
|
-
transport: scope.transport,
|
|
792
|
-
accessMode: scope.accessMode,
|
|
793
|
-
ctx
|
|
794
|
-
})) continue;
|
|
795
|
-
const schema = await collectionSchema(scope, name);
|
|
796
|
-
if (scope.accessMode === "system" || schema.access.visible && schema.access.operations.read.allowed !== false) entries.push(filterEntitySchemaFields(schema, policy));
|
|
797
|
-
}
|
|
798
|
-
return entries;
|
|
799
|
-
}
|
|
800
|
-
async function visibleGlobals(scope, config) {
|
|
801
|
-
const entries = [];
|
|
802
|
-
for (const name of Object.keys(scope.app.getGlobals())) {
|
|
803
|
-
const policy = resolveEntityPolicy(config, "global", name, scope.transport);
|
|
804
|
-
if (!policy.expose) continue;
|
|
805
|
-
const ctx = await scope.getContext();
|
|
806
|
-
if (!await evaluateMcpRule(operationRule(policy, "read") ?? operationRule(policy, "get"), {
|
|
807
|
-
transport: scope.transport,
|
|
808
|
-
accessMode: scope.accessMode,
|
|
809
|
-
ctx
|
|
810
|
-
})) continue;
|
|
811
|
-
const schema = await globalSchema(scope, name);
|
|
812
|
-
if (scope.accessMode === "system" || schema.access.visible && schema.access.operations.read.allowed !== false) entries.push(filterEntitySchemaFields(schema, policy));
|
|
813
|
-
}
|
|
814
|
-
return entries;
|
|
815
|
-
}
|
|
816
|
-
function routeToolInputSchema(route$1) {
|
|
817
|
-
if (!isJsonRoute(route$1.definition)) return void 0;
|
|
818
|
-
const params = route$1.params ?? [];
|
|
819
|
-
if (params.length === 0) return route$1.definition.schema;
|
|
820
|
-
const shape = {};
|
|
821
|
-
for (const param of params) shape[param] = z.string();
|
|
822
|
-
return z.object({
|
|
823
|
-
params: z.object(shape),
|
|
824
|
-
input: route$1.definition.schema
|
|
825
|
-
});
|
|
826
|
-
}
|
|
827
|
-
async function routeCatalog(scope, config) {
|
|
828
|
-
if (config.resources?.routes === false) return [];
|
|
829
|
-
if (config.routes?.exposeAnnotated === false) return [];
|
|
830
|
-
const ctx = await scope.getContext();
|
|
831
|
-
const services = extractAppServices(scope.app, {
|
|
832
|
-
db: ctx.db ?? scope.app.db,
|
|
833
|
-
session: ctx.session,
|
|
834
|
-
locale: ctx.locale
|
|
835
|
-
});
|
|
836
|
-
const routes = [];
|
|
837
|
-
for (const route$1 of introspectRoutes(scope.app)) {
|
|
838
|
-
if (!isJsonRoute(route$1.definition)) continue;
|
|
839
|
-
if (route$1.meta?.mcp?.expose !== true) continue;
|
|
840
|
-
const policy = resolveEntityPolicy(config, "route", route$1.key, scope.transport);
|
|
841
|
-
if (!policy.expose) continue;
|
|
842
|
-
if (!await evaluateMcpRule(operationRule(policy, "execute") ?? operationRule(policy, "read"), {
|
|
843
|
-
transport: scope.transport,
|
|
844
|
-
accessMode: scope.accessMode,
|
|
845
|
-
ctx
|
|
846
|
-
})) continue;
|
|
847
|
-
const params = Object.fromEntries((route$1.params ?? []).map((param) => [param, ""]));
|
|
848
|
-
if (!await evaluateRouteAccess(route$1.definition.access, {
|
|
849
|
-
...services,
|
|
850
|
-
locale: ctx.locale,
|
|
851
|
-
request: scope.request,
|
|
852
|
-
params
|
|
853
|
-
})) continue;
|
|
854
|
-
routes.push({
|
|
855
|
-
key: route$1.key,
|
|
856
|
-
path: route$1.path,
|
|
857
|
-
methods: route$1.methods,
|
|
858
|
-
mode: route$1.mode,
|
|
859
|
-
params: route$1.params,
|
|
860
|
-
meta: route$1.meta,
|
|
861
|
-
hasInputSchema: isJsonRoute(route$1.definition),
|
|
862
|
-
hasOutputSchema: isJsonRoute(route$1.definition) && !!route$1.definition.outputSchema,
|
|
863
|
-
inputSchema: toJsonSchema(route$1.definition.schema),
|
|
864
|
-
outputSchema: toJsonSchema(route$1.definition.outputSchema),
|
|
865
|
-
toolInputSchema: toJsonSchema(routeToolInputSchema(route$1))
|
|
866
|
-
});
|
|
867
|
-
}
|
|
868
|
-
return routes;
|
|
869
|
-
}
|
|
870
|
-
function resourceEntry(uri, name, description) {
|
|
871
|
-
return {
|
|
872
|
-
uri,
|
|
873
|
-
name,
|
|
874
|
-
mimeType: "application/json",
|
|
875
|
-
description
|
|
876
|
-
};
|
|
877
|
-
}
|
|
878
|
-
function registerSchemaResources(server, scope, config) {
|
|
879
|
-
if (config.resources?.schemas === false) return;
|
|
880
|
-
server.registerResource("questpie-schema-collections", "questpie://schema/collections", {
|
|
881
|
-
title: "QUESTPIE collections schema",
|
|
882
|
-
mimeType: "application/json"
|
|
883
|
-
}, async (uri) => jsonResource(uri.toString(), await visibleCollections(scope, config)));
|
|
884
|
-
server.registerResource("questpie-schema-collection", new ResourceTemplate("questpie://schema/collections/{name}", {
|
|
885
|
-
list: async () => ({ resources: (await visibleCollections(scope, config)).map((schema) => resourceEntry(`questpie://schema/collections/${schema.name}`, `Collection ${schema.name}`, schema.description ? String(schema.description) : void 0)) }),
|
|
886
|
-
complete: { name: async (value) => (await visibleCollections(scope, config)).map((schema) => schema.name).filter((name) => name.startsWith(value)) }
|
|
887
|
-
}), {
|
|
888
|
-
title: "QUESTPIE collection schema",
|
|
889
|
-
mimeType: "application/json"
|
|
890
|
-
}, async (uri, variables) => {
|
|
891
|
-
const name = String(variables.name);
|
|
892
|
-
const schema = (await visibleCollections(scope, config)).find((item) => item.name === name);
|
|
893
|
-
if (!schema) throw new Error(`Collection ${name} is not exposed to MCP`);
|
|
894
|
-
return jsonResource(uri.toString(), schema);
|
|
895
|
-
});
|
|
896
|
-
server.registerResource("questpie-schema-globals", "questpie://schema/globals", {
|
|
897
|
-
title: "QUESTPIE globals schema",
|
|
898
|
-
mimeType: "application/json"
|
|
899
|
-
}, async (uri) => jsonResource(uri.toString(), await visibleGlobals(scope, config)));
|
|
900
|
-
server.registerResource("questpie-schema-global", new ResourceTemplate("questpie://schema/globals/{name}", {
|
|
901
|
-
list: async () => ({ resources: (await visibleGlobals(scope, config)).map((schema) => resourceEntry(`questpie://schema/globals/${schema.name}`, `Global ${schema.name}`, schema.description ? String(schema.description) : void 0)) }),
|
|
902
|
-
complete: { name: async (value) => (await visibleGlobals(scope, config)).map((schema) => schema.name).filter((name) => name.startsWith(value)) }
|
|
903
|
-
}), {
|
|
904
|
-
title: "QUESTPIE global schema",
|
|
905
|
-
mimeType: "application/json"
|
|
906
|
-
}, async (uri, variables) => {
|
|
907
|
-
const name = String(variables.name);
|
|
908
|
-
const schema = (await visibleGlobals(scope, config)).find((item) => item.name === name);
|
|
909
|
-
if (!schema) throw new Error(`Global ${name} is not exposed to MCP`);
|
|
910
|
-
return jsonResource(uri.toString(), schema);
|
|
911
|
-
});
|
|
912
|
-
server.registerResource("questpie-schema-routes", "questpie://schema/routes", {
|
|
913
|
-
title: "QUESTPIE route schema",
|
|
914
|
-
mimeType: "application/json"
|
|
915
|
-
}, async (uri) => jsonResource(uri.toString(), await routeCatalog(scope, config)));
|
|
916
|
-
server.registerResource("questpie-schema-route", new ResourceTemplate("questpie://schema/routes/{key}", {
|
|
917
|
-
list: async () => ({ resources: (await routeCatalog(scope, config)).map((route$1) => resourceEntry(`questpie://schema/routes/${encodeURIComponent(route$1.key)}`, `Route ${route$1.key}`, route$1.meta?.description)) }),
|
|
918
|
-
complete: { key: async (value) => (await routeCatalog(scope, config)).map((route$1) => route$1.key).filter((key) => key.startsWith(value)) }
|
|
919
|
-
}), {
|
|
920
|
-
title: "QUESTPIE route schema",
|
|
921
|
-
mimeType: "application/json"
|
|
922
|
-
}, async (uri, variables) => {
|
|
923
|
-
const key = decodeURIComponent(String(variables.key));
|
|
924
|
-
const route$1 = (await routeCatalog(scope, config)).find((item) => item.key === key);
|
|
925
|
-
if (!route$1) throw new Error(`Route ${key} is not exposed to MCP`);
|
|
926
|
-
return jsonResource(uri.toString(), route$1);
|
|
927
|
-
});
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
//#endregion
|
|
931
|
-
//#region src/server/route-tools.ts
|
|
932
|
-
function sanitizeRouteKey(key) {
|
|
933
|
-
return key.replace(/[:/[\].]+/g, ".").replace(/\.+/g, ".").replace(/^\./, "").replace(/\.$/, "");
|
|
934
|
-
}
|
|
935
|
-
function routeInputSchema(route$1) {
|
|
936
|
-
if (!isJsonRoute(route$1.definition)) return z.object({});
|
|
937
|
-
if (route$1.params.length === 0) return jsonSchemaCompatibleSchema(route$1.definition.schema) ?? z.object({});
|
|
938
|
-
const paramsShape = {};
|
|
939
|
-
for (const param of route$1.params) paramsShape[param] = z.string();
|
|
940
|
-
return z.object({
|
|
941
|
-
params: z.object(paramsShape),
|
|
942
|
-
input: jsonSchemaCompatibleSchema(route$1.definition.schema) ?? z.object({})
|
|
943
|
-
});
|
|
944
|
-
}
|
|
945
|
-
async function registerRouteTools(server, scope, config) {
|
|
946
|
-
if (config.routes?.exposeAnnotated === false) return;
|
|
947
|
-
for (const route$1 of introspectRoutes(scope.app)) {
|
|
948
|
-
const definition = route$1.definition;
|
|
949
|
-
if (!isJsonRoute(definition)) continue;
|
|
950
|
-
if (route$1.meta?.mcp?.expose !== true) continue;
|
|
951
|
-
const policy = resolveEntityPolicy(config, "route", route$1.key, scope.transport);
|
|
952
|
-
if (!policy.expose) continue;
|
|
953
|
-
const ctx = await scope.getContext();
|
|
954
|
-
if (!await evaluateMcpRule(operationRule(policy, "execute") ?? operationRule(policy, "read"), {
|
|
955
|
-
transport: scope.transport,
|
|
956
|
-
accessMode: scope.accessMode,
|
|
957
|
-
ctx
|
|
958
|
-
})) continue;
|
|
959
|
-
if (!scopeGateAllows(scopesFromContext(ctx), requiredScopesForOperation(policy, "route", route$1.key, "execute", "invoke"))) continue;
|
|
960
|
-
const name = route$1.meta.mcp.name ?? `routes.${sanitizeRouteKey(route$1.key)}`;
|
|
961
|
-
const inputSchema = routeInputSchema(route$1);
|
|
962
|
-
server.registerTool(name, {
|
|
963
|
-
title: route$1.meta.mcp.title ?? route$1.meta.title,
|
|
964
|
-
description: route$1.meta.mcp.description ?? route$1.meta.description,
|
|
965
|
-
inputSchema,
|
|
966
|
-
outputSchema: jsonSchemaCompatibleSchema(definition.outputSchema),
|
|
967
|
-
annotations: route$1.meta.mcp.annotations
|
|
968
|
-
}, async (input) => {
|
|
969
|
-
try {
|
|
970
|
-
const routeCtx = await scope.getContext();
|
|
971
|
-
if (!await evaluateMcpRule(operationRule(policy, "execute") ?? operationRule(policy, "read"), {
|
|
972
|
-
transport: scope.transport,
|
|
973
|
-
accessMode: scope.accessMode,
|
|
974
|
-
ctx: routeCtx
|
|
975
|
-
})) throw new Error("MCP access denied");
|
|
976
|
-
if (!scopeGateAllows(scopesFromContext(routeCtx), requiredScopesForOperation(policy, "route", route$1.key, "execute", "invoke"))) throw new Error("MCP access denied");
|
|
977
|
-
const routeInput = route$1.params.length > 0 ? input.input : input;
|
|
978
|
-
const params = route$1.params.length > 0 ? input.params : {};
|
|
979
|
-
return toToolResult(await executeJsonRoute(scope.app, definition, routeInput, routeCtx, scope.request ?? new Request("http://questpie.local/mcp"), params));
|
|
980
|
-
} catch (error) {
|
|
981
|
-
return toToolError(error);
|
|
982
|
-
}
|
|
983
|
-
});
|
|
984
|
-
}
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
//#endregion
|
|
988
|
-
//#region src/server/create-server.ts
|
|
989
|
-
async function createMcpServer(app, options = {}) {
|
|
990
|
-
const config = resolveMcpConfig(app, options.config);
|
|
991
|
-
const transport = options.transport ?? "http";
|
|
992
|
-
const accessMode = transport === "http" ? "user" : options.accessMode ?? defaultAccessModeForTransport(config, transport);
|
|
993
|
-
const scope = createRuntimeScope(app, {
|
|
994
|
-
...options,
|
|
995
|
-
transport,
|
|
996
|
-
accessMode,
|
|
997
|
-
config
|
|
998
|
-
});
|
|
999
|
-
const server = new McpServer({
|
|
1000
|
-
name: config.name ?? "questpie",
|
|
1001
|
-
version: config.version ?? "0.0.0"
|
|
1002
|
-
}, { capabilities: {
|
|
1003
|
-
tools: {},
|
|
1004
|
-
resources: {}
|
|
1005
|
-
} });
|
|
1006
|
-
await registerCrudTools(server, scope, config);
|
|
1007
|
-
await registerRouteTools(server, scope, config);
|
|
1008
|
-
registerSchemaResources(server, scope, config);
|
|
1009
|
-
await registerCustomTools(server, scope);
|
|
1010
|
-
return server;
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
//#endregion
|
|
1014
|
-
export { requiredScopesForOperation as a, normalizeRequiredScopes as i, mcpTool as n, scopeGateAllows as o, defaultOperationScope as r, scopesFromContext as s, createMcpServer as t };
|