@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.
@@ -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 };