prisma-generator-express 1.67.0 → 1.68.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.
Files changed (68) hide show
  1. package/README.md +223 -28
  2. package/dist/copy/operationDefinitions.d.ts +4 -1
  3. package/dist/copy/operationDefinitions.js +64 -3
  4. package/dist/copy/operationDefinitions.js.map +1 -1
  5. package/dist/generators/generateFastifyHandler.js +55 -9
  6. package/dist/generators/generateFastifyHandler.js.map +1 -1
  7. package/dist/generators/generateHonoHandler.js +44 -4
  8. package/dist/generators/generateHonoHandler.js.map +1 -1
  9. package/dist/generators/generateHonoOpenApi.d.ts +13 -0
  10. package/dist/generators/generateHonoOpenApi.js +70 -0
  11. package/dist/generators/generateHonoOpenApi.js.map +1 -0
  12. package/dist/generators/generateMcpApp.d.ts +10 -0
  13. package/dist/generators/generateMcpApp.js +113 -0
  14. package/dist/generators/generateMcpApp.js.map +1 -0
  15. package/dist/generators/generateModelMcp.d.ts +9 -0
  16. package/dist/generators/generateModelMcp.js +145 -0
  17. package/dist/generators/generateModelMcp.js.map +1 -0
  18. package/dist/generators/generateModelMetadata.js +9 -5
  19. package/dist/generators/generateModelMetadata.js.map +1 -1
  20. package/dist/generators/generateRouter.js +62 -56
  21. package/dist/generators/generateRouter.js.map +1 -1
  22. package/dist/generators/generateRouterFastify.js +56 -64
  23. package/dist/generators/generateRouterFastify.js.map +1 -1
  24. package/dist/generators/generateRouterHono.js +43 -411
  25. package/dist/generators/generateRouterHono.js.map +1 -1
  26. package/dist/generators/generateRouterPartsHono.d.ts +14 -0
  27. package/dist/generators/generateRouterPartsHono.js +180 -0
  28. package/dist/generators/generateRouterPartsHono.js.map +1 -0
  29. package/dist/generators/generateUnifiedHandler.js +36 -20
  30. package/dist/generators/generateUnifiedHandler.js.map +1 -1
  31. package/dist/index.d.ts +2 -0
  32. package/dist/index.js +101 -3
  33. package/dist/index.js.map +1 -1
  34. package/dist/utils/copyFiles.d.ts +1 -1
  35. package/dist/utils/copyFiles.js +20 -1
  36. package/dist/utils/copyFiles.js.map +1 -1
  37. package/dist/utils/mcpGate.d.ts +10 -0
  38. package/dist/utils/mcpGate.js +118 -0
  39. package/dist/utils/mcpGate.js.map +1 -0
  40. package/dist/utils/writeFileSafely.js +6 -0
  41. package/dist/utils/writeFileSafely.js.map +1 -1
  42. package/package.json +18 -3
  43. package/src/copy/buildModelOpenApi.ts +76 -126
  44. package/src/copy/docsRenderer.ts +14 -15
  45. package/src/copy/mcpRuntime.ts +1017 -0
  46. package/src/copy/operationDefinitions.ts +78 -4
  47. package/src/copy/operationPipeline.ts +267 -0
  48. package/src/copy/operationRuntime.ts +24 -14
  49. package/src/copy/operationSchemas.ts +2773 -0
  50. package/src/copy/pagination.ts +10 -2
  51. package/src/copy/routeConfig.hono.ts +34 -5
  52. package/src/copy/routeConfig.ts +1 -1
  53. package/src/copy/routerParts.ts +524 -0
  54. package/src/generators/generateFastifyHandler.ts +55 -9
  55. package/src/generators/generateHonoHandler.ts +44 -4
  56. package/src/generators/generateHonoOpenApi.ts +93 -0
  57. package/src/generators/generateMcpApp.ts +133 -0
  58. package/src/generators/generateModelMcp.ts +208 -0
  59. package/src/generators/generateModelMetadata.ts +16 -10
  60. package/src/generators/generateRouter.ts +62 -56
  61. package/src/generators/generateRouterFastify.ts +56 -64
  62. package/src/generators/generateRouterHono.ts +49 -459
  63. package/src/generators/generateRouterPartsHono.ts +220 -0
  64. package/src/generators/generateUnifiedHandler.ts +36 -21
  65. package/src/index.ts +116 -3
  66. package/src/utils/copyFiles.ts +36 -0
  67. package/src/utils/mcpGate.ts +157 -0
  68. package/src/utils/writeFileSafely.ts +6 -0
@@ -0,0 +1,1017 @@
1
+ import {
2
+ fromJsonSchema,
3
+ McpServer,
4
+ type AuthInfo,
5
+ type StandardSchemaWithJSON,
6
+ } from '@modelcontextprotocol/server'
7
+ import {
8
+ normalizeOperation,
9
+ validateOperationConfig,
10
+ type NormalizedOperationConfig,
11
+ type OperationConfigInput,
12
+ type PaginationConfig,
13
+ } from './routeConfig'
14
+ import {
15
+ getEnv,
16
+ isPlainObject,
17
+ resolveDropGuardEnv,
18
+ sanitizeKeys,
19
+ } from './misc'
20
+ import {
21
+ routeOperation,
22
+ prepareGuardOperation,
23
+ settleStage,
24
+ classifyError,
25
+ memoizeContext,
26
+ executeOperation,
27
+ requirePrisma,
28
+ type ArgsChannel,
29
+ type RouteStageOk,
30
+ } from './operationPipeline'
31
+ import { transformResult, type OperationContext } from './operationRuntime'
32
+ import { HttpError } from './errorMapper'
33
+ import {
34
+ argsShapeConfigProblem,
35
+ buildModelAwareArgsSchema,
36
+ buildModelAwareWriteArgsSchema,
37
+ writeShapeConfigProblem,
38
+ type WriteSchemaOperation,
39
+ type SchemaFieldMeta,
40
+ type SchemaModelMeta,
41
+ } from './operationSchemas'
42
+
43
+ export type { SchemaFieldMeta, SchemaModelMeta }
44
+ import { mergePaginationConfig } from './pagination'
45
+ import type { OpKind } from './projectionDefaults'
46
+ import { OPERATION_BY_NAME } from './operationDefinitions'
47
+
48
+ /**
49
+ * MCP transport over the shared operation pipeline: explicitly allowlisted
50
+ * read actions and guarded write actions (no updateEach), each represented
51
+ * as one tool.
52
+ *
53
+ * Reuses the REST core: variant resolution, guard enforcement, pagination,
54
+ * operation overrides, memoized context, the Prisma operation core,
55
+ * transformResult and error classification. The only MCP-owned policy is the
56
+ * `authorize` callback, and it runs AFTER variant settlement and BEFORE any
57
+ * application-context resolution, dynamic guard-shape evaluation or database
58
+ * call — a denied call performs zero work.
59
+ *
60
+ * The verified principal is REQUIRED: registration refuses to run without
61
+ * one, and every call re-reads it from the SDK's per-request context
62
+ * (`ctx.http.authInfo`). A call without verified authentication is denied
63
+ * before any work.
64
+ */
65
+
66
+ export type McpAuthInfo = AuthInfo
67
+
68
+ /** Hooks are opaque here: MCP only checks presence, never calls them. */
69
+ type McpHook = (...args: never[]) => unknown
70
+
71
+ /** Everything `server.registerTool` needs, computed BEFORE any mutation. */
72
+ export interface McpRegistration {
73
+ name: string
74
+ config: {
75
+ description?: string
76
+ annotations?: {
77
+ readOnlyHint?: boolean
78
+ destructiveHint?: boolean
79
+ idempotentHint?: boolean
80
+ openWorldHint?: boolean
81
+ }
82
+ inputSchema: StandardSchemaWithJSON<Record<string, unknown>>
83
+ }
84
+ handler: (
85
+ args: Record<string, unknown>,
86
+ ctx?: McpCallContext,
87
+ ) => Promise<{
88
+ content: Array<{ type: 'text'; text: string }>
89
+ isError?: boolean
90
+ }>
91
+ }
92
+
93
+ /** The five read operations exposed in this release. */
94
+ export type McpReadOperation =
95
+ | 'findMany'
96
+ | 'findUnique'
97
+ | 'findFirst'
98
+ | 'count'
99
+ | 'findManyPaginated'
100
+
101
+ const MCP_READ_OPERATIONS: ReadonlySet<string> = new Set([
102
+ 'findMany',
103
+ 'findUnique',
104
+ 'findFirst',
105
+ 'count',
106
+ 'findManyPaginated',
107
+ ])
108
+
109
+ /**
110
+ * Every guarded write operation. `updateEach` is NOT one: it bypasses guard
111
+ * shapes entirely, so no MCP tool can expose it under the fail-closed rules.
112
+ */
113
+ export type McpWriteOperation = WriteSchemaOperation
114
+
115
+ const MCP_WRITE_OPERATIONS: ReadonlySet<string> = new Set([
116
+ 'create',
117
+ 'createMany',
118
+ 'createManyAndReturn',
119
+ 'update',
120
+ 'updateMany',
121
+ 'updateManyAndReturn',
122
+ 'upsert',
123
+ 'delete',
124
+ 'deleteMany',
125
+ ])
126
+
127
+ /** OpKind per write op (the dropped-guard vocabulary; MCP never drops). */
128
+ const WRITE_OP_KIND: Record<McpWriteOperation, OpKind> = {
129
+ create: 'create',
130
+ createMany: 'createMany',
131
+ createManyAndReturn: 'createMany',
132
+ update: 'update',
133
+ updateMany: 'updateMany',
134
+ updateManyAndReturn: 'updateMany',
135
+ upsert: 'upsert',
136
+ delete: 'delete',
137
+ deleteMany: 'deleteMany',
138
+ }
139
+
140
+ /** Thrown by an `authorize` callback to DENY a call. */
141
+ export class McpAuthorizationError extends Error {
142
+ constructor(message: string) {
143
+ super(message)
144
+ this.name = 'McpAuthorizationError'
145
+ }
146
+ }
147
+
148
+ /** Result payload exceeded the serialized-size cap. */
149
+ export class McpResultSizeError extends Error {
150
+ constructor(maxBytes: number) {
151
+ super(
152
+ 'Result exceeds the MCP result-size cap of ' +
153
+ maxBytes +
154
+ ' UTF-8 bytes. Narrow the query: reduce take, or use select to return fewer fields.',
155
+ )
156
+ this.name = 'McpResultSizeError'
157
+ }
158
+ }
159
+
160
+ export interface McpSharedOptions {
161
+ /** The Prisma client (or extended client) tools execute against. */
162
+ prisma: unknown
163
+ /**
164
+ * Maps the verified AuthInfo to the guard routing key. The caller string is
165
+ * a ROUTING KEY ONLY — never an identity, never taken from tool arguments.
166
+ */
167
+ resolveCaller: (authInfo: AuthInfo) => string | undefined
168
+ /**
169
+ * MCP-only authorization. Runs after variant settlement, before any context
170
+ * resolution, dynamic shape evaluation or database call. Denial = throw
171
+ * `McpAuthorizationError`; any other throw becomes a classified internal
172
+ * error. Exceptions are never treated as allow.
173
+ */
174
+ authorize: (input: {
175
+ principal: AuthInfo
176
+ model: string
177
+ operation: string
178
+ args: Record<string, unknown>
179
+ variant: string | undefined
180
+ }) => void | Promise<void>
181
+ /** Injected when a list query omits `take`. */
182
+ defaultLimit: number
183
+ /** take is clamped down to this before the guard applies its own limits. */
184
+ maxLimit: number
185
+ /** Cap on the UTF-8 byte size of the final serialized result. */
186
+ maxResultBytes: number
187
+ /** Application context for dynamic guard shapes and overrides. */
188
+ resolveContext?: (authInfo: AuthInfo) => unknown
189
+ /**
190
+ * The VERIFIED principal for this registration. The mount glue passes the
191
+ * per-request factory's `ctx.authInfo`; registration refuses to run without
192
+ * it, and every call re-verifies against the SDK request context.
193
+ */
194
+ authInfo: AuthInfo
195
+ }
196
+
197
+ export interface McpToolContribution {
198
+ readonly model: string
199
+ readonly operation: string
200
+ /**
201
+ * Preflight. Runs for EVERY contribution before ANY registers, so one bad
202
+ * tool cannot leave a partially registered set behind.
203
+ */
204
+ validate(shared: McpSharedOptions): void
205
+ /**
206
+ * Builds the registration payload WITHOUT touching the server. Runs for
207
+ * every contribution before `registerTool` is called on any of them, so
208
+ * duplicate names or late schema failures cannot leave a partial set.
209
+ * Returns null when this caller gets no tool (unresolvable variant).
210
+ */
211
+ prepare(shared: McpSharedOptions, authInfo: AuthInfo): McpRegistration | null
212
+ }
213
+
214
+ function positiveSafeInteger(value: unknown, name: string): number {
215
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) {
216
+ throw new Error(
217
+ 'registerMcpTools: ' +
218
+ name +
219
+ ' must be a positive safe integer, got ' +
220
+ String(value),
221
+ )
222
+ }
223
+ return value
224
+ }
225
+
226
+ function looksLikeAuthInfo(value: unknown): value is AuthInfo {
227
+ return (
228
+ typeof value === 'object' &&
229
+ value !== null &&
230
+ typeof (value as Record<string, unknown>).clientId === 'string'
231
+ )
232
+ }
233
+
234
+ /**
235
+ * Fail closed, before a single tool is validated: verified principal present,
236
+ * every required callback present, every numeric option valid, and NO
237
+ * effective guard drop in this process. `allowE2EGuardBypass` on a REST
238
+ * config cannot change this — MCP never honours the bypass.
239
+ */
240
+ export function validateMcpSharedOptions(
241
+ options: Partial<McpSharedOptions>,
242
+ ): void {
243
+ if (typeof options.prisma !== 'object' || options.prisma === null) {
244
+ throw new Error(
245
+ 'registerMcpTools: prisma is required — the guarded client the tools execute against.',
246
+ )
247
+ }
248
+ if (!looksLikeAuthInfo(options.authInfo)) {
249
+ throw new Error(
250
+ "registerMcpTools: a verified authInfo is required. Pass the per-request factory's ctx.authInfo; MCP never serves an unauthenticated caller.",
251
+ )
252
+ }
253
+ if (typeof options.resolveCaller !== 'function') {
254
+ throw new Error(
255
+ 'registerMcpTools: resolveCaller is required. It maps the verified AuthInfo to the guard routing key.',
256
+ )
257
+ }
258
+ if (typeof options.authorize !== 'function') {
259
+ throw new Error(
260
+ 'registerMcpTools: authorize is required. Deny by throwing McpAuthorizationError.',
261
+ )
262
+ }
263
+ const defaultLimit = positiveSafeInteger(options.defaultLimit, 'defaultLimit')
264
+ const maxLimit = positiveSafeInteger(options.maxLimit, 'maxLimit')
265
+ positiveSafeInteger(options.maxResultBytes, 'maxResultBytes')
266
+ if (defaultLimit > maxLimit) {
267
+ throw new Error(
268
+ 'registerMcpTools: defaultLimit (' +
269
+ defaultLimit +
270
+ ') must not exceed maxLimit (' +
271
+ maxLimit +
272
+ ').',
273
+ )
274
+ }
275
+ if (resolveDropGuardEnv(getEnv())) {
276
+ throw new Error(
277
+ 'registerMcpTools: refusing to register MCP tools because the guard is dropped in this environment ' +
278
+ '(PGE_DROP_GUARD=true or the deprecated E2E=true). MCP is fail-closed and offers no bypass.',
279
+ )
280
+ }
281
+ }
282
+
283
+ type TakeConfig =
284
+ | { kind: 'number'; max: number; default: number }
285
+ | { kind: 'object'; max: number; default?: number }
286
+ | { kind: 'none' }
287
+
288
+ function takeConfigOfShape(shape: unknown): TakeConfig {
289
+ if (!isPlainObject(shape)) return { kind: 'none' }
290
+ const take = shape.take
291
+ if (typeof take === 'number') {
292
+ return { kind: 'number', max: take, default: take }
293
+ }
294
+ if (isPlainObject(take)) {
295
+ const max = typeof take.max === 'number' ? take.max : undefined
296
+ if (max === undefined) return { kind: 'none' }
297
+ const def = typeof take.default === 'number' ? take.default : undefined
298
+ return { kind: 'object', max, default: def }
299
+ }
300
+ return { kind: 'none' }
301
+ }
302
+
303
+ function requireListShapeTake(
304
+ shape: unknown,
305
+ location: string,
306
+ defaultLimit: number,
307
+ ): void {
308
+ const cfg = takeConfigOfShape(shape)
309
+ if (cfg.kind === 'none') {
310
+ throw new Error(
311
+ location +
312
+ ': every exposed findMany/findManyPaginated shape must declare take ' +
313
+ '(take: N or take: { max, default? }); without it MCP cannot bound rows.',
314
+ )
315
+ }
316
+ if (cfg.max < defaultLimit) {
317
+ throw new Error(
318
+ location +
319
+ ': shape take.max (' +
320
+ cfg.max +
321
+ ') is below defaultLimit (' +
322
+ defaultLimit +
323
+ '), so the injected default would be rejected. Raise take.max or lower defaultLimit.',
324
+ )
325
+ }
326
+ }
327
+
328
+ function opKindOf(operation: string): OpKind {
329
+ if (operation === 'findUnique') return 'readUnique'
330
+ if (isWriteOperation(operation)) return WRITE_OP_KIND[operation]
331
+ return 'read'
332
+ }
333
+
334
+ function isWriteOperation(operation: string): operation is McpWriteOperation {
335
+ return MCP_WRITE_OPERATIONS.has(operation)
336
+ }
337
+
338
+ function snakeCase(value: string): string {
339
+ return value.replace(/[A-Z]/g, (c) => '_' + c.toLowerCase()).replace(/^_/, '')
340
+ }
341
+
342
+ function toolName(model: string, operation: string): string {
343
+ return snakeCase(model) + '_' + snakeCase(operation)
344
+ }
345
+
346
+ function isErrorResult(
347
+ message: string,
348
+ status?: number,
349
+ ): {
350
+ content: Array<{ type: 'text'; text: string }>
351
+ isError: boolean
352
+ } {
353
+ return {
354
+ content: [
355
+ {
356
+ type: 'text',
357
+ text: JSON.stringify(
358
+ status === undefined ? { message } : { message, status },
359
+ ),
360
+ },
361
+ ],
362
+ isError: true,
363
+ }
364
+ }
365
+
366
+ export interface CreateMcpReadToolInput {
367
+ model: string
368
+ operation: McpReadOperation
369
+ config: Record<string, unknown>
370
+ core: (ctx: OperationContext) => Promise<unknown>
371
+ /** This model's fields (from the emitted *Metadata.ts). */
372
+ fields: readonly SchemaFieldMeta[]
373
+ /** This model's enum value maps: enum name -> values. */
374
+ enums: ReadonlyMap<string, readonly string[]>
375
+ /**
376
+ * Related models' full metadata, for recursive select/include narrowing
377
+ * (compound uniques ride along so nested cursors keep their selectors).
378
+ */
379
+ modelIndex: ReadonlyMap<string, SchemaModelMeta>
380
+ /**
381
+ * Compound unique constraints of this model. `selector` is the compound
382
+ * client-input key (fields joined with '_'); `fields` are the columns.
383
+ */
384
+ compoundUniques?: ReadonlyArray<{
385
+ selector: string
386
+ fields: readonly string[]
387
+ }>
388
+ /** This model's unique field names (id + @unique columns). */
389
+ uniqueFields?: readonly string[]
390
+ }
391
+
392
+ /** What the SDK v2 tool callback actually receives over HTTP. */
393
+ export interface McpCallContext {
394
+ http?: { authInfo?: AuthInfo }
395
+ }
396
+
397
+ /** The generator's `writeStrategy`, baked into every emitted write factory. */
398
+ export type McpWriteStrategy = 'regular' | 'throwOnNonReturning' | 'forceReturn'
399
+
400
+ export type CreateMcpWriteToolInput = Omit<
401
+ CreateMcpReadToolInput,
402
+ 'operation'
403
+ > & {
404
+ operation: McpWriteOperation
405
+ /**
406
+ * What the operation core actually calls for createMany/updateMany:
407
+ * `forceReturn` routes them to the returning guard methods (projection
408
+ * allowed), `throwOnNonReturning` makes their cores 501 on every call.
409
+ * Defaults to `regular`.
410
+ */
411
+ writeStrategy?: McpWriteStrategy
412
+ }
413
+
414
+ /**
415
+ * The guard method the operation core really invokes under the generator's
416
+ * writeStrategy (mirrors generateOperationCore decideWriteOp): the schema
417
+ * and shape validation follow THAT method, never the tool's name.
418
+ */
419
+ function effectiveWriteOperation(
420
+ operation: McpWriteOperation,
421
+ strategy: McpWriteStrategy,
422
+ ): McpWriteOperation {
423
+ if (strategy !== 'forceReturn') return operation
424
+ if (operation === 'createMany') return 'createManyAndReturn'
425
+ if (operation === 'updateMany') return 'updateManyAndReturn'
426
+ return operation
427
+ }
428
+
429
+ /**
430
+ * Creates one model-operation tool contribution. Registration-time refusals
431
+ * (missing guard, REST hooks present) throw HERE, at creation, so a
432
+ * misconfigured deployment fails at boot with the offending model and
433
+ * operation named. Static limit checks run in `validate`, which
434
+ * `registerMcpTools` runs for every contribution before registering any.
435
+ */
436
+ export function createMcpReadTool(
437
+ input: CreateMcpReadToolInput,
438
+ ): McpToolContribution {
439
+ return createMcpOperationTool(input, false)
440
+ }
441
+
442
+ /**
443
+ * The write twin: same pipeline, same fail-closed rules, plus the write-shape
444
+ * contract — data configs must be statically mirrorable (no relation writes,
445
+ * no inline refines) or registration refuses.
446
+ */
447
+ export function createMcpWriteTool(
448
+ input: CreateMcpWriteToolInput,
449
+ ): McpToolContribution {
450
+ return createMcpOperationTool(input, true)
451
+ }
452
+
453
+ function createMcpOperationTool(
454
+ input: CreateMcpReadToolInput | CreateMcpWriteToolInput,
455
+ write: boolean,
456
+ ): McpToolContribution {
457
+ const operationName: string = input.operation
458
+ if (write && operationName === 'updateEach') {
459
+ throw new Error(
460
+ 'MCP tool for ' +
461
+ input.model +
462
+ '.updateEach: refused. updateEach bypasses guard shapes entirely, ' +
463
+ 'and every MCP tool must execute through a guard.',
464
+ )
465
+ }
466
+ const allowedOperations = write ? MCP_WRITE_OPERATIONS : MCP_READ_OPERATIONS
467
+ if (!allowedOperations.has(input.operation)) {
468
+ throw new Error(
469
+ 'MCP tool for ' +
470
+ input.model +
471
+ '.' +
472
+ input.operation +
473
+ ': this release exposes ' +
474
+ (write
475
+ ? 'create, createMany, createManyAndReturn, update, updateMany, updateManyAndReturn, upsert, delete and deleteMany only.'
476
+ : 'findMany, findUnique, findFirst, count and findManyPaginated only.'),
477
+ )
478
+ }
479
+
480
+ const location = input.model + '.' + input.operation + ' (MCP)'
481
+ const writeStrategy: McpWriteStrategy =
482
+ 'writeStrategy' in input && input.writeStrategy
483
+ ? input.writeStrategy
484
+ : 'regular'
485
+ if (
486
+ write &&
487
+ writeStrategy === 'throwOnNonReturning' &&
488
+ (operationName === 'createMany' || operationName === 'updateMany')
489
+ ) {
490
+ throw new Error(
491
+ location +
492
+ ': disabled by writeStrategy="throwOnNonReturning" — its operation ' +
493
+ 'core returns 501 for every call. Expose the returning variant ' +
494
+ '(' +
495
+ operationName +
496
+ 'AndReturn) instead.',
497
+ )
498
+ }
499
+ // the guard method the core invokes: schemas and shape validation follow it
500
+ const schemaOperation = isWriteOperation(input.operation)
501
+ ? effectiveWriteOperation(input.operation, writeStrategy)
502
+ : undefined
503
+ const raw = input.config[input.operation]
504
+ validateOperationConfig(
505
+ raw as { shape?: unknown; variants?: unknown } | undefined,
506
+ location,
507
+ )
508
+ const opConfig = normalizeOperation<McpHook, McpHook>(
509
+ raw as OperationConfigInput<McpHook, McpHook> | undefined,
510
+ )
511
+
512
+ if (!opConfig.guardShape) {
513
+ throw new Error(
514
+ location +
515
+ ': no guard configured. Every MCP-exposed operation requires a guard shape or variants; read and write tools are not exempt.',
516
+ )
517
+ }
518
+
519
+ const hookViolations: string[] = []
520
+ if (opConfig.authorize !== undefined) hookViolations.push('authorize')
521
+ if (opConfig.operationBefore.length > 0) hookViolations.push('before')
522
+ if (opConfig.operationAfter.length > 0) hookViolations.push('after')
523
+ for (const [key, hooks] of Object.entries(opConfig.variantHooks)) {
524
+ if (hooks.before.length > 0)
525
+ hookViolations.push('variants.' + key + '.before')
526
+ if (hooks.after.length > 0)
527
+ hookViolations.push('variants.' + key + '.after')
528
+ }
529
+ if (hookViolations.length > 0) {
530
+ throw new Error(
531
+ location +
532
+ ': the REST configuration defines hooks (' +
533
+ hookViolations.join(', ') +
534
+ '). MCP refuses to expose an operation whose transport-specific policy ' +
535
+ 'would be silently ignored. Remove the hooks or keep the operation REST-only.',
536
+ )
537
+ }
538
+
539
+ // write schemas are narrowed from a STATIC shape only: a dynamic write
540
+ // shape would need an opaque data/where surface, which MCP refuses
541
+ if (write) {
542
+ const shapes =
543
+ opConfig.guardRouting.kind === 'named'
544
+ ? Object.entries(opConfig.guardShape as Record<string, unknown>)
545
+ : [['', opConfig.guardShape] as const]
546
+ for (const [key, entry] of shapes) {
547
+ if (typeof entry === 'function') {
548
+ throw new Error(
549
+ location +
550
+ (key ? ' variant "' + key + '"' : '') +
551
+ ': dynamic (function) write shapes are refused. MCP write tools ' +
552
+ 'advertise a schema narrowed from a static shape; use static ' +
553
+ 'shapes (per-tenant variants for tenant-forced values).',
554
+ )
555
+ }
556
+ }
557
+ }
558
+
559
+ const isList =
560
+ input.operation === 'findMany' || input.operation === 'findManyPaginated'
561
+
562
+ const meta = OPERATION_BY_NAME[input.operation]
563
+ const mergedPagination = mergePaginationConfig(
564
+ input.config.pagination as Partial<PaginationConfig> | undefined,
565
+ opConfig.pagination,
566
+ )
567
+ const modelMeta: SchemaModelMeta = {
568
+ name: input.model,
569
+ fields: input.fields,
570
+ enums: input.enums,
571
+ uniqueFields:
572
+ input.uniqueFields ??
573
+ input.fields
574
+ .filter((f) => f.isId || f.isUnique === true)
575
+ .map((f) => f.name),
576
+ compoundUniques: input.compoundUniques ?? [],
577
+ modelIndex: input.modelIndex,
578
+ }
579
+
580
+ return {
581
+ model: input.model,
582
+ operation: input.operation,
583
+
584
+ validate(shared: McpSharedOptions): void {
585
+ const shapeIsObject = isPlainObject(opConfig.guardShape)
586
+ if (isList && shapeIsObject) {
587
+ const routing = opConfig.guardRouting
588
+ if (routing.kind === 'named') {
589
+ for (const key of routing.keys) {
590
+ const entry = (opConfig.guardShape as Record<string, unknown>)[key]
591
+ // a function variant is resolved per call; its contracts are
592
+ // enforced after resolution, not here
593
+ if (typeof entry === 'function') continue
594
+ requireListShapeTake(
595
+ entry,
596
+ location + ' variant "' + key + '"',
597
+ shared.defaultLimit,
598
+ )
599
+ }
600
+ } else {
601
+ requireListShapeTake(
602
+ opConfig.guardShape,
603
+ location,
604
+ shared.defaultLimit,
605
+ )
606
+ }
607
+ }
608
+ // guard-config validity per exposed variant — the COMPLETE shape,
609
+ // not just where: guard 1.33 rejects (or crashes on) invalid cursor,
610
+ // orderBy, distinct, select, include, _count AND write data/where
611
+ // configs for EVERY input too, so the tool must not register —
612
+ // fail closed at boot
613
+ const checkShape = (shape: unknown, loc: string): void => {
614
+ if (!isPlainObject(shape)) return
615
+ const problem = write
616
+ ? writeShapeConfigProblem(
617
+ modelMeta,
618
+ schemaOperation ?? input.operation,
619
+ shape,
620
+ )
621
+ : argsShapeConfigProblem(modelMeta, input.operation, shape)
622
+ if (problem) {
623
+ throw new Error(loc + ': ' + problem)
624
+ }
625
+ }
626
+ if (shapeIsObject) {
627
+ const routing = opConfig.guardRouting
628
+ if (routing.kind === 'named') {
629
+ for (const key of routing.keys) {
630
+ const entry = (opConfig.guardShape as Record<string, unknown>)[key]
631
+ if (typeof entry === 'function') continue
632
+ checkShape(entry, location + ' variant "' + key + '"')
633
+ }
634
+ } else {
635
+ checkShape(opConfig.guardShape, location)
636
+ }
637
+ }
638
+ },
639
+
640
+ prepare(
641
+ shared: McpSharedOptions,
642
+ authInfo: AuthInfo,
643
+ ): McpRegistration | null {
644
+ const caller = shared.resolveCaller(authInfo)
645
+ const routed = routeOperation({
646
+ guardRouting: opConfig.guardRouting,
647
+ caller,
648
+ })
649
+ // Fail closed: a caller whose variant cannot be resolved gets NO tool.
650
+ if (!routed.ok) return null
651
+
652
+ const variantShape =
653
+ routed.variantKey !== undefined &&
654
+ isPlainObject(opConfig.guardShape) &&
655
+ routed.variantKey in (opConfig.guardShape as Record<string, unknown>)
656
+ ? (opConfig.guardShape as Record<string, unknown>)[routed.variantKey]
657
+ : opConfig.guardShape
658
+
659
+ const schema = schemaOperation
660
+ ? buildModelAwareWriteArgsSchema(
661
+ schemaOperation,
662
+ modelMeta,
663
+ variantShape,
664
+ )
665
+ : buildModelAwareArgsSchema(input.operation, modelMeta, variantShape)
666
+ const takeCfg = takeConfigOfShape(variantShape)
667
+ const effectiveMax =
668
+ takeCfg.kind === 'none'
669
+ ? shared.maxLimit
670
+ : Math.min(shared.maxLimit, takeCfg.max)
671
+
672
+ const description = write
673
+ ? 'Guarded ' +
674
+ input.operation +
675
+ ' over ' +
676
+ input.model +
677
+ ' through the same pipeline as REST: guard enforcement, forced values, classified errors.' +
678
+ ' Explicitly allowlisted by the application; authorization runs before any database call.' +
679
+ (meta.destructive
680
+ ? ' This operation is destructive (destructiveHint).'
681
+ : '') +
682
+ ' Results larger than ' +
683
+ shared.maxResultBytes +
684
+ ' UTF-8 bytes are rejected.'
685
+ : 'Read-only ' +
686
+ input.operation +
687
+ ' over ' +
688
+ input.model +
689
+ ' through the guarded REST pipeline.' +
690
+ (isList
691
+ ? ' Rows are limited: take defaults to ' +
692
+ shared.defaultLimit +
693
+ ', must be a positive integer, and is clamped down to ' +
694
+ effectiveMax +
695
+ " (the guard shape may cap lower); take above the guard shape's own max is rejected."
696
+ : '') +
697
+ ' Results larger than ' +
698
+ shared.maxResultBytes +
699
+ ' UTF-8 bytes are rejected — narrow take or select.'
700
+
701
+ // every tool acts on this application's own database — a closed world
702
+ // (the MCP default for openWorldHint is true)
703
+ const annotations = {
704
+ readOnlyHint: meta.readOnly,
705
+ destructiveHint: meta.destructive,
706
+ idempotentHint: meta.idempotent,
707
+ openWorldHint: false,
708
+ }
709
+
710
+ return {
711
+ name: toolName(input.model, input.operation),
712
+ config: {
713
+ description,
714
+ annotations,
715
+ inputSchema: fromJsonSchema<Record<string, unknown>>(
716
+ schema as Parameters<typeof fromJsonSchema>[0],
717
+ ),
718
+ },
719
+ handler: (args: Record<string, unknown>, ctx?: McpCallContext) =>
720
+ runMcpCall({
721
+ input,
722
+ opConfig,
723
+ mergedPagination,
724
+ shared,
725
+ validatedArgs: args,
726
+ principal: ctx?.http?.authInfo,
727
+ isList,
728
+ write,
729
+ }),
730
+ }
731
+ },
732
+ }
733
+ }
734
+
735
+ async function runMcpCall(deps: {
736
+ input: CreateMcpReadToolInput | CreateMcpWriteToolInput
737
+ opConfig: NormalizedOperationConfig<McpHook, McpHook>
738
+ mergedPagination: PaginationConfig | undefined
739
+ shared: McpSharedOptions
740
+ validatedArgs: Record<string, unknown>
741
+ principal: AuthInfo | undefined
742
+ isList: boolean
743
+ write: boolean
744
+ }): Promise<{
745
+ content: Array<{ type: 'text'; text: string }>
746
+ isError?: boolean
747
+ }> {
748
+ const { input, opConfig, shared, isList, write } = deps
749
+ const location = input.model + '.' + input.operation + ' (MCP)'
750
+ try {
751
+ // Fail closed on missing verified authentication BEFORE any work.
752
+ const principal = deps.principal
753
+ if (!looksLikeAuthInfo(principal)) {
754
+ return isErrorResult('unauthenticated: a verified principal is required')
755
+ }
756
+
757
+ const sanitized = sanitizeKeys(deps.validatedArgs)
758
+ // a fully forced selector leaves `where` optional in the schema; the operation
759
+ // core still requires the body field (REST parity), so the empty object
760
+ // is injected and guard merges the forced selector into it
761
+ const args =
762
+ write &&
763
+ sanitized.where === undefined &&
764
+ OPERATION_BY_NAME[input.operation].requiredBodyFields.includes('where')
765
+ ? { ...sanitized, where: {} }
766
+ : sanitized
767
+ // REST write routes carry no query channel: parsedQuery stays empty and
768
+ // the body channel alone feeds the operation core
769
+ const holder: { value?: Record<string, unknown> } = {
770
+ value: write ? {} : args,
771
+ }
772
+ const channel: ArgsChannel = {
773
+ read: () => holder.value,
774
+ write: (next) => {
775
+ holder.value = next
776
+ },
777
+ }
778
+
779
+ // 2-3. route + settle variant, re-resolved for THIS call from the verified
780
+ // principal (the registration-time resolution only chose the tool set)
781
+ const caller = shared.resolveCaller(principal)
782
+ const routed: RouteStageOk = (() => {
783
+ const result = routeOperation({
784
+ guardRouting: opConfig.guardRouting,
785
+ caller,
786
+ })
787
+ settleStage(result)
788
+ return result
789
+ })()
790
+
791
+ // 4. MCP authorize — before context, before shape evaluation, before Prisma
792
+ await shared.authorize({
793
+ principal,
794
+ model: input.model,
795
+ operation: input.operation,
796
+ args,
797
+ variant: routed.variantKey,
798
+ })
799
+
800
+ // 5-6. prepareGuard + settle, with resolved-shape validation ALWAYS on
801
+ const getContext = memoizeContext(() => shared.resolveContext?.(principal))
802
+ const guard = await prepareGuardOperation(routed, {
803
+ guardShape: opConfig.guardShape,
804
+ opKind: opKindOf(input.operation),
805
+ policy: { dropGuard: false, validateResolvedShapes: true },
806
+ getContext,
807
+ args: channel,
808
+ writeArgs: undefined,
809
+ })
810
+ settleStage(guard)
811
+
812
+ // dynamic list shapes: the resolved shape must bound rows, with room for
813
+ // the injected default — classified 500, no database call
814
+ if (isList) {
815
+ const resolvedMap = guard.guardShape
816
+ const shapeForCheck =
817
+ routed.variantKey !== undefined && isPlainObject(resolvedMap)
818
+ ? (resolvedMap as Record<string, unknown>)[routed.variantKey]
819
+ : resolvedMap
820
+ requireListShapeTake(
821
+ shapeForCheck,
822
+ location + ' resolved shape',
823
+ shared.defaultLimit,
824
+ )
825
+ }
826
+
827
+ // pre-guard take normalization for list queries: take must be a positive
828
+ // integer (the guard rejects anything else); above maxLimit clamps down
829
+ if (isList) {
830
+ const current = holder.value as Record<string, unknown>
831
+ const raw = current.take
832
+ if (raw === undefined) {
833
+ current.take = shared.defaultLimit
834
+ } else if (
835
+ typeof raw !== 'number' ||
836
+ !Number.isFinite(raw) ||
837
+ !Number.isInteger(raw) ||
838
+ raw <= 0
839
+ ) {
840
+ throw new HttpError(400, 'Invalid take: must be a positive integer')
841
+ } else {
842
+ // the effective bound is the smaller of maxLimit and the resolved
843
+ // shape's take.max — exactly what the tool description states; the
844
+ // guard's own stricter rejection sits behind this as backstop
845
+ const resolvedShape =
846
+ routed.variantKey !== undefined && isPlainObject(guard.guardShape)
847
+ ? (guard.guardShape as Record<string, unknown>)[routed.variantKey]
848
+ : guard.guardShape
849
+ const takeCfg = takeConfigOfShape(resolvedShape)
850
+ const effectiveMax =
851
+ takeCfg.kind === 'none'
852
+ ? shared.maxLimit
853
+ : Math.min(shared.maxLimit, takeCfg.max)
854
+ if (raw > effectiveMax) {
855
+ current.take = effectiveMax
856
+ }
857
+ }
858
+ }
859
+
860
+ // 7. execute — the SAME shared stage REST uses. Write cores read their
861
+ // arguments from ctx.body, exactly like the REST write routes: the tool
862
+ // arguments ARE the request body.
863
+ const result = await executeOperation(
864
+ { variantKey: routed.variantKey, caller: routed.caller },
865
+ { guardShape: guard.guardShape },
866
+ {
867
+ core: input.core,
868
+ args: channel,
869
+ body: write ? args : undefined,
870
+ prisma: requirePrisma(shared.prisma),
871
+ pagination: deps.mergedPagination,
872
+ override: opConfig.override,
873
+ getContext,
874
+ },
875
+ )
876
+
877
+ const transformed = transformResult(result)
878
+ const json = JSON.stringify(transformed)
879
+
880
+ // 9. result-size enforcement on the final serialized payload
881
+ const bytes = new TextEncoder().encode(json).byteLength
882
+ if (bytes > shared.maxResultBytes) {
883
+ // a write is already COMMITTED here: an error would invite a retry
884
+ // (a duplicate create, a second update). Report success with the
885
+ // result omitted instead; reads keep the narrowing error.
886
+ if (write) {
887
+ return {
888
+ content: [
889
+ {
890
+ type: 'text',
891
+ text: JSON.stringify({
892
+ committed: true,
893
+ resultOmitted: true,
894
+ message:
895
+ input.operation +
896
+ ' on ' +
897
+ input.model +
898
+ ' was committed. Its result (' +
899
+ bytes +
900
+ ' UTF-8 bytes) exceeds the MCP result-size cap of ' +
901
+ shared.maxResultBytes +
902
+ ' bytes and was omitted. Do not retry; read the affected records with a narrower select.',
903
+ }),
904
+ },
905
+ ],
906
+ }
907
+ }
908
+ throw new McpResultSizeError(shared.maxResultBytes)
909
+ }
910
+
911
+ // 10. MCP response encoding
912
+ return { content: [{ type: 'text', text: json }] }
913
+ } catch (error) {
914
+ // actionable MCP-specific failures keep their message in EVERY
915
+ // environment — classifyError would strip them under NODE_ENV=production
916
+ if (error instanceof McpAuthorizationError) {
917
+ return isErrorResult(error.message)
918
+ }
919
+ if (error instanceof McpResultSizeError) {
920
+ return isErrorResult(error.message)
921
+ }
922
+ const classified = classifyError(error)
923
+ return isErrorResult(classified.message, classified.status)
924
+ }
925
+ }
926
+
927
+ /**
928
+ * App-level registry. `tools` is the explicit allowlist: only the model-op
929
+ * contributions the application passes in are registered — `enableAll` on a
930
+ * REST config never implies MCP exposure.
931
+ *
932
+ * Contract: `server` must be FRESH for this registration (the mount glue's
933
+ * per-request factory guarantees it — each request constructs a new
934
+ * McpServer). Under that contract registration is all-or-nothing: every
935
+ * contribution is validated, every payload prepared, names checked for
936
+ * duplicates and for pre-existing registrations (SDK introspection), and the
937
+ * whole set rehearsed on a throwaway McpServer BEFORE the caller's server is
938
+ * mutated. A server reused across registrations falls outside this contract.
939
+ */
940
+ export function registerMcpTools(
941
+ server: McpServer,
942
+ options: Partial<McpSharedOptions> & {
943
+ tools: readonly McpToolContribution[]
944
+ },
945
+ ): void {
946
+ validateMcpSharedOptions(options)
947
+ if (!Array.isArray(options.tools)) {
948
+ throw new Error('registerMcpTools: tools must be an array of contributions')
949
+ }
950
+ const shared = options as McpSharedOptions
951
+ // pass 1: preflight everything — one invalid tool registers nothing
952
+ for (const tool of options.tools) {
953
+ tool.validate(shared)
954
+ }
955
+ // pass 2: build every payload; nothing has touched the server yet, so a
956
+ // duplicate name or a late failure here still leaves zero tools registered
957
+ const registrations: McpRegistration[] = []
958
+ for (const tool of options.tools) {
959
+ const registration = tool.prepare(shared, shared.authInfo)
960
+ if (registration) registrations.push(registration)
961
+ }
962
+ const names = new Set<string>()
963
+ for (const registration of registrations) {
964
+ if (names.has(registration.name)) {
965
+ throw new Error(
966
+ 'registerMcpTools: duplicate tool name "' +
967
+ registration.name +
968
+ '" — two contributions resolved to the same tool.',
969
+ )
970
+ }
971
+ names.add(registration.name)
972
+ }
973
+ // pass 3: detect names ALREADY registered on the caller's server — the SDK
974
+ // exposes registered input schemas by name, and a collision would silently
975
+ // overwrite an existing tool. Best-effort: disabled tools are invisible to
976
+ // this introspection, which is exactly why the contract requires a FRESH
977
+ // server (the mount glue provides one per request).
978
+ for (const registration of registrations) {
979
+ const introspect = (
980
+ server as {
981
+ toolInputSchemaJson?: (
982
+ name: string,
983
+ ) => Record<string, unknown> | undefined
984
+ }
985
+ ).toolInputSchemaJson?.bind(server)
986
+ if (introspect && introspect(registration.name) !== undefined) {
987
+ throw new Error(
988
+ 'registerMcpTools: "' +
989
+ registration.name +
990
+ '" is already registered on this server. MCP never overwrites an existing tool.',
991
+ )
992
+ }
993
+ }
994
+ // pass 4: REHEARSE on a throwaway real McpServer — the SDK performs its
995
+ // own validation (schema checks, per-server duplicate detection) inside
996
+ // registerTool, so any throw there happens before the caller's server is
997
+ // touched. The rehearsal instance is discarded.
998
+ const rehearsal = new McpServer({
999
+ name: 'registration-rehearsal',
1000
+ version: '0.0.0',
1001
+ })
1002
+ for (const registration of registrations) {
1003
+ rehearsal.registerTool(
1004
+ registration.name,
1005
+ registration.config,
1006
+ registration.handler,
1007
+ )
1008
+ }
1009
+ // pass 4: mutate the real server
1010
+ for (const registration of registrations) {
1011
+ server.registerTool(
1012
+ registration.name,
1013
+ registration.config,
1014
+ registration.handler,
1015
+ )
1016
+ }
1017
+ }