@opensaas/stack-core 0.1.3 → 0.1.4

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.
@@ -0,0 +1,600 @@
1
+ /**
2
+ * Runtime MCP route handler
3
+ * Creates MCP API handlers from OpenSaaS config at runtime
4
+ */
5
+
6
+ import type { OpenSaasConfig, FieldConfig } from '../config/types.js'
7
+ import type { AccessContext } from '../access/types.js'
8
+ import { getDbKey } from '../lib/case-utils.js'
9
+ import type { McpSession, McpSessionProvider } from './types.js'
10
+
11
+ /**
12
+ * Create MCP route handlers
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * // app/api/mcp/[[...transport]]/route.ts
17
+ * import { createMcpHandlers } from '@opensaas/stack-core/mcp'
18
+ * import { createBetterAuthMcpAdapter } from '@opensaas/stack-auth/mcp'
19
+ * import config from '@/opensaas.config'
20
+ * import { auth } from '@/lib/auth'
21
+ * import { getContext } from '@/.opensaas/context'
22
+ *
23
+ * const { GET, POST, DELETE } = createMcpHandlers({
24
+ * config,
25
+ * getSession: createBetterAuthMcpAdapter(auth),
26
+ * getContext
27
+ * })
28
+ *
29
+ * export { GET, POST, DELETE }
30
+ * ```
31
+ */
32
+ export function createMcpHandlers(options: {
33
+ config: OpenSaasConfig
34
+ getSession: McpSessionProvider
35
+ getContext: (session?: { userId: string }) => AccessContext
36
+ }): {
37
+ GET: (req: Request) => Promise<Response>
38
+ POST: (req: Request) => Promise<Response>
39
+ DELETE: (req: Request) => Promise<Response>
40
+ } {
41
+ const { config, getSession, getContext } = options
42
+
43
+ // Validate MCP is enabled
44
+ if (!config.mcp?.enabled) {
45
+ const notEnabledHandler = async () =>
46
+ new Response(JSON.stringify({ error: 'MCP not enabled' }), {
47
+ status: 404,
48
+ headers: { 'Content-Type': 'application/json' },
49
+ })
50
+ return { GET: notEnabledHandler, POST: notEnabledHandler, DELETE: notEnabledHandler }
51
+ }
52
+
53
+ const basePath = config.mcp.basePath || '/api/mcp'
54
+
55
+ /**
56
+ * Main MCP request handler
57
+ */
58
+ const handler = async (req: Request): Promise<Response> => {
59
+ // Authenticate using provided session provider
60
+ const session = await getSession(req.headers)
61
+ if (!session) {
62
+ return new Response(null, {
63
+ status: 401,
64
+ headers: {
65
+ 'WWW-Authenticate': `Bearer realm="${basePath}", error="invalid_token"`,
66
+ },
67
+ })
68
+ }
69
+
70
+ try {
71
+ const body = (await req.json()) as {
72
+ jsonrpc?: string
73
+ id?: number | string
74
+ method: string
75
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- MCP protocol params are dynamic and depend on tool being called
76
+ params?: any
77
+ }
78
+
79
+ // Handle initialize
80
+ if (body.method === 'initialize') {
81
+ return handleInitialize(body.params, body.id)
82
+ }
83
+
84
+ // Handle notifications/initialized (sent by client after initialize response)
85
+ if (body.method === 'notifications/initialized') {
86
+ // Notifications don't require a response in JSON-RPC 2.0
87
+ return new Response(null, { status: 204 })
88
+ }
89
+
90
+ // Handle tools/list
91
+ if (body.method === 'tools/list') {
92
+ return handleToolsList(config, body.id)
93
+ }
94
+
95
+ // Handle tools/call
96
+ if (body.method === 'tools/call') {
97
+ return await handleToolsCall(body.params, session, config, getContext, body.id)
98
+ }
99
+
100
+ return new Response(
101
+ JSON.stringify({
102
+ jsonrpc: '2.0',
103
+ id: body.id ?? null,
104
+ error: { code: -32601, message: 'Method not found' },
105
+ }),
106
+ {
107
+ status: 400,
108
+ headers: { 'Content-Type': 'application/json' },
109
+ },
110
+ )
111
+ } catch (error) {
112
+ return new Response(
113
+ JSON.stringify({
114
+ error: 'Request handling failed',
115
+ message: error instanceof Error ? error.message : 'Unknown error',
116
+ }),
117
+ {
118
+ status: 500,
119
+ headers: { 'Content-Type': 'application/json' },
120
+ },
121
+ )
122
+ }
123
+ }
124
+
125
+ return {
126
+ GET: handler,
127
+ POST: handler,
128
+ DELETE: handler,
129
+ }
130
+ }
131
+
132
+ /**
133
+ * MCP tool definition following Model Context Protocol specification
134
+ */
135
+ type McpTool = {
136
+ name: string
137
+ description: string
138
+ inputSchema: {
139
+ type: 'object'
140
+ properties: Record<string, unknown>
141
+ required?: string[]
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Handle initialize request - respond with server capabilities
147
+ */
148
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Initialize params are from the client
149
+ function handleInitialize(_params?: any, id?: number | string): Response {
150
+ return new Response(
151
+ JSON.stringify({
152
+ jsonrpc: '2.0',
153
+ id: id ?? null,
154
+ result: {
155
+ protocolVersion: '2024-11-05',
156
+ capabilities: {
157
+ tools: {},
158
+ },
159
+ serverInfo: {
160
+ name: 'opensaas-mcp-server',
161
+ version: '1.0.0',
162
+ },
163
+ },
164
+ }),
165
+ {
166
+ headers: { 'Content-Type': 'application/json' },
167
+ },
168
+ )
169
+ }
170
+
171
+ /**
172
+ * Convert field config to JSON schema property
173
+ */
174
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Field configs have varying structures
175
+ function fieldToJsonSchema(fieldName: string, fieldConfig: any): Record<string, unknown> {
176
+ const baseSchema: Record<string, unknown> = {}
177
+
178
+ switch (fieldConfig.type) {
179
+ case 'text':
180
+ case 'password':
181
+ baseSchema.type = 'string'
182
+ if (fieldConfig.validation?.length) {
183
+ if (fieldConfig.validation.length.min)
184
+ baseSchema.minLength = fieldConfig.validation.length.min
185
+ if (fieldConfig.validation.length.max)
186
+ baseSchema.maxLength = fieldConfig.validation.length.max
187
+ }
188
+ break
189
+ case 'integer':
190
+ baseSchema.type = 'number'
191
+ if (fieldConfig.validation?.min !== undefined) baseSchema.minimum = fieldConfig.validation.min
192
+ if (fieldConfig.validation?.max !== undefined) baseSchema.maximum = fieldConfig.validation.max
193
+ break
194
+ case 'checkbox':
195
+ baseSchema.type = 'boolean'
196
+ break
197
+ case 'timestamp':
198
+ baseSchema.type = 'string'
199
+ baseSchema.format = 'date-time'
200
+ break
201
+ case 'select':
202
+ baseSchema.type = 'string'
203
+ if (fieldConfig.options) {
204
+ baseSchema.enum = fieldConfig.options.map((opt: { value: string }) => opt.value)
205
+ }
206
+ break
207
+ case 'relationship':
208
+ // For relationships, expect an ID or connect object
209
+ baseSchema.type = 'object'
210
+ baseSchema.properties = {
211
+ connect: {
212
+ type: 'object',
213
+ properties: {
214
+ id: { type: 'string' },
215
+ },
216
+ },
217
+ }
218
+ break
219
+ default:
220
+ // For custom field types, default to string
221
+ baseSchema.type = 'string'
222
+ }
223
+
224
+ return baseSchema
225
+ }
226
+
227
+ /**
228
+ * Generate field schemas for create/update operations
229
+ */
230
+
231
+ function generateFieldSchemas(
232
+ fields: Record<string, FieldConfig>,
233
+ operation: 'create' | 'update',
234
+ ): {
235
+ properties: Record<string, unknown>
236
+ required: string[]
237
+ } {
238
+ const properties: Record<string, unknown> = {}
239
+ const required: string[] = []
240
+
241
+ for (const [fieldName, fieldConfig] of Object.entries(fields)) {
242
+ // Skip system fields
243
+ if (['id', 'createdAt', 'updatedAt'].includes(fieldName)) continue
244
+
245
+ properties[fieldName] = fieldToJsonSchema(fieldName, fieldConfig)
246
+
247
+ // Add to required array if field is required for this operation
248
+ if (
249
+ operation === 'create' &&
250
+ 'validation' in fieldConfig &&
251
+ fieldConfig.validation?.isRequired
252
+ ) {
253
+ required.push(fieldName)
254
+ }
255
+ }
256
+
257
+ return { properties, required }
258
+ }
259
+
260
+ /**
261
+ * Handle tools/list request - list all available tools
262
+ */
263
+ function handleToolsList(config: OpenSaasConfig, id?: number | string): Response {
264
+ const tools: McpTool[] = []
265
+
266
+ // Generate CRUD tools for each list
267
+ for (const [listKey, listConfig] of Object.entries(config.lists)) {
268
+ // Check if MCP is enabled for this list
269
+ if (listConfig.mcp?.enabled === false) continue
270
+
271
+ const dbKey = getDbKey(listKey)
272
+ const defaultTools = config.mcp?.defaultTools || {
273
+ read: true,
274
+ create: true,
275
+ update: true,
276
+ delete: true,
277
+ }
278
+
279
+ const enabledTools = {
280
+ read: listConfig.mcp?.tools?.read ?? defaultTools.read ?? true,
281
+ create: listConfig.mcp?.tools?.create ?? defaultTools.create ?? true,
282
+ update: listConfig.mcp?.tools?.update ?? defaultTools.update ?? true,
283
+ delete: listConfig.mcp?.tools?.delete ?? defaultTools.delete ?? true,
284
+ }
285
+
286
+ // Read tool
287
+ if (enabledTools.read) {
288
+ tools.push({
289
+ name: `list_${dbKey}_query`,
290
+ description: `Query ${listKey} records with optional filters`,
291
+ inputSchema: {
292
+ type: 'object',
293
+ properties: {
294
+ where: { type: 'object', description: 'Prisma where clause' },
295
+ take: { type: 'number', description: 'Number of records to return (max 100)' },
296
+ skip: { type: 'number', description: 'Number of records to skip' },
297
+ orderBy: { type: 'object', description: 'Sort order' },
298
+ },
299
+ },
300
+ })
301
+ }
302
+
303
+ // Create tool
304
+ if (enabledTools.create) {
305
+ const fieldSchemas = generateFieldSchemas(listConfig.fields, 'create')
306
+ tools.push({
307
+ name: `list_${dbKey}_create`,
308
+ description: `Create a new ${listKey} record`,
309
+ inputSchema: {
310
+ type: 'object',
311
+ properties: {
312
+ data: {
313
+ type: 'object',
314
+ description: 'Record data with the following fields',
315
+ properties: fieldSchemas.properties,
316
+ required: fieldSchemas.required,
317
+ },
318
+ },
319
+ required: ['data'],
320
+ },
321
+ })
322
+ }
323
+
324
+ // Update tool
325
+ if (enabledTools.update) {
326
+ const fieldSchemas = generateFieldSchemas(listConfig.fields, 'update')
327
+ tools.push({
328
+ name: `list_${dbKey}_update`,
329
+ description: `Update an existing ${listKey} record`,
330
+ inputSchema: {
331
+ type: 'object',
332
+ properties: {
333
+ where: {
334
+ type: 'object',
335
+ description: 'Record identifier',
336
+ properties: {
337
+ id: { type: 'string' },
338
+ },
339
+ required: ['id'],
340
+ },
341
+ data: {
342
+ type: 'object',
343
+ description: 'Fields to update',
344
+ properties: fieldSchemas.properties,
345
+ },
346
+ },
347
+ required: ['where', 'data'],
348
+ },
349
+ })
350
+ }
351
+
352
+ // Delete tool
353
+ if (enabledTools.delete) {
354
+ tools.push({
355
+ name: `list_${dbKey}_delete`,
356
+ description: `Delete a ${listKey} record`,
357
+ inputSchema: {
358
+ type: 'object',
359
+ properties: {
360
+ where: {
361
+ type: 'object',
362
+ description: 'Record identifier',
363
+ properties: {
364
+ id: { type: 'string' },
365
+ },
366
+ required: ['id'],
367
+ },
368
+ },
369
+ required: ['where'],
370
+ },
371
+ })
372
+ }
373
+
374
+ // Custom tools
375
+ if (listConfig.mcp?.customTools) {
376
+ for (const customTool of listConfig.mcp.customTools) {
377
+ tools.push({
378
+ name: customTool.name,
379
+ description: customTool.description,
380
+ inputSchema: customTool.inputSchema,
381
+ })
382
+ }
383
+ }
384
+ }
385
+
386
+ return new Response(
387
+ JSON.stringify({
388
+ jsonrpc: '2.0',
389
+ id: id ?? null,
390
+ result: { tools },
391
+ }),
392
+ {
393
+ headers: { 'Content-Type': 'application/json' },
394
+ },
395
+ )
396
+ }
397
+
398
+ /**
399
+ * Handle tools/call request - execute a tool
400
+ */
401
+ async function handleToolsCall(
402
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- MCP tool params vary by tool
403
+ params: any,
404
+ session: McpSession,
405
+ config: OpenSaasConfig,
406
+ getContext: (session?: { userId: string }) => AccessContext,
407
+ id?: number | string,
408
+ ): Promise<Response> {
409
+ const toolName = params?.name
410
+ const toolArgs = params?.arguments || {}
411
+
412
+ console.log('Handling tool call:', toolName, toolArgs)
413
+
414
+ if (!toolName) {
415
+ return new Response(
416
+ JSON.stringify({
417
+ jsonrpc: '2.0',
418
+ id: id ?? null,
419
+ error: { code: -32602, message: 'Invalid params: Tool name required' },
420
+ }),
421
+ {
422
+ status: 400,
423
+ headers: { 'Content-Type': 'application/json' },
424
+ },
425
+ )
426
+ }
427
+
428
+ // Parse tool name: list_{dbKey}_{operation}
429
+ const match = toolName.match(/^list_([a-z][a-zA-Z0-9]*)_(query|create|update|delete)$/)
430
+
431
+ if (match) {
432
+ const [, dbKey, operation] = match
433
+ return await handleCrudTool(dbKey, operation, toolArgs, session, config, getContext, id)
434
+ }
435
+
436
+ // Handle custom tools
437
+ return await handleCustomTool(toolName, toolArgs, session, config, getContext, id)
438
+ }
439
+
440
+ /**
441
+ * Handle CRUD tool execution
442
+ */
443
+ async function handleCrudTool(
444
+ dbKey: string,
445
+ operation: string,
446
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Tool arguments vary by operation
447
+ args: any,
448
+ session: McpSession,
449
+ config: OpenSaasConfig,
450
+ getContext: (session?: { userId: string }) => AccessContext,
451
+ id?: number | string,
452
+ ): Promise<Response> {
453
+ // Create context with user session
454
+ const context = getContext({ userId: session.userId })
455
+
456
+ try {
457
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Result type varies by Prisma operation
458
+ let result: any
459
+
460
+ switch (operation) {
461
+ case 'query':
462
+ result = await context.db[dbKey].findMany({
463
+ where: args.where,
464
+ take: Math.min(args.take || 10, 100),
465
+ skip: args.skip,
466
+ orderBy: args.orderBy,
467
+ })
468
+ return createSuccessResponse(
469
+ {
470
+ items: result,
471
+ count: result.length,
472
+ },
473
+ id,
474
+ )
475
+
476
+ case 'create':
477
+ result = await context.db[dbKey].create({
478
+ data: args.data,
479
+ })
480
+ if (!result) {
481
+ return createErrorResponse(
482
+ 'Failed to create record. Access denied or validation failed.',
483
+ id,
484
+ )
485
+ }
486
+ return createSuccessResponse({ success: true, item: result }, id)
487
+
488
+ case 'update':
489
+ result = await context.db[dbKey].update({
490
+ where: args.where,
491
+ data: args.data,
492
+ })
493
+ if (!result) {
494
+ return createErrorResponse(
495
+ 'Failed to update record. Access denied or record not found.',
496
+ id,
497
+ )
498
+ }
499
+ return createSuccessResponse({ success: true, item: result }, id)
500
+
501
+ case 'delete':
502
+ result = await context.db[dbKey].delete({
503
+ where: args.where,
504
+ })
505
+ if (!result) {
506
+ return createErrorResponse(
507
+ 'Failed to delete record. Access denied or record not found.',
508
+ id,
509
+ )
510
+ }
511
+ return createSuccessResponse({ success: true, deletedId: args.where.id }, id)
512
+
513
+ default:
514
+ return createErrorResponse(`Unknown operation: ${operation}`, id)
515
+ }
516
+ } catch (error) {
517
+ return createErrorResponse(
518
+ 'Operation failed: ' + (error instanceof Error ? error.message : 'Unknown error'),
519
+ id,
520
+ )
521
+ }
522
+ }
523
+
524
+ /**
525
+ * Handle custom tool execution
526
+ */
527
+ async function handleCustomTool(
528
+ toolName: string,
529
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Custom tool arguments are user-defined
530
+ args: any,
531
+ session: McpSession,
532
+ config: OpenSaasConfig,
533
+ getContext: (session?: { userId: string }) => AccessContext,
534
+ id?: number | string,
535
+ ): Promise<Response> {
536
+ // Find custom tool in config
537
+ for (const [_listKey, listConfig] of Object.entries(config.lists)) {
538
+ const customTool = listConfig.mcp?.customTools?.find((t) => t.name === toolName)
539
+
540
+ if (customTool) {
541
+ const context = getContext({ userId: session.userId })
542
+
543
+ try {
544
+ const result = await customTool.handler({
545
+ input: args,
546
+ context,
547
+ })
548
+
549
+ return createSuccessResponse(result, id)
550
+ } catch (error) {
551
+ return createErrorResponse(
552
+ 'Custom tool execution failed: ' +
553
+ (error instanceof Error ? error.message : 'Unknown error'),
554
+ id,
555
+ )
556
+ }
557
+ }
558
+ }
559
+
560
+ return createErrorResponse(`Unknown tool: ${toolName}`, id)
561
+ }
562
+
563
+ /**
564
+ * Helper to create success response
565
+ */
566
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Response data structure is flexible per MCP protocol
567
+ function createSuccessResponse(data: any, id?: number | string): Response {
568
+ return new Response(
569
+ JSON.stringify({
570
+ jsonrpc: '2.0',
571
+ id: id ?? null,
572
+ result: {
573
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
574
+ },
575
+ }),
576
+ {
577
+ headers: { 'Content-Type': 'application/json' },
578
+ },
579
+ )
580
+ }
581
+
582
+ /**
583
+ * Helper to create error response
584
+ */
585
+ function createErrorResponse(message: string, id?: number | string): Response {
586
+ return new Response(
587
+ JSON.stringify({
588
+ jsonrpc: '2.0',
589
+ id: id ?? null,
590
+ error: {
591
+ code: -32603,
592
+ message,
593
+ },
594
+ }),
595
+ {
596
+ status: 400,
597
+ headers: { 'Content-Type': 'application/json' },
598
+ },
599
+ )
600
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * MCP (Model Context Protocol) integration for OpenSaaS Stack
3
+ * Auth-agnostic MCP server runtime
4
+ */
5
+
6
+ export { createMcpHandlers } from './handler.js'
7
+ export type { McpSession, McpSessionProvider } from './types.js'
@@ -0,0 +1,18 @@
1
+ /**
2
+ * MCP session type - auth-agnostic session information
3
+ * Can be provided by any authentication system (better-auth, custom, etc.)
4
+ */
5
+ export type McpSession = {
6
+ userId: string
7
+ scopes?: string[]
8
+ accessToken?: string
9
+ expiresAt?: Date
10
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Allows additional session properties from auth providers
11
+ [key: string]: any
12
+ }
13
+
14
+ /**
15
+ * Session provider function type
16
+ * Auth packages should implement this interface to integrate with MCP
17
+ */
18
+ export type McpSessionProvider = (headers: Headers) => Promise<McpSession | null>