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.
- package/README.md +223 -28
- package/dist/copy/operationDefinitions.d.ts +4 -1
- package/dist/copy/operationDefinitions.js +64 -3
- package/dist/copy/operationDefinitions.js.map +1 -1
- package/dist/generators/generateFastifyHandler.js +55 -9
- package/dist/generators/generateFastifyHandler.js.map +1 -1
- package/dist/generators/generateHonoHandler.js +44 -4
- package/dist/generators/generateHonoHandler.js.map +1 -1
- package/dist/generators/generateHonoOpenApi.d.ts +13 -0
- package/dist/generators/generateHonoOpenApi.js +70 -0
- package/dist/generators/generateHonoOpenApi.js.map +1 -0
- package/dist/generators/generateMcpApp.d.ts +10 -0
- package/dist/generators/generateMcpApp.js +113 -0
- package/dist/generators/generateMcpApp.js.map +1 -0
- package/dist/generators/generateModelMcp.d.ts +9 -0
- package/dist/generators/generateModelMcp.js +145 -0
- package/dist/generators/generateModelMcp.js.map +1 -0
- package/dist/generators/generateModelMetadata.js +9 -5
- package/dist/generators/generateModelMetadata.js.map +1 -1
- package/dist/generators/generateRouter.js +62 -56
- package/dist/generators/generateRouter.js.map +1 -1
- package/dist/generators/generateRouterFastify.js +56 -64
- package/dist/generators/generateRouterFastify.js.map +1 -1
- package/dist/generators/generateRouterHono.js +43 -411
- package/dist/generators/generateRouterHono.js.map +1 -1
- package/dist/generators/generateRouterPartsHono.d.ts +14 -0
- package/dist/generators/generateRouterPartsHono.js +180 -0
- package/dist/generators/generateRouterPartsHono.js.map +1 -0
- package/dist/generators/generateUnifiedHandler.js +36 -20
- package/dist/generators/generateUnifiedHandler.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +101 -3
- package/dist/index.js.map +1 -1
- package/dist/utils/copyFiles.d.ts +1 -1
- package/dist/utils/copyFiles.js +20 -1
- package/dist/utils/copyFiles.js.map +1 -1
- package/dist/utils/mcpGate.d.ts +10 -0
- package/dist/utils/mcpGate.js +118 -0
- package/dist/utils/mcpGate.js.map +1 -0
- package/dist/utils/writeFileSafely.js +6 -0
- package/dist/utils/writeFileSafely.js.map +1 -1
- package/package.json +18 -3
- package/src/copy/buildModelOpenApi.ts +76 -126
- package/src/copy/docsRenderer.ts +14 -15
- package/src/copy/mcpRuntime.ts +1017 -0
- package/src/copy/operationDefinitions.ts +78 -4
- package/src/copy/operationPipeline.ts +267 -0
- package/src/copy/operationRuntime.ts +24 -14
- package/src/copy/operationSchemas.ts +2773 -0
- package/src/copy/pagination.ts +10 -2
- package/src/copy/routeConfig.hono.ts +34 -5
- package/src/copy/routeConfig.ts +1 -1
- package/src/copy/routerParts.ts +524 -0
- package/src/generators/generateFastifyHandler.ts +55 -9
- package/src/generators/generateHonoHandler.ts +44 -4
- package/src/generators/generateHonoOpenApi.ts +93 -0
- package/src/generators/generateMcpApp.ts +133 -0
- package/src/generators/generateModelMcp.ts +208 -0
- package/src/generators/generateModelMetadata.ts +16 -10
- package/src/generators/generateRouter.ts +62 -56
- package/src/generators/generateRouterFastify.ts +56 -64
- package/src/generators/generateRouterHono.ts +49 -459
- package/src/generators/generateRouterPartsHono.ts +220 -0
- package/src/generators/generateUnifiedHandler.ts +36 -21
- package/src/index.ts +116 -3
- package/src/utils/copyFiles.ts +36 -0
- package/src/utils/mcpGate.ts +157 -0
- 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
|
+
}
|