-
Notifications
You must be signed in to change notification settings - Fork 17
Expand file tree
/
Copy pathserver_test.go
More file actions
1027 lines (890 loc) · 39.7 KB
/
server_test.go
File metadata and controls
1027 lines (890 loc) · 39.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package server
import (
"fmt"
"net/http"
"reflect"
"sync"
"testing"
transport "github.com/ckanthony/gin-mcp/pkg/transport"
"github.com/ckanthony/gin-mcp/pkg/types"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)
// --- Mock Transport for Testing ---
type mockTransport struct {
HandleConnectionCalled bool
HandleMessageCalled bool
RegisteredHandlers map[string]transport.MessageHandler
NotifiedToolsChanged bool
AddedConnections map[string]chan *types.MCPMessage
mu sync.RWMutex
// Add fields to mock executeTool interactions if needed for handleToolCall tests
MockExecuteResult interface{}
MockExecuteError error
LastExecuteTool *types.Tool
LastExecuteArgs map[string]interface{}
}
func newMockTransport() *mockTransport {
return &mockTransport{
RegisteredHandlers: make(map[string]transport.MessageHandler),
AddedConnections: make(map[string]chan *types.MCPMessage),
}
}
func (m *mockTransport) HandleConnection(c *gin.Context) {
m.HandleConnectionCalled = true
h := c.Writer.Header()
h.Set("Content-Type", "text/event-stream") // Mock setting headers
c.Status(http.StatusOK)
}
func (m *mockTransport) HandleMessage(c *gin.Context) {
m.HandleMessageCalled = true
c.Status(http.StatusOK)
}
func (m *mockTransport) SendInitialMessage(c *gin.Context, msg *types.MCPMessage) error { return nil }
func (m *mockTransport) RegisterHandler(method string, handler transport.MessageHandler) {
m.RegisteredHandlers[method] = handler
}
func (m *mockTransport) AddConnection(connID string, msgChan chan *types.MCPMessage) {
m.mu.Lock()
defer m.mu.Unlock()
m.AddedConnections[connID] = msgChan
}
func (m *mockTransport) RemoveConnection(connID string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.AddedConnections, connID)
}
func (m *mockTransport) NotifyToolsChanged() { m.NotifiedToolsChanged = true }
// Mock executeTool behavior for handleToolCall tests
func (m *mockTransport) executeTool(tool *types.Tool, args map[string]interface{}) interface{} {
m.LastExecuteTool = tool
m.LastExecuteArgs = args
if m.MockExecuteError != nil {
// Return nil or a specific error structure if the real executeTool does
return nil // Simulate failure by returning nil
}
return m.MockExecuteResult
}
// --- End Mock Transport ---
func TestNew(t *testing.T) {
engine := gin.New()
config := &Config{
Name: "TestServer",
Description: "Test Description",
BaseURL: "http://test.com",
}
mcp := New(engine, config)
assert.NotNil(t, mcp)
assert.Equal(t, engine, mcp.engine)
assert.Equal(t, "TestServer", mcp.name)
assert.Equal(t, "Test Description", mcp.description)
assert.Equal(t, "http://test.com", mcp.baseURL)
assert.NotNil(t, mcp.operations)
assert.NotNil(t, mcp.registeredSchemas)
assert.Equal(t, config, mcp.config)
// Test nil config
mcpNil := New(engine, nil)
assert.NotNil(t, mcpNil)
assert.Equal(t, "Gin MCP", mcpNil.name) // Default name
assert.Equal(t, "MCP server for Gin application", mcpNil.description) // Default desc
}
func TestMount(t *testing.T) {
engine := gin.New()
mockT := newMockTransport()
mcp := New(engine, nil)
mcp.transport = mockT // Inject mock transport
// Simulate the handler registration part of Mount()
mcp.transport.RegisterHandler("initialize", mcp.handleInitialize)
mcp.transport.RegisterHandler("tools/list", mcp.handleToolsList)
mcp.transport.RegisterHandler("tools/call", mcp.handleToolCall)
// Check if handlers were registered on the mock
assert.NotNil(t, mockT.RegisteredHandlers["initialize"], "initialize handler should be registered")
assert.NotNil(t, mockT.RegisteredHandlers["tools/list"], "tools/list handler should be registered")
assert.NotNil(t, mockT.RegisteredHandlers["tools/call"], "tools/call handler should be registered")
// We are not calling the real Mount, so we don't check gin routes here.
// Route mounting could be a separate test if needed.
}
func TestSetupServerAndFilter(t *testing.T) {
engine := gin.New()
engine.GET("/items", func(c *gin.Context) {})
engine.POST("/items/:id", func(c *gin.Context) {}) // Different path/method
engine.GET("/users", func(c *gin.Context) {})
engine.GET("/mcp/ignore", func(c *gin.Context) {}) // Should be ignored
mcp := New(engine, &Config{})
mcp.Mount("/mcp")
err := mcp.SetupServer()
assert.NoError(t, err)
assert.Len(t, mcp.tools, 4, "Should have 4 tools initially (items GET, items POST, users GET, mcp GET)")
expectedNames := map[string]bool{
"GET_items": true,
"POST_items_id": true,
"GET_users": true,
"GET_mcp_ignore": true,
}
actualNames := make(map[string]bool)
for _, tool := range mcp.tools {
actualNames[tool.Name] = true
}
assert.Equal(t, expectedNames, actualNames, "Initial tool names mismatch")
// --- Test Include Filter ---
mcp.config.IncludeOperations = []string{"GET_items", "GET_users"}
// Re-run setup to get all tools, then filter
err = mcp.SetupServer()
assert.NoError(t, err)
mcp.filterTools() // Filter the full set
assert.Len(t, mcp.tools, 2, "Should have 2 tools after include filter")
assert.True(t, toolExists(mcp.tools, "GET_items"), "Include filter should keep GET_items")
assert.True(t, toolExists(mcp.tools, "GET_users"), "Include filter should keep GET_users")
assert.False(t, toolExists(mcp.tools, "POST_items_id"), "Include filter should remove POST_items_id")
assert.False(t, toolExists(mcp.tools, "GET_mcp_ignore"), "Include filter should remove GET_mcp_ignore")
// --- Test Exclude Filter ---
// Re-run setup to get all tools, then filter
err = mcp.SetupServer()
assert.NoError(t, err)
mcp.config.IncludeOperations = nil // Clear include filter
mcp.config.ExcludeOperations = []string{"POST_items_id", "GET_mcp_ignore"} // Exclude two
mcp.filterTools() // Filter the full set
assert.Len(t, mcp.tools, 2, "Should have 2 tools after exclude filter")
assert.True(t, toolExists(mcp.tools, "GET_items"), "Exclude filter should keep GET_items")
assert.True(t, toolExists(mcp.tools, "GET_users"), "Exclude filter should keep GET_users")
assert.False(t, toolExists(mcp.tools, "POST_items_id"), "Exclude filter should remove POST_items_id")
assert.False(t, toolExists(mcp.tools, "GET_mcp_ignore"), "Exclude filter should remove GET_mcp_ignore")
// --- Test Exclusion Wins Over Inclusion (operations) ---
// Re-run setup to get all tools, then filter
err = mcp.SetupServer()
assert.NoError(t, err)
mcp.config.IncludeOperations = []string{"GET_items", "GET_users"}
mcp.config.ExcludeOperations = []string{"GET_items"} // Exclude should win over include
mcp.filterTools() // Filter the full set
assert.Len(t, mcp.tools, 1, "Exclusion should win over inclusion")
assert.False(t, toolExists(mcp.tools, "GET_items"), "Exclude should remove GET_items even though it's included")
assert.True(t, toolExists(mcp.tools, "GET_users"), "Include should keep GET_users (not excluded)")
assert.False(t, toolExists(mcp.tools, "POST_items_id"), "Include should filter out non-included POST_items_id")
assert.False(t, toolExists(mcp.tools, "GET_mcp_ignore"), "Include should filter out non-included GET_mcp_ignore")
// --- Test Filtering with no initial tools (should not panic) ---
mcp.tools = []types.Tool{} // Explicitly empty tools
mcp.config.IncludeOperations = []string{"GET_items"}
mcp.config.ExcludeOperations = []string{"GET_users"}
mcp.filterTools()
assert.Len(t, mcp.tools, 0, "Filtering should not panic or error with no tools initially")
}
func TestSetupServerAndFilter_Tags(t *testing.T) {
engine := gin.New()
// Register handlers with tags
engine.GET("/items", listItems) // tags: public, internal
engine.GET("/items/:id", getItem) // tags: public
engine.POST("/items", createItem) // tags: admin
engine.DELETE("/items/:id", deleteItem) // tags: admin, internal
engine.GET("/health", healthCheck) // no tags
// --- Test IncludeTags Filter ---
t.Run("IncludeTags_PublicOnly", func(t *testing.T) {
mcp := New(engine, &Config{
IncludeTags: []string{"public"},
})
err := mcp.SetupServer()
assert.NoError(t, err)
// Should include: listItems (public, internal) and getItem (public)
assert.Len(t, mcp.tools, 2, "Should have 2 tools with 'public' tag")
assert.True(t, toolExists(mcp.tools, "GET_items"), "Should include GET_items (has public tag)")
assert.True(t, toolExists(mcp.tools, "GET_items_id"), "Should include GET_items_id (has public tag)")
assert.False(t, toolExists(mcp.tools, "POST_items"), "Should exclude POST_items (no public tag)")
assert.False(t, toolExists(mcp.tools, "DELETE_items_id"), "Should exclude DELETE_items_id (no public tag)")
assert.False(t, toolExists(mcp.tools, "GET_health"), "Should exclude GET_health (no tags)")
})
t.Run("IncludeTags_AdminOnly", func(t *testing.T) {
mcp := New(engine, &Config{
IncludeTags: []string{"admin"},
})
err := mcp.SetupServer()
assert.NoError(t, err)
// Should include: createItem (admin) and deleteItem (admin, internal)
assert.Len(t, mcp.tools, 2, "Should have 2 tools with 'admin' tag")
assert.True(t, toolExists(mcp.tools, "POST_items"), "Should include POST_items (has admin tag)")
assert.True(t, toolExists(mcp.tools, "DELETE_items_id"), "Should include DELETE_items_id (has admin tag)")
assert.False(t, toolExists(mcp.tools, "GET_items"), "Should exclude GET_items (no admin tag)")
})
t.Run("IncludeTags_Multiple", func(t *testing.T) {
mcp := New(engine, &Config{
IncludeTags: []string{"public", "admin"},
})
err := mcp.SetupServer()
assert.NoError(t, err)
// Should include: listItems, getItem, createItem, deleteItem (all have public OR admin)
assert.Len(t, mcp.tools, 4, "Should have 4 tools with 'public' or 'admin' tags")
assert.True(t, toolExists(mcp.tools, "GET_items"))
assert.True(t, toolExists(mcp.tools, "GET_items_id"))
assert.True(t, toolExists(mcp.tools, "POST_items"))
assert.True(t, toolExists(mcp.tools, "DELETE_items_id"))
assert.False(t, toolExists(mcp.tools, "GET_health"), "Should exclude GET_health (no matching tags)")
})
// --- Test ExcludeTags Filter ---
t.Run("ExcludeTags_InternalOnly", func(t *testing.T) {
mcp := New(engine, &Config{
ExcludeTags: []string{"internal"},
})
err := mcp.SetupServer()
assert.NoError(t, err)
// Should exclude: listItems and deleteItem (both have 'internal' tag)
assert.Len(t, mcp.tools, 3, "Should have 3 tools without 'internal' tag")
assert.False(t, toolExists(mcp.tools, "GET_items"), "Should exclude GET_items (has internal tag)")
assert.True(t, toolExists(mcp.tools, "GET_items_id"), "Should include GET_items_id (no internal tag)")
assert.True(t, toolExists(mcp.tools, "POST_items"), "Should include POST_items (no internal tag)")
assert.False(t, toolExists(mcp.tools, "DELETE_items_id"), "Should exclude DELETE_items_id (has internal tag)")
assert.True(t, toolExists(mcp.tools, "GET_health"), "Should include GET_health (no tags, not excluded)")
})
t.Run("ExcludeTags_AdminOnly", func(t *testing.T) {
mcp := New(engine, &Config{
ExcludeTags: []string{"admin"},
})
err := mcp.SetupServer()
assert.NoError(t, err)
// Should exclude: createItem and deleteItem (both have 'admin' tag)
assert.Len(t, mcp.tools, 3, "Should have 3 tools without 'admin' tag")
assert.True(t, toolExists(mcp.tools, "GET_items"))
assert.True(t, toolExists(mcp.tools, "GET_items_id"))
assert.False(t, toolExists(mcp.tools, "POST_items"), "Should exclude POST_items (has admin tag)")
assert.False(t, toolExists(mcp.tools, "DELETE_items_id"), "Should exclude DELETE_items_id (has admin tag)")
assert.True(t, toolExists(mcp.tools, "GET_health"))
})
// --- Test Combined Include and Exclude Tags ---
t.Run("IncludeTags_and_ExcludeTags", func(t *testing.T) {
mcp := New(engine, &Config{
IncludeTags: []string{"public", "admin"}, // Include public and admin
ExcludeTags: []string{"internal"}, // But exclude internal
})
err := mcp.SetupServer()
assert.NoError(t, err)
// Include: listItems, getItem, createItem, deleteItem (all have public OR admin)
// Then exclude: listItems and deleteItem (both have internal)
// Result: getItem, createItem
assert.Len(t, mcp.tools, 2, "Should have 2 tools after include and exclude filters")
assert.False(t, toolExists(mcp.tools, "GET_items"), "Should exclude GET_items (has internal tag)")
assert.True(t, toolExists(mcp.tools, "GET_items_id"), "Should include GET_items_id (public, not internal)")
assert.True(t, toolExists(mcp.tools, "POST_items"), "Should include POST_items (admin, not internal)")
assert.False(t, toolExists(mcp.tools, "DELETE_items_id"), "Should exclude DELETE_items_id (has internal tag)")
assert.False(t, toolExists(mcp.tools, "GET_health"), "Should exclude GET_health (no matching include tags)")
})
// --- Test Precedence: Operations vs Tags ---
t.Run("IncludeOperations_Takes_Precedence_Over_IncludeTags", func(t *testing.T) {
mcp := New(engine, &Config{
IncludeOperations: []string{"GET_items"},
IncludeTags: []string{"admin"}, // Should be ignored
})
err := mcp.SetupServer()
assert.NoError(t, err)
// Only GET_items should be included (operations take precedence)
assert.Len(t, mcp.tools, 1, "IncludeOperations should take precedence")
assert.True(t, toolExists(mcp.tools, "GET_items"))
assert.False(t, toolExists(mcp.tools, "POST_items"), "Should not include admin-tagged tools")
})
t.Run("ExcludeOperations_Takes_Precedence_Over_ExcludeTags", func(t *testing.T) {
mcp := New(engine, &Config{
ExcludeOperations: []string{"GET_health"},
ExcludeTags: []string{"admin"}, // Should be ignored
})
err := mcp.SetupServer()
assert.NoError(t, err)
// All tools except GET_health should be included (operations take precedence over tags)
assert.Len(t, mcp.tools, 4, "ExcludeOperations should take precedence")
assert.True(t, toolExists(mcp.tools, "GET_items"))
assert.True(t, toolExists(mcp.tools, "GET_items_id"))
assert.True(t, toolExists(mcp.tools, "POST_items"), "Should include admin-tagged tools (ExcludeTags ignored)")
assert.True(t, toolExists(mcp.tools, "DELETE_items_id"), "Should include admin-tagged tools (ExcludeTags ignored)")
assert.False(t, toolExists(mcp.tools, "GET_health"))
})
// --- Test Exclusion Wins Over Inclusion ---
t.Run("ExcludeTags_Wins_Over_IncludeTags", func(t *testing.T) {
mcp := New(engine, &Config{
IncludeTags: []string{"public", "internal"}, // Include public and internal
ExcludeTags: []string{"internal"}, // But exclude internal
})
err := mcp.SetupServer()
assert.NoError(t, err)
// Include: listItems (public+internal), getItem (public), deleteItem (admin+internal)
// Then exclude: listItems and deleteItem (both have internal)
// Result: only getItem
assert.Len(t, mcp.tools, 1, "Exclusion should win over inclusion")
assert.False(t, toolExists(mcp.tools, "GET_items"), "Should exclude despite being included")
assert.True(t, toolExists(mcp.tools, "GET_items_id"), "Should include (public, not internal)")
assert.False(t, toolExists(mcp.tools, "DELETE_items_id"), "Should exclude despite being included")
})
t.Run("ExcludeOperations_Wins_Over_IncludeOperations", func(t *testing.T) {
mcp := New(engine, &Config{
IncludeOperations: []string{"GET_items", "POST_items"},
ExcludeOperations: []string{"GET_items"}, // Exclude one of the included
})
err := mcp.SetupServer()
assert.NoError(t, err)
// Include: GET_items, POST_items
// Then exclude: GET_items
// Result: only POST_items
assert.Len(t, mcp.tools, 1, "Exclusion should win over inclusion")
assert.False(t, toolExists(mcp.tools, "GET_items"), "Should exclude despite being included")
assert.True(t, toolExists(mcp.tools, "POST_items"), "Should keep POST_items (not excluded)")
})
t.Run("ExcludeTags_Works_With_IncludeOperations", func(t *testing.T) {
mcp := New(engine, &Config{
IncludeOperations: []string{"GET_items", "POST_items", "DELETE_items_id"},
ExcludeTags: []string{"internal"}, // Exclude internal-tagged tools
})
err := mcp.SetupServer()
assert.NoError(t, err)
// Include: GET_items, POST_items, DELETE_items_id
// Then exclude by tag: GET_items (has internal), DELETE_items_id (has internal)
// Result: only POST_items
assert.Len(t, mcp.tools, 1, "Tag exclusion should work with operation inclusion")
assert.False(t, toolExists(mcp.tools, "GET_items"), "Should exclude GET_items (has internal tag)")
assert.True(t, toolExists(mcp.tools, "POST_items"), "Should keep POST_items (no internal tag)")
assert.False(t, toolExists(mcp.tools, "DELETE_items_id"), "Should exclude DELETE_items_id (has internal tag)")
})
// --- Test Config Not Mutated ---
t.Run("Config_Not_Mutated_After_SetupServer", func(t *testing.T) {
config := &Config{
IncludeOperations: []string{"GET_items"},
IncludeTags: []string{"public"}, // Will be ignored but should not be cleared
ExcludeOperations: []string{"POST_items"},
ExcludeTags: []string{"admin"}, // Will be ignored but should not be cleared
}
mcp := New(engine, config)
err := mcp.SetupServer()
assert.NoError(t, err)
// Verify original config is not mutated
assert.Len(t, config.IncludeOperations, 1, "IncludeOperations should not be modified")
assert.Equal(t, []string{"GET_items"}, config.IncludeOperations)
assert.Len(t, config.IncludeTags, 1, "IncludeTags should not be cleared")
assert.Equal(t, []string{"public"}, config.IncludeTags)
assert.Len(t, config.ExcludeOperations, 1, "ExcludeOperations should not be modified")
assert.Equal(t, []string{"POST_items"}, config.ExcludeOperations)
assert.Len(t, config.ExcludeTags, 1, "ExcludeTags should not be cleared")
assert.Equal(t, []string{"admin"}, config.ExcludeTags)
// Call SetupServer again and verify config is still intact
mcp.tools = []types.Tool{} // Force re-discovery
err = mcp.SetupServer()
assert.NoError(t, err)
assert.Len(t, config.IncludeTags, 1, "IncludeTags should still be intact after second call")
assert.Equal(t, []string{"public"}, config.IncludeTags)
assert.Len(t, config.ExcludeTags, 1, "ExcludeTags should still be intact after second call")
assert.Equal(t, []string{"admin"}, config.ExcludeTags)
})
}
// Helper for checking tool existence
func toolExists(tools []types.Tool, name string) bool {
for _, tool := range tools {
if tool.Name == name {
return true
}
}
return false
}
func TestHandleInitialize(t *testing.T) {
mcp := New(gin.New(), &Config{Name: "MyServer"})
req := &types.MCPMessage{
Jsonrpc: "2.0",
ID: types.RawMessage(`"init-req-1"`),
Method: "initialize",
Params: map[string]interface{}{"clientInfo": "testClient"},
}
resp := mcp.handleInitialize(req)
assert.NotNil(t, resp)
assert.Equal(t, req.ID, resp.ID)
assert.Nil(t, resp.Error)
assert.NotNil(t, resp.Result)
resultMap, ok := resp.Result.(map[string]interface{})
assert.True(t, ok)
assert.Equal(t, "2024-11-05", resultMap["protocolVersion"])
assert.Contains(t, resultMap, "capabilities")
serverInfo, ok := resultMap["serverInfo"].(map[string]interface{})
assert.True(t, ok)
assert.Equal(t, "MyServer", serverInfo["name"])
}
func TestHandleInitialize_InvalidParams(t *testing.T) {
mcp := New(gin.New(), nil)
req := &types.MCPMessage{
Jsonrpc: "2.0",
ID: types.RawMessage(`"init-req-invalid"`),
Method: "initialize",
Params: "not a map", // Invalid parameter type
}
resp := mcp.handleInitialize(req)
assert.NotNil(t, resp)
assert.Equal(t, req.ID, resp.ID)
assert.Nil(t, resp.Result)
assert.NotNil(t, resp.Error)
errMap, ok := resp.Error.(map[string]interface{})
assert.True(t, ok)
assert.Equal(t, -32602, errMap["code"].(int))
assert.Contains(t, errMap["message"].(string), "Invalid parameters format")
}
func TestHandleToolsList(t *testing.T) {
engine := gin.New()
engine.GET("/tool1", func(c *gin.Context) {})
mcp := New(engine, nil)
err := mcp.SetupServer() // Populate tools
assert.NoError(t, err)
assert.NotEmpty(t, mcp.tools) // Ensure tools are loaded
req := &types.MCPMessage{
Jsonrpc: "2.0",
ID: types.RawMessage(`"list-req-1"`),
Method: "tools/list",
}
resp := mcp.handleToolsList(req)
assert.NotNil(t, resp)
assert.Equal(t, req.ID, resp.ID)
assert.Nil(t, resp.Error)
assert.NotNil(t, resp.Result)
resultMap, ok := resp.Result.(map[string]interface{})
assert.True(t, ok)
assert.Contains(t, resultMap, "tools")
toolsList, ok := resultMap["tools"].([]types.Tool)
assert.True(t, ok)
assert.Equal(t, len(mcp.tools), len(toolsList))
assert.Equal(t, mcp.tools[0].Name, toolsList[0].Name) // Basic check
}
func TestHandleToolsList_SetupError(t *testing.T) {
mcp := New(gin.New(), nil)
// Force SetupServer to fail by making route conversion fail (e.g., invalid registered schema)
// This is tricky to force directly without more refactoring.
// Alternative: Temporarily override SetupServer with a mock for this test.
// For now, let's assume SetupServer could fail and check the response.
// We know this path isn't hit currently because SetupServer is simple.
// --- Simplified Check (doesn't guarantee SetupServer failed) ---
// Since forcing SetupServer failure is hard, we'll skip actively causing
// the error for now and focus on other uncovered areas.
// A more robust test would involve dependency injection for route discovery.
// log.Println("Skipping TestHandleToolsList_SetupError as forcing SetupServer failure is complex without refactoring.")
// --- Test Setup (if we *could* force an error) ---
// mcp.forceSetupError = true // Hypothetical flag
mcp.tools = []types.Tool{} // Ensure SetupServer is called
// Note: The current SetupServer implementation doesn't actually return errors.
// resp := mcp.handleToolsList(req)
// assert.NotNil(t, resp)
// assert.Equal(t, req.ID, resp.ID)
// assert.Nil(t, resp.Result)
// assert.NotNil(t, resp.Error)
// errMap, ok := resp.Error.(map[string]interface{})
// assert.True(t, ok)
// assert.Equal(t, -32603, errMap["code"].(int)) // Internal error
// assert.Contains(t, errMap["message"].(string), "Failed to setup server")
}
func TestRegisterSchema(t *testing.T) {
mcp := New(gin.New(), nil)
type QueryParams struct {
Page int `form:"page" description:"Page number for pagination"`
}
type Body struct {
Name string `json:"name" description:"Product name"`
}
// Test valid registration
mcp.RegisterSchema("GET", "/items", QueryParams{}, nil)
mcp.RegisterSchema("POST", "/items", nil, Body{})
keyGet := "GET /items"
keyPost := "POST /items"
assert.Contains(t, mcp.registeredSchemas, keyGet)
assert.NotNil(t, mcp.registeredSchemas[keyGet].QueryType)
assert.Equal(t, reflect.TypeOf(QueryParams{}), reflect.TypeOf(mcp.registeredSchemas[keyGet].QueryType))
assert.Nil(t, mcp.registeredSchemas[keyGet].BodyType)
assert.Contains(t, mcp.registeredSchemas, keyPost)
assert.Nil(t, mcp.registeredSchemas[keyPost].QueryType)
assert.NotNil(t, mcp.registeredSchemas[keyPost].BodyType)
assert.Equal(t, reflect.TypeOf(Body{}), reflect.TypeOf(mcp.registeredSchemas[keyPost].BodyType))
// Test registration with pointer types
mcp.RegisterSchema("PUT", "/items/:id", &QueryParams{}, &Body{})
keyPut := "PUT /items/:id"
assert.Contains(t, mcp.registeredSchemas, keyPut)
assert.NotNil(t, mcp.registeredSchemas[keyPut].QueryType)
assert.Equal(t, reflect.TypeOf(&QueryParams{}), reflect.TypeOf(mcp.registeredSchemas[keyPut].QueryType))
assert.NotNil(t, mcp.registeredSchemas[keyPut].BodyType)
assert.Equal(t, reflect.TypeOf(&Body{}), reflect.TypeOf(mcp.registeredSchemas[keyPut].BodyType))
// Test overriding registration (should just update)
mcp.RegisterSchema("GET", "/items", nil, Body{}) // Override GET /items
assert.Contains(t, mcp.registeredSchemas, keyGet)
assert.Nil(t, mcp.registeredSchemas[keyGet].QueryType) // Should be nil now
assert.NotNil(t, mcp.registeredSchemas[keyGet].BodyType) // Should have body now
assert.Equal(t, reflect.TypeOf(Body{}), reflect.TypeOf(mcp.registeredSchemas[keyGet].BodyType))
}
func TestHaveToolsChanged(t *testing.T) {
mcp := New(gin.New(), nil)
tool1 := types.Tool{Name: "tool1", Description: "Desc1"}
tool2 := types.Tool{Name: "tool2", Description: "Desc2"}
tool1_updated := types.Tool{Name: "tool1", Description: "Desc1 Updated"}
// Initial state (no tools)
assert.False(t, mcp.haveToolsChanged([]types.Tool{}), "No tools -> No tools should be false")
assert.True(t, mcp.haveToolsChanged([]types.Tool{tool1}), "No tools -> Tools should be true")
// Set initial tools
mcp.tools = []types.Tool{tool1, tool2}
// Compare same tools
assert.False(t, mcp.haveToolsChanged([]types.Tool{tool1, tool2}), "Same tools should be false")
assert.False(t, mcp.haveToolsChanged([]types.Tool{tool2, tool1}), "Same tools (different order) should be false")
// Compare different number of tools
assert.True(t, mcp.haveToolsChanged([]types.Tool{tool1}), "Different number of tools should be true")
// Compare different tool name
assert.True(t, mcp.haveToolsChanged([]types.Tool{tool1, types.Tool{Name: "tool3"}}), "Different tool name should be true")
// Compare different description
assert.True(t, mcp.haveToolsChanged([]types.Tool{tool1_updated, tool2}), "Different description should be true")
}
func TestHandleToolCall(t *testing.T) {
mcp := New(gin.New(), nil)
// Add a dummy tool for the test
dummyTool := types.Tool{
Name: "do_something",
Description: "Does something",
InputSchema: &types.JSONSchema{
Type: "object",
Properties: map[string]*types.JSONSchema{
"param1": {Type: "string"},
},
Required: []string{"param1"},
},
}
mcp.tools = []types.Tool{dummyTool}
mcp.operations[dummyTool.Name] = types.Operation{Method: "POST", Path: "/do"} // Need corresponding operation
// ** Test valid tool call **
// Assign mock ONLY for this case
mcp.executeToolFunc = func(operationID string, parameters map[string]interface{}) (interface{}, error) {
assert.Equal(t, dummyTool.Name, operationID) // operationID is the tool name here
assert.Equal(t, "value1", parameters["param1"])
return map[string]interface{}{"result": "success"}, nil // Return nil error for success
}
callReq := &types.MCPMessage{
Jsonrpc: "2.0",
ID: types.RawMessage(`"call-1"`),
Method: "tools/call",
Params: map[string]interface{}{ // Structure based on server.go logic
"name": dummyTool.Name,
"arguments": map[string]interface{}{ // Arguments are nested
"param1": "value1",
},
},
}
resp := mcp.handleToolCall(callReq)
assert.NotNil(t, resp)
assert.Nil(t, resp.Error, "Expected no error for valid call")
assert.Equal(t, callReq.ID, resp.ID)
assert.NotNil(t, resp.Result)
// Check the actual result structure returned by handleToolCall
resultMap, ok := resp.Result.(map[string]interface{}) // Top level is map[string]interface{}
assert.True(t, ok, "Result should be a map")
// Check for the 'content' array
contentList, contentOk := resultMap["content"].([]map[string]interface{})
assert.True(t, contentOk, "Result map should contain 'content' array")
assert.Len(t, contentList, 1, "Content array should have one item")
// Check the content item structure
contentItem := contentList[0]
assert.Equal(t, string(types.ContentTypeText), contentItem["type"], "Content type mismatch")
assert.Contains(t, contentItem, "text", "Content item should contain 'text' field")
// Check the JSON content within the 'text' field
expectedResultJSON := `{"result":"success"}` // This matches the mock's return, marshalled
actualText, textOk := contentItem["text"].(string)
assert.True(t, textOk, "Content text field should be a string")
assert.JSONEq(t, expectedResultJSON, actualText, "Result content JSON mismatch")
// ** Test tool not found **
// Reset mock or ensure it's not called (handleToolCall should error out before calling it)
mcp.executeToolFunc = mcp.defaultExecuteTool // Reset to default or nil if appropriate
callNotFound := &types.MCPMessage{
Jsonrpc: "2.0",
ID: types.RawMessage(`"call-2"`),
Method: "tools/call",
Params: map[string]interface{}{"name": "nonexistent", "arguments": map[string]interface{}{}},
}
respNotFound := mcp.handleToolCall(callNotFound)
assert.NotNil(t, respNotFound)
assert.NotNil(t, respNotFound.Error)
assert.Nil(t, respNotFound.Result)
errMap, ok := respNotFound.Error.(map[string]interface{})
assert.True(t, ok)
assert.EqualValues(t, -32601, errMap["code"]) // Use EqualValues for numeric flexibility
assert.Contains(t, errMap["message"].(string), "not found")
// ** Test invalid params format **
// No mock needed here
callInvalidParams := &types.MCPMessage{
Jsonrpc: "2.0",
ID: types.RawMessage(`"call-3"`),
Method: "tools/call",
Params: "not a map",
}
respInvalidParams := mcp.handleToolCall(callInvalidParams)
assert.NotNil(t, respInvalidParams)
assert.NotNil(t, respInvalidParams.Error)
assert.Nil(t, respInvalidParams.Result)
errMapIP, ok := respInvalidParams.Error.(map[string]interface{})
assert.True(t, ok)
assert.EqualValues(t, -32602, errMapIP["code"]) // Use EqualValues
assert.Contains(t, errMapIP["message"].(string), "Invalid parameters format")
// ** Test missing arguments **
// No mock needed here
callMissingArgs := &types.MCPMessage{
Jsonrpc: "2.0",
ID: types.RawMessage(`"call-4"`),
Method: "tools/call",
Params: map[string]interface{}{"name": dummyTool.Name}, // Missing 'arguments'
}
respMissingArgs := mcp.handleToolCall(callMissingArgs)
assert.NotNil(t, respMissingArgs)
assert.NotNil(t, respMissingArgs.Error)
assert.Nil(t, respMissingArgs.Result)
errMapMA, ok := respMissingArgs.Error.(map[string]interface{})
assert.True(t, ok)
assert.EqualValues(t, -32602, errMapMA["code"]) // Use EqualValues
assert.Contains(t, errMapMA["message"].(string), "Missing tool name or arguments")
// ** Test executeTool error **
// Assign specific error mock ONLY for this case
mcp.executeToolFunc = func(operationID string, parameters map[string]interface{}) (interface{}, error) {
assert.Equal(t, dummyTool.Name, operationID) // Still check the name if desired
return nil, fmt.Errorf("mock execution error")
}
callExecError := &types.MCPMessage{
Jsonrpc: "2.0",
ID: types.RawMessage(`"call-5"`),
Method: "tools/call",
Params: map[string]interface{}{"name": dummyTool.Name, "arguments": map[string]interface{}{"param1": "value1"}},
}
respExecError := mcp.handleToolCall(callExecError)
assert.NotNil(t, respExecError)
assert.NotNil(t, respExecError.Error)
assert.Nil(t, respExecError.Result)
errMapEE, ok := respExecError.Error.(map[string]interface{})
assert.True(t, ok)
assert.EqualValues(t, -32603, errMapEE["code"]) // Use EqualValues
assert.Contains(t, errMapEE["message"].(string), "mock execution error")
}
func TestSetupServer_NotifyToolsChanged(t *testing.T) {
engine := gin.New()
mockT := newMockTransport() // Use the existing mock transport
mcp := New(engine, nil)
mcp.transport = mockT // Inject mock transport
// Initial setup (no tools initially)
err := mcp.SetupServer()
assert.NoError(t, err)
initialTools := mcp.tools
assert.Empty(t, initialTools, "Should have no tools initially")
assert.False(t, mockT.NotifiedToolsChanged, "Notify should not be called on first setup")
// Add a route and setup again
engine.GET("/new_route", func(c *gin.Context) {})
err = mcp.SetupServer()
assert.NoError(t, err)
assert.NotEmpty(t, mcp.tools, "Should have tools after adding a route")
// Check if NotifyToolsChanged was called (since tools changed)
// Note: SetupServer only calls Notify if m.transport is not nil AND tools changed.
// The current SetupServer logic calls haveToolsChanged *before* updating m.tools,
// so the check might be against the old list. Let's refine SetupServer or the test.
// --- Refined approach: Call SetupServer twice with route changes ---
engine = gin.New() // Reset engine
mockT = newMockTransport()
mcp = New(engine, nil)
mcp.transport = mockT
// 1. First Setup (no routes)
err = mcp.SetupServer()
assert.NoError(t, err)
assert.Empty(t, mcp.tools)
assert.False(t, mockT.NotifiedToolsChanged, "Notify should not be called on first setup (no routes)")
// 2. Add route, Setup again
engine.GET("/route1", func(c *gin.Context) {})
mcp.tools = []types.Tool{} // Force re-discovery by clearing tools
err = mcp.SetupServer()
assert.NoError(t, err)
assert.NotEmpty(t, mcp.tools)
// haveToolsChanged compares the new tools (discovered from /route1) against the *previous* m.tools (which was empty).
// Since they are different, NotifyToolsChanged should be called.
assert.True(t, mockT.NotifiedToolsChanged, "Notify should be called when tools change (empty -> route1)")
// 3. Reset notification flag, Setup again (no change)
mockT.NotifiedToolsChanged = false
// m.tools now contains the tool for /route1
err = mcp.SetupServer()
assert.NoError(t, err)
// haveToolsChanged compares the new tools (still just /route1) against the *previous* m.tools (also /route1).
// Since they are the same, NotifyToolsChanged should NOT be called.
assert.False(t, mockT.NotifiedToolsChanged, "Notify should NOT be called when tools list is unchanged")
// 4. Add another route, Setup again
mockT.NotifiedToolsChanged = false // Reset flag
engine.GET("/route2", func(c *gin.Context) {})
mcp.tools = []types.Tool{} // Force re-discovery
err = mcp.SetupServer()
assert.NoError(t, err)
// haveToolsChanged compares the new tools (/route1, /route2) against the *previous* m.tools (/route1).
// Since they are different, NotifyToolsChanged should be called.
assert.True(t, mockT.NotifiedToolsChanged, "Notify should be called when tools change (route1 -> route1, route2)")
}
// TODO: Add tests for executeTool using mocks
func TestGinMCPWithDocs(t *testing.T) {
// Set Gin to test mode
gin.SetMode(gin.TestMode)
// Create router
r := gin.New()
// Register routes
r.GET("/products", ListProducts)
r.GET("/products/:id", GetProduct)
r.POST("/products", CreateProduct)
// Create MCP server
mcp := New(r, &Config{
Name: "Test API",
Description: "Test API with docs",
BaseURL: "http://localhost:8080",
})
// Define request and response structures
type ListProductsParams struct {
Page int `form:"page" description:"Page number for pagination"`
}
type Product struct {
Name string `json:"name" description:"Product name"`
Description string `json:"description" description:"Product description"`
Price float64 `json:"price" description:"Product price"`
}
// Register schemas
mcp.RegisterSchema("GET", "/products", ListProductsParams{}, nil)
mcp.RegisterSchema("GET", "/products/:id", nil, nil)
mcp.RegisterSchema("POST", "/products", nil, Product{})
// Mount MCP and setup server
mcp.Mount("/mcp")
err := mcp.SetupServer()
assert.NoError(t, err)
// Get tools list using handleToolsList directly
req := &types.MCPMessage{
Jsonrpc: "2.0",
ID: types.RawMessage(`"list-req-1"`),
Method: "tools/list",
}
resp := mcp.handleToolsList(req)
assert.NotNil(t, resp)
assert.Nil(t, resp.Error)
// Get tools list from response
resultMap, ok := resp.Result.(map[string]interface{})
assert.True(t, ok)
assert.Contains(t, resultMap, "tools")
tools, ok := resultMap["tools"].([]types.Tool)
assert.True(t, ok)
assert.NotEmpty(t, tools)
// Verify tools in the list
for _, tool := range tools {
switch tool.Name {
case "GET_products":
// Verify comment conversion to description
assert.Contains(t, tool.Description, "Get product list")
assert.Contains(t, tool.Description, "Returns a paginated list of products")
// Verify parameter description
assert.NotNil(t, tool.InputSchema)
assert.Contains(t, tool.InputSchema.Properties["page"].Description, "Page number for pagination")
case "GET_products_id":
assert.Contains(t, tool.Description, "Get product details")
assert.Contains(t, tool.Description, "Returns detailed information for a specific product")
assert.NotNil(t, tool.InputSchema)
assert.Contains(t, tool.InputSchema.Properties["id"].Description, "Product ID")
case "POST_products":
assert.Contains(t, tool.Description, "Create new product")
assert.Contains(t, tool.Description, "Creates a new product and returns the creation result")
// Verify request body schema
assert.NotNil(t, tool.InputSchema)
assert.Contains(t, tool.InputSchema.Properties["name"].Description, "Product name")
assert.Contains(t, tool.InputSchema.Properties["description"].Description, "Product description")
assert.Contains(t, tool.InputSchema.Properties["price"].Description, "Product price")
}
}
}
// ListProducts handles product list retrieval
// @summary Get product list
// @description Returns a paginated list of products
// @param page Page number for pagination, starting from 1
// @return List of products
func ListProducts(c *gin.Context) {
c.JSON(200, gin.H{"message": "list products"})
}
// GetProduct handles single product retrieval
// @summary Get product details
// @description Returns detailed information for a specific product
// @param id Product ID
// @return Product details
func GetProduct(c *gin.Context) {
c.JSON(200, gin.H{"message": "get product"})
}
// CreateProduct handles new product creation
// @summary Create new product
// @description Creates a new product and returns the creation result
// @param name Product name
// @param description Product description
// @param price Product price
// @return Created product information
func CreateProduct(c *gin.Context) {
c.JSON(200, gin.H{"message": "create product"})
}
// --- Handlers for tag filtering tests ---
// listItems handles item listing
// @summary List items
// @tags public internal
func listItems(c *gin.Context) {
c.JSON(200, gin.H{"items": []string{}})
}
// getItem handles item retrieval
// @summary Get item
// @tags public
func getItem(c *gin.Context) {
c.JSON(200, gin.H{"item": "test"})
}
// createItem handles item creation
// @summary Create item
// @tags admin
func createItem(c *gin.Context) {
c.JSON(200, gin.H{"created": true})
}
// deleteItem handles item deletion
// @summary Delete item
// @tags admin internal
func deleteItem(c *gin.Context) {
c.JSON(200, gin.H{"deleted": true})
}
// healthCheck handles health checks
// @summary Health check
func healthCheck(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
}
// TestHandleToolCall_CustomOperationId tests tool execution with custom operation IDs
func TestHandleToolCall_CustomOperationId(t *testing.T) {
mcp := New(gin.New(), nil)
// Add a tool with custom operation ID
customTool := types.Tool{
Name: "myCustomToolId",
Description: "Custom tool with operation ID",
InputSchema: &types.JSONSchema{
Type: "object",
Properties: map[string]*types.JSONSchema{
"input": {Type: "string"},
},
Required: []string{"input"},
},
}
mcp.tools = []types.Tool{customTool}
mcp.operations[customTool.Name] = types.Operation{Method: "GET", Path: "/custom"}
// Set up mock execution function
mcp.executeToolFunc = func(operationID string, parameters map[string]interface{}) (interface{}, error) {
assert.Equal(t, "myCustomToolId", operationID, "Should call with custom operation ID")
assert.Equal(t, "test-value", parameters["input"])
return map[string]interface{}{"status": "executed"}, nil
}
// Create tool call request with custom operation ID
callReq := &types.MCPMessage{
Jsonrpc: "2.0",
ID: types.RawMessage(`"custom-call-1"`),
Method: "tools/call",
Params: map[string]interface{}{