@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +39 -0
- package/CLAUDE.md +4 -2
- package/dist/config/types.d.ts +12 -2
- package/dist/config/types.d.ts.map +1 -1
- package/dist/mcp/handler.d.ts +40 -0
- package/dist/mcp/handler.d.ts.map +1 -0
- package/dist/mcp/handler.js +460 -0
- package/dist/mcp/handler.js.map +1 -0
- package/dist/mcp/index.d.ts +7 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/index.js +6 -0
- package/dist/mcp/index.js.map +1 -0
- package/dist/mcp/types.d.ts +17 -0
- package/dist/mcp/types.d.ts.map +1 -0
- package/dist/mcp/types.js +2 -0
- package/dist/mcp/types.js.map +1 -0
- package/package.json +5 -1
- package/src/config/types.ts +58 -45
- package/src/mcp/handler.ts +600 -0
- package/src/mcp/index.ts +7 -0
- package/src/mcp/types.ts +18 -0
- package/tests/mcp-handler.test.ts +686 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -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
|
+
}
|
package/src/mcp/index.ts
ADDED
package/src/mcp/types.ts
ADDED
|
@@ -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>
|