@opensaas/stack-core 0.1.3 → 0.1.5

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,686 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { createMcpHandlers } from '../src/mcp/handler.js'
3
+ import type { OpenSaasConfig } from '../src/config/types.js'
4
+ import type { AccessContext } from '../src/access/types.js'
5
+ import type { McpSession, McpSessionProvider } from '../src/mcp/types.js'
6
+
7
+ describe('MCP Handler', () => {
8
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
9
+ let mockPrisma: any
10
+ let config: OpenSaasConfig
11
+ let getContext: (session?: { userId: string }) => AccessContext
12
+ let mockGetSession: McpSessionProvider
13
+
14
+ beforeEach(() => {
15
+ // Mock Prisma client
16
+ mockPrisma = {
17
+ post: {
18
+ findMany: vi.fn(),
19
+ create: vi.fn(),
20
+ update: vi.fn(),
21
+ delete: vi.fn(),
22
+ },
23
+ }
24
+
25
+ // Sample config with MCP enabled
26
+ config = {
27
+ db: {
28
+ provider: 'postgresql',
29
+ url: 'postgresql://localhost:5432/test',
30
+ },
31
+ mcp: {
32
+ enabled: true,
33
+ basePath: '/api/mcp',
34
+ },
35
+ lists: {
36
+ Post: {
37
+ fields: {
38
+ title: { type: 'text', validation: { isRequired: true } },
39
+ content: { type: 'text' },
40
+ },
41
+ access: {
42
+ operation: {
43
+ query: () => true,
44
+ create: ({ session }) => !!session,
45
+ update: ({ session }) => !!session,
46
+ delete: ({ session }) => !!session,
47
+ },
48
+ },
49
+ },
50
+ },
51
+ }
52
+
53
+ // Mock getContext function
54
+ getContext = vi.fn((session?: { userId: string }) => ({
55
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
56
+ db: mockPrisma as any,
57
+ session: session || null,
58
+ prisma: mockPrisma,
59
+ }))
60
+
61
+ // Mock session provider
62
+ mockGetSession = vi.fn(async () => ({
63
+ userId: 'user-123',
64
+ scopes: ['read', 'write'],
65
+ }))
66
+ })
67
+
68
+ describe('createMcpHandlers', () => {
69
+ it('should create GET, POST, DELETE handlers', () => {
70
+ const handlers = createMcpHandlers({ config, getSession: mockGetSession, getContext })
71
+
72
+ expect(handlers.GET).toBeDefined()
73
+ expect(handlers.POST).toBeDefined()
74
+ expect(handlers.DELETE).toBeDefined()
75
+ expect(typeof handlers.GET).toBe('function')
76
+ expect(typeof handlers.POST).toBe('function')
77
+ expect(typeof handlers.DELETE).toBe('function')
78
+ })
79
+
80
+ it('should return 404 when MCP is not enabled', async () => {
81
+ const disabledConfig = { ...config, mcp: { enabled: false } }
82
+ const handlers = createMcpHandlers({
83
+ config: disabledConfig,
84
+ getSession: mockGetSession,
85
+ getContext,
86
+ })
87
+
88
+ const request = new Request('http://localhost/api/mcp', {
89
+ method: 'POST',
90
+ headers: { 'Content-Type': 'application/json' },
91
+ body: JSON.stringify({ method: 'initialize' }),
92
+ })
93
+
94
+ const response = await handlers.POST(request)
95
+ expect(response.status).toBe(404)
96
+
97
+ const data = await response.json()
98
+ expect(data.error).toBe('MCP not enabled')
99
+ })
100
+
101
+ it('should return 401 when session is not provided', async () => {
102
+ const noSessionProvider: McpSessionProvider = vi.fn(async () => null)
103
+ const handlers = createMcpHandlers({
104
+ config,
105
+ getSession: noSessionProvider,
106
+ getContext,
107
+ })
108
+
109
+ const request = new Request('http://localhost/api/mcp', {
110
+ method: 'POST',
111
+ headers: { 'Content-Type': 'application/json' },
112
+ body: JSON.stringify({ method: 'initialize' }),
113
+ })
114
+
115
+ const response = await handlers.POST(request)
116
+ expect(response.status).toBe(401)
117
+ expect(response.headers.get('WWW-Authenticate')).toContain('Bearer')
118
+ })
119
+ })
120
+
121
+ describe('initialize method', () => {
122
+ it('should handle initialize request', async () => {
123
+ const handlers = createMcpHandlers({ config, getSession: mockGetSession, getContext })
124
+
125
+ const request = new Request('http://localhost/api/mcp', {
126
+ method: 'POST',
127
+ headers: { 'Content-Type': 'application/json' },
128
+ body: JSON.stringify({
129
+ jsonrpc: '2.0',
130
+ id: 1,
131
+ method: 'initialize',
132
+ params: {},
133
+ }),
134
+ })
135
+
136
+ const response = await handlers.POST(request)
137
+ expect(response.status).toBe(200)
138
+
139
+ const data = await response.json()
140
+ expect(data.jsonrpc).toBe('2.0')
141
+ expect(data.id).toBe(1)
142
+ expect(data.result.protocolVersion).toBe('2024-11-05')
143
+ expect(data.result.capabilities.tools).toBeDefined()
144
+ expect(data.result.serverInfo.name).toBe('opensaas-mcp-server')
145
+ })
146
+ })
147
+
148
+ describe('notifications/initialized method', () => {
149
+ it('should handle initialized notification with 204 response', async () => {
150
+ const handlers = createMcpHandlers({ config, getSession: mockGetSession, getContext })
151
+
152
+ const request = new Request('http://localhost/api/mcp', {
153
+ method: 'POST',
154
+ headers: { 'Content-Type': 'application/json' },
155
+ body: JSON.stringify({
156
+ jsonrpc: '2.0',
157
+ method: 'notifications/initialized',
158
+ }),
159
+ })
160
+
161
+ const response = await handlers.POST(request)
162
+ expect(response.status).toBe(204)
163
+ })
164
+ })
165
+
166
+ describe('tools/list method', () => {
167
+ it('should list all CRUD tools for enabled lists', async () => {
168
+ const handlers = createMcpHandlers({ config, getSession: mockGetSession, getContext })
169
+
170
+ const request = new Request('http://localhost/api/mcp', {
171
+ method: 'POST',
172
+ headers: { 'Content-Type': 'application/json' },
173
+ body: JSON.stringify({
174
+ jsonrpc: '2.0',
175
+ id: 1,
176
+ method: 'tools/list',
177
+ }),
178
+ })
179
+
180
+ const response = await handlers.POST(request)
181
+ expect(response.status).toBe(200)
182
+
183
+ const data = await response.json()
184
+ expect(data.result.tools).toBeDefined()
185
+ expect(Array.isArray(data.result.tools)).toBe(true)
186
+
187
+ const toolNames = data.result.tools.map((t: { name: string }) => t.name)
188
+ expect(toolNames).toContain('list_post_query')
189
+ expect(toolNames).toContain('list_post_create')
190
+ expect(toolNames).toContain('list_post_update')
191
+ expect(toolNames).toContain('list_post_delete')
192
+ })
193
+
194
+ it('should exclude disabled tools', async () => {
195
+ const configWithDisabledTools = {
196
+ ...config,
197
+ lists: {
198
+ Post: {
199
+ ...config.lists.Post,
200
+ mcp: {
201
+ tools: {
202
+ read: true,
203
+ create: true,
204
+ update: false,
205
+ delete: false,
206
+ },
207
+ },
208
+ },
209
+ },
210
+ }
211
+
212
+ const handlers = createMcpHandlers({
213
+ config: configWithDisabledTools,
214
+ getSession: mockGetSession,
215
+ getContext,
216
+ })
217
+
218
+ const request = new Request('http://localhost/api/mcp', {
219
+ method: 'POST',
220
+ headers: { 'Content-Type': 'application/json' },
221
+ body: JSON.stringify({
222
+ jsonrpc: '2.0',
223
+ id: 1,
224
+ method: 'tools/list',
225
+ }),
226
+ })
227
+
228
+ const response = await handlers.POST(request)
229
+ const data = await response.json()
230
+
231
+ const toolNames = data.result.tools.map((t: { name: string }) => t.name)
232
+ expect(toolNames).toContain('list_post_query')
233
+ expect(toolNames).toContain('list_post_create')
234
+ expect(toolNames).not.toContain('list_post_update')
235
+ expect(toolNames).not.toContain('list_post_delete')
236
+ })
237
+
238
+ it('should include custom tools', async () => {
239
+ const configWithCustomTools = {
240
+ ...config,
241
+ lists: {
242
+ Post: {
243
+ ...config.lists.Post,
244
+ mcp: {
245
+ customTools: [
246
+ {
247
+ name: 'publishPost',
248
+ description: 'Publish a post',
249
+ inputSchema: {
250
+ type: 'object' as const,
251
+ properties: { postId: { type: 'string' } },
252
+ },
253
+ handler: vi.fn(),
254
+ },
255
+ ],
256
+ },
257
+ },
258
+ },
259
+ }
260
+
261
+ const handlers = createMcpHandlers({
262
+ config: configWithCustomTools,
263
+ getSession: mockGetSession,
264
+ getContext,
265
+ })
266
+
267
+ const request = new Request('http://localhost/api/mcp', {
268
+ method: 'POST',
269
+ headers: { 'Content-Type': 'application/json' },
270
+ body: JSON.stringify({
271
+ jsonrpc: '2.0',
272
+ id: 1,
273
+ method: 'tools/list',
274
+ }),
275
+ })
276
+
277
+ const response = await handlers.POST(request)
278
+ const data = await response.json()
279
+
280
+ const toolNames = data.result.tools.map((t: { name: string }) => t.name)
281
+ expect(toolNames).toContain('publishPost')
282
+ })
283
+ })
284
+
285
+ describe('tools/call method - CRUD operations', () => {
286
+ it('should execute query operation', async () => {
287
+ const mockResults = [
288
+ { id: '1', title: 'Post 1', content: 'Content 1' },
289
+ { id: '2', title: 'Post 2', content: 'Content 2' },
290
+ ]
291
+ mockPrisma.post.findMany.mockResolvedValue(mockResults)
292
+
293
+ const handlers = createMcpHandlers({ config, getSession: mockGetSession, getContext })
294
+
295
+ const request = new Request('http://localhost/api/mcp', {
296
+ method: 'POST',
297
+ headers: { 'Content-Type': 'application/json' },
298
+ body: JSON.stringify({
299
+ jsonrpc: '2.0',
300
+ id: 1,
301
+ method: 'tools/call',
302
+ params: {
303
+ name: 'list_post_query',
304
+ arguments: {
305
+ where: {},
306
+ take: 10,
307
+ },
308
+ },
309
+ }),
310
+ })
311
+
312
+ const response = await handlers.POST(request)
313
+ expect(response.status).toBe(200)
314
+
315
+ const data = await response.json()
316
+ expect(data.result.content[0].type).toBe('text')
317
+
318
+ const result = JSON.parse(data.result.content[0].text)
319
+ expect(result.items).toEqual(mockResults)
320
+ expect(result.count).toBe(2)
321
+ expect(mockPrisma.post.findMany).toHaveBeenCalledWith({
322
+ where: {},
323
+ take: 10,
324
+ skip: undefined,
325
+ orderBy: undefined,
326
+ })
327
+ })
328
+
329
+ it('should execute create operation', async () => {
330
+ const mockResult = { id: '1', title: 'New Post', content: 'New Content' }
331
+ mockPrisma.post.create.mockResolvedValue(mockResult)
332
+
333
+ const handlers = createMcpHandlers({ config, getSession: mockGetSession, getContext })
334
+
335
+ const request = new Request('http://localhost/api/mcp', {
336
+ method: 'POST',
337
+ headers: { 'Content-Type': 'application/json' },
338
+ body: JSON.stringify({
339
+ jsonrpc: '2.0',
340
+ id: 1,
341
+ method: 'tools/call',
342
+ params: {
343
+ name: 'list_post_create',
344
+ arguments: {
345
+ data: { title: 'New Post', content: 'New Content' },
346
+ },
347
+ },
348
+ }),
349
+ })
350
+
351
+ const response = await handlers.POST(request)
352
+ expect(response.status).toBe(200)
353
+
354
+ const data = await response.json()
355
+ const result = JSON.parse(data.result.content[0].text)
356
+ expect(result.success).toBe(true)
357
+ expect(result.item).toEqual(mockResult)
358
+ expect(mockPrisma.post.create).toHaveBeenCalledWith({
359
+ data: { title: 'New Post', content: 'New Content' },
360
+ })
361
+ })
362
+
363
+ it('should execute update operation', async () => {
364
+ const mockResult = { id: '1', title: 'Updated Post', content: 'Updated Content' }
365
+ mockPrisma.post.update.mockResolvedValue(mockResult)
366
+
367
+ const handlers = createMcpHandlers({ config, getSession: mockGetSession, getContext })
368
+
369
+ const request = new Request('http://localhost/api/mcp', {
370
+ method: 'POST',
371
+ headers: { 'Content-Type': 'application/json' },
372
+ body: JSON.stringify({
373
+ jsonrpc: '2.0',
374
+ id: 1,
375
+ method: 'tools/call',
376
+ params: {
377
+ name: 'list_post_update',
378
+ arguments: {
379
+ where: { id: '1' },
380
+ data: { title: 'Updated Post' },
381
+ },
382
+ },
383
+ }),
384
+ })
385
+
386
+ const response = await handlers.POST(request)
387
+ expect(response.status).toBe(200)
388
+
389
+ const data = await response.json()
390
+ const result = JSON.parse(data.result.content[0].text)
391
+ expect(result.success).toBe(true)
392
+ expect(result.item).toEqual(mockResult)
393
+ })
394
+
395
+ it('should execute delete operation', async () => {
396
+ const mockResult = { id: '1', title: 'Deleted Post', content: 'Deleted Content' }
397
+ mockPrisma.post.delete.mockResolvedValue(mockResult)
398
+
399
+ const handlers = createMcpHandlers({ config, getSession: mockGetSession, getContext })
400
+
401
+ const request = new Request('http://localhost/api/mcp', {
402
+ method: 'POST',
403
+ headers: { 'Content-Type': 'application/json' },
404
+ body: JSON.stringify({
405
+ jsonrpc: '2.0',
406
+ id: 1,
407
+ method: 'tools/call',
408
+ params: {
409
+ name: 'list_post_delete',
410
+ arguments: {
411
+ where: { id: '1' },
412
+ },
413
+ },
414
+ }),
415
+ })
416
+
417
+ const response = await handlers.POST(request)
418
+ expect(response.status).toBe(200)
419
+
420
+ const data = await response.json()
421
+ const result = JSON.parse(data.result.content[0].text)
422
+ expect(result.success).toBe(true)
423
+ expect(result.deletedId).toBe('1')
424
+ })
425
+
426
+ it('should limit query results to max 100', async () => {
427
+ mockPrisma.post.findMany.mockResolvedValue([])
428
+
429
+ const handlers = createMcpHandlers({ config, getSession: mockGetSession, getContext })
430
+
431
+ const request = new Request('http://localhost/api/mcp', {
432
+ method: 'POST',
433
+ headers: { 'Content-Type': 'application/json' },
434
+ body: JSON.stringify({
435
+ jsonrpc: '2.0',
436
+ id: 1,
437
+ method: 'tools/call',
438
+ params: {
439
+ name: 'list_post_query',
440
+ arguments: {
441
+ take: 200, // Request more than max
442
+ },
443
+ },
444
+ }),
445
+ })
446
+
447
+ await handlers.POST(request)
448
+
449
+ expect(mockPrisma.post.findMany).toHaveBeenCalledWith({
450
+ where: undefined,
451
+ take: 100, // Should be limited to 100
452
+ skip: undefined,
453
+ orderBy: undefined,
454
+ })
455
+ })
456
+
457
+ it('should handle access denied by returning error', async () => {
458
+ mockPrisma.post.create.mockResolvedValue(null) // Simulates access denial
459
+
460
+ const handlers = createMcpHandlers({ config, getSession: mockGetSession, getContext })
461
+
462
+ const request = new Request('http://localhost/api/mcp', {
463
+ method: 'POST',
464
+ headers: { 'Content-Type': 'application/json' },
465
+ body: JSON.stringify({
466
+ jsonrpc: '2.0',
467
+ id: 1,
468
+ method: 'tools/call',
469
+ params: {
470
+ name: 'list_post_create',
471
+ arguments: {
472
+ data: { title: 'Test' },
473
+ },
474
+ },
475
+ }),
476
+ })
477
+
478
+ const response = await handlers.POST(request)
479
+ expect(response.status).toBe(400)
480
+
481
+ const data = await response.json()
482
+ expect(data.error.message).toContain('Access denied')
483
+ })
484
+ })
485
+
486
+ describe('tools/call method - custom tools', () => {
487
+ it('should execute custom tool handler', async () => {
488
+ const customHandler = vi.fn(async () => ({ published: true }))
489
+
490
+ const configWithCustomTools = {
491
+ ...config,
492
+ lists: {
493
+ Post: {
494
+ ...config.lists.Post,
495
+ mcp: {
496
+ customTools: [
497
+ {
498
+ name: 'publishPost',
499
+ description: 'Publish a post',
500
+ inputSchema: {
501
+ type: 'object' as const,
502
+ properties: { postId: { type: 'string' } },
503
+ },
504
+ handler: customHandler,
505
+ },
506
+ ],
507
+ },
508
+ },
509
+ },
510
+ }
511
+
512
+ const handlers = createMcpHandlers({
513
+ config: configWithCustomTools,
514
+ getSession: mockGetSession,
515
+ getContext,
516
+ })
517
+
518
+ const request = new Request('http://localhost/api/mcp', {
519
+ method: 'POST',
520
+ headers: { 'Content-Type': 'application/json' },
521
+ body: JSON.stringify({
522
+ jsonrpc: '2.0',
523
+ id: 1,
524
+ method: 'tools/call',
525
+ params: {
526
+ name: 'publishPost',
527
+ arguments: {
528
+ postId: 'post-123',
529
+ },
530
+ },
531
+ }),
532
+ })
533
+
534
+ const response = await handlers.POST(request)
535
+ expect(response.status).toBe(200)
536
+
537
+ expect(customHandler).toHaveBeenCalledWith({
538
+ input: { postId: 'post-123' },
539
+ context: expect.any(Object),
540
+ })
541
+
542
+ const data = await response.json()
543
+ const result = JSON.parse(data.result.content[0].text)
544
+ expect(result.published).toBe(true)
545
+ })
546
+
547
+ it('should handle unknown tool name', async () => {
548
+ const handlers = createMcpHandlers({ config, getSession: mockGetSession, getContext })
549
+
550
+ const request = new Request('http://localhost/api/mcp', {
551
+ method: 'POST',
552
+ headers: { 'Content-Type': 'application/json' },
553
+ body: JSON.stringify({
554
+ jsonrpc: '2.0',
555
+ id: 1,
556
+ method: 'tools/call',
557
+ params: {
558
+ name: 'unknown_tool',
559
+ arguments: {},
560
+ },
561
+ }),
562
+ })
563
+
564
+ const response = await handlers.POST(request)
565
+ expect(response.status).toBe(400)
566
+
567
+ const data = await response.json()
568
+ expect(data.error.message).toContain('Unknown tool')
569
+ })
570
+ })
571
+
572
+ describe('error handling', () => {
573
+ it('should handle invalid method', async () => {
574
+ const handlers = createMcpHandlers({ config, getSession: mockGetSession, getContext })
575
+
576
+ const request = new Request('http://localhost/api/mcp', {
577
+ method: 'POST',
578
+ headers: { 'Content-Type': 'application/json' },
579
+ body: JSON.stringify({
580
+ jsonrpc: '2.0',
581
+ id: 1,
582
+ method: 'invalid/method',
583
+ }),
584
+ })
585
+
586
+ const response = await handlers.POST(request)
587
+ expect(response.status).toBe(400)
588
+
589
+ const data = await response.json()
590
+ expect(data.error.code).toBe(-32601)
591
+ expect(data.error.message).toBe('Method not found')
592
+ })
593
+
594
+ it('should handle missing tool name in tools/call', async () => {
595
+ const handlers = createMcpHandlers({ config, getSession: mockGetSession, getContext })
596
+
597
+ const request = new Request('http://localhost/api/mcp', {
598
+ method: 'POST',
599
+ headers: { 'Content-Type': 'application/json' },
600
+ body: JSON.stringify({
601
+ jsonrpc: '2.0',
602
+ id: 1,
603
+ method: 'tools/call',
604
+ params: {
605
+ arguments: {},
606
+ },
607
+ }),
608
+ })
609
+
610
+ const response = await handlers.POST(request)
611
+ expect(response.status).toBe(400)
612
+
613
+ const data = await response.json()
614
+ expect(data.error.message).toContain('Tool name required')
615
+ })
616
+
617
+ it('should handle operation errors gracefully', async () => {
618
+ mockPrisma.post.findMany.mockRejectedValue(new Error('Database error'))
619
+
620
+ const handlers = createMcpHandlers({ config, getSession: mockGetSession, getContext })
621
+
622
+ const request = new Request('http://localhost/api/mcp', {
623
+ method: 'POST',
624
+ headers: { 'Content-Type': 'application/json' },
625
+ body: JSON.stringify({
626
+ jsonrpc: '2.0',
627
+ id: 1,
628
+ method: 'tools/call',
629
+ params: {
630
+ name: 'list_post_query',
631
+ arguments: {},
632
+ },
633
+ }),
634
+ })
635
+
636
+ const response = await handlers.POST(request)
637
+ expect(response.status).toBe(400)
638
+
639
+ const data = await response.json()
640
+ expect(data.error.message).toContain('Database error')
641
+ })
642
+ })
643
+
644
+ describe('session integration', () => {
645
+ it('should pass session to getContext', async () => {
646
+ const mockSession: McpSession = {
647
+ userId: 'user-456',
648
+ scopes: ['admin'],
649
+ }
650
+
651
+ const sessionProvider: McpSessionProvider = vi.fn(async () => mockSession)
652
+ const mockGetContext = vi.fn((session?: { userId: string }) => ({
653
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
654
+ db: mockPrisma as any,
655
+ session: session || null,
656
+ prisma: mockPrisma,
657
+ }))
658
+
659
+ mockPrisma.post.findMany.mockResolvedValue([])
660
+
661
+ const handlers = createMcpHandlers({
662
+ config,
663
+ getSession: sessionProvider,
664
+ getContext: mockGetContext,
665
+ })
666
+
667
+ const request = new Request('http://localhost/api/mcp', {
668
+ method: 'POST',
669
+ headers: { 'Content-Type': 'application/json' },
670
+ body: JSON.stringify({
671
+ jsonrpc: '2.0',
672
+ id: 1,
673
+ method: 'tools/call',
674
+ params: {
675
+ name: 'list_post_query',
676
+ arguments: {},
677
+ },
678
+ }),
679
+ })
680
+
681
+ await handlers.POST(request)
682
+
683
+ expect(mockGetContext).toHaveBeenCalledWith({ userId: 'user-456' })
684
+ })
685
+ })
686
+ })