main
1package integration
2
3import (
4 "bufio"
5 "encoding/json"
6 "fmt"
7 "io"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "strings"
12 "testing"
13 "time"
14)
15
16// JSONRPCRequest represents a JSON-RPC 2.0 request
17type JSONRPCRequest struct {
18 JSONRPC string `json:"jsonrpc"`
19 ID int `json:"id"`
20 Method string `json:"method"`
21 Params interface{} `json:"params,omitempty"`
22}
23
24// JSONRPCResponse represents a JSON-RPC 2.0 response
25type JSONRPCResponse struct {
26 JSONRPC string `json:"jsonrpc"`
27 ID int `json:"id"`
28 Result json.RawMessage `json:"result,omitempty"`
29 Error *JSONRPCError `json:"error,omitempty"`
30}
31
32// JSONRPCError represents a JSON-RPC 2.0 error
33type JSONRPCError struct {
34 Code int `json:"code"`
35 Message string `json:"message"`
36}
37
38// InitializeParams represents initialization parameters
39type InitializeParams struct {
40 ProtocolVersion string `json:"protocolVersion"`
41 Capabilities interface{} `json:"capabilities"`
42 ClientInfo ClientInfo `json:"clientInfo"`
43}
44
45// ClientInfo represents client information
46type ClientInfo struct {
47 Name string `json:"name"`
48 Version string `json:"version"`
49}
50
51// InitializeResult represents initialization result
52type InitializeResult struct {
53 ProtocolVersion string `json:"protocolVersion"`
54 Capabilities interface{} `json:"capabilities"`
55 ServerInfo ServerInfo `json:"serverInfo"`
56}
57
58// ServerInfo represents server information
59type ServerInfo struct {
60 Name string `json:"name"`
61 Version string `json:"version"`
62}
63
64// ListResourcesResult represents resources/list result
65type ListResourcesResult struct {
66 Resources []Resource `json:"resources"`
67}
68
69// Resource represents an MCP resource
70type Resource struct {
71 URI string `json:"uri"`
72 Name string `json:"name"`
73 Description string `json:"description,omitempty"`
74 MimeType string `json:"mimeType,omitempty"`
75}
76
77// ListToolsResult represents tools/list result
78type ListToolsResult struct {
79 Tools []Tool `json:"tools"`
80}
81
82// Tool represents an MCP tool
83type Tool struct {
84 Name string `json:"name"`
85 Description string `json:"description,omitempty"`
86 InputSchema interface{} `json:"inputSchema,omitempty"`
87}
88
89// MCPServer represents a spawned MCP server for testing
90type MCPServer struct {
91 cmd *exec.Cmd
92 stdin io.WriteCloser
93 stdout io.ReadCloser
94 name string
95}
96
97// NewMCPServer creates and starts a new MCP server for testing
98func NewMCPServer(binaryPath string, args ...string) (*MCPServer, error) {
99 cmd := exec.Command(binaryPath, args...)
100
101 stdin, err := cmd.StdinPipe()
102 if err != nil {
103 return nil, fmt.Errorf("failed to create stdin pipe: %w", err)
104 }
105
106 stdout, err := cmd.StdoutPipe()
107 if err != nil {
108 stdin.Close()
109 return nil, fmt.Errorf("failed to create stdout pipe: %w", err)
110 }
111
112 if err := cmd.Start(); err != nil {
113 stdin.Close()
114 stdout.Close()
115 return nil, fmt.Errorf("failed to start server: %w", err)
116 }
117
118 return &MCPServer{
119 cmd: cmd,
120 stdin: stdin,
121 stdout: stdout,
122 name: filepath.Base(binaryPath),
123 }, nil
124}
125
126// SendRequest sends a JSON-RPC request and returns the response
127func (s *MCPServer) SendRequest(req JSONRPCRequest) (*JSONRPCResponse, error) {
128 reqBytes, err := json.Marshal(req)
129 if err != nil {
130 return nil, fmt.Errorf("failed to marshal request: %w", err)
131 }
132
133 // Send request
134 _, err = s.stdin.Write(append(reqBytes, '\n'))
135 if err != nil {
136 return nil, fmt.Errorf("failed to write request: %w", err)
137 }
138
139 // Read response with timeout
140 scanner := bufio.NewScanner(s.stdout)
141 responseChan := make(chan string, 1)
142 errorChan := make(chan error, 1)
143
144 go func() {
145 if scanner.Scan() {
146 responseChan <- scanner.Text()
147 } else if err := scanner.Err(); err != nil {
148 errorChan <- err
149 } else {
150 errorChan <- fmt.Errorf("EOF")
151 }
152 }()
153
154 select {
155 case responseText := <-responseChan:
156 var resp JSONRPCResponse
157 if err := json.Unmarshal([]byte(responseText), &resp); err != nil {
158 return nil, fmt.Errorf("failed to unmarshal response: %w", err)
159 }
160 return &resp, nil
161 case err := <-errorChan:
162 return nil, fmt.Errorf("failed to read response: %w", err)
163 case <-time.After(5 * time.Second):
164 return nil, fmt.Errorf("timeout waiting for response")
165 }
166}
167
168// Close shuts down the MCP server
169func (s *MCPServer) Close() error {
170 // Send shutdown request
171 shutdownReq := JSONRPCRequest{
172 JSONRPC: "2.0",
173 ID: 999,
174 Method: "shutdown",
175 }
176 s.SendRequest(shutdownReq)
177
178 s.stdin.Close()
179 s.stdout.Close()
180
181 // Wait for process to exit or kill it
182 done := make(chan error, 1)
183 go func() {
184 done <- s.cmd.Wait()
185 }()
186
187 select {
188 case <-done:
189 return nil
190 case <-time.After(2 * time.Second):
191 return s.cmd.Process.Kill()
192 }
193}
194
195// ServerTestConfig represents configuration for testing a server
196type ServerTestConfig struct {
197 BinaryName string
198 Args []string
199 ExpectedTools []string
200 ExpectedServers string
201 MinResources int
202}
203
204// getProjectRoot returns the project root directory
205func getProjectRoot() string {
206 wd, _ := os.Getwd()
207 // Go up from test/integration to project root
208 return filepath.Join(wd, "../..")
209}
210
211// getBinaryPath returns the path to a binary in the bin directory
212func getBinaryPath(binaryName string) string {
213 return filepath.Join(getProjectRoot(), "bin", binaryName)
214}
215
216// TestAllServers tests all MCP servers
217func TestAllServers(t *testing.T) {
218 // Create temp directories for testing
219 tempDir := t.TempDir()
220
221 servers := []ServerTestConfig{
222 {
223 BinaryName: "mcp-filesystem",
224 Args: []string{tempDir},
225 ExpectedTools: []string{"read_file", "write_file"},
226 ExpectedServers: "filesystem",
227 MinResources: 1, // Should have at least the temp directory
228 },
229 {
230 BinaryName: "mcp-git",
231 Args: []string{getProjectRoot()},
232 ExpectedTools: []string{"git_status", "git_diff", "git_commit"},
233 ExpectedServers: "mcp-git",
234 MinResources: 1, // Should have git repository resources
235 },
236 {
237 BinaryName: "mcp-memory",
238 Args: []string{},
239 ExpectedTools: []string{"create_entities", "create_relations", "read_graph"},
240 ExpectedServers: "mcp-memory",
241 MinResources: 1, // Should have knowledge graph resource
242 },
243 {
244 BinaryName: "mcp-fetch",
245 Args: []string{},
246 ExpectedTools: []string{"fetch"},
247 ExpectedServers: "mcp-fetch",
248 MinResources: 0, // No static resources
249 },
250 {
251 BinaryName: "mcp-time",
252 Args: []string{},
253 ExpectedTools: []string{"get_current_time", "convert_time"},
254 ExpectedServers: "mcp-time",
255 MinResources: 0, // No static resources
256 },
257 {
258 BinaryName: "mcp-sequential-thinking",
259 Args: []string{},
260 ExpectedTools: []string{"sequentialthinking"},
261 ExpectedServers: "mcp-sequential-thinking",
262 MinResources: 0, // No static resources
263 },
264 {
265 BinaryName: "mcp-maildir",
266 Args: []string{tempDir},
267 ExpectedTools: []string{"maildir_scan_folders", "maildir_list_messages"},
268 ExpectedServers: "maildir-server",
269 MinResources: 1, // Should have maildir resources
270 },
271 {
272 BinaryName: "mcp-imap",
273 Args: []string{"--server", "example.com", "--username", "test", "--password", "test"},
274 ExpectedTools: []string{"imap_list_folders", "imap_list_messages", "imap_get_connection_info", "imap_delete_message", "imap_move_to_trash"},
275 ExpectedServers: "imap",
276 MinResources: 0, // No static resources (connection fails gracefully)
277 },
278 {
279 BinaryName: "mcp-bash",
280 Args: []string{},
281 ExpectedTools: []string{"exec"},
282 ExpectedServers: "bash",
283 MinResources: 90, // Bash server has bash builtins and coreutils resources
284 },
285 }
286
287 for _, config := range servers {
288 t.Run(config.BinaryName, func(t *testing.T) {
289 testServer(t, config)
290 })
291 }
292}
293
294// testServer tests a single MCP server
295func testServer(t *testing.T, config ServerTestConfig) {
296 binaryPath := getBinaryPath(config.BinaryName)
297
298 // Check if binary exists
299 if _, err := os.Stat(binaryPath); os.IsNotExist(err) {
300 t.Fatalf("Binary not found: %s (run 'make build' first)", binaryPath)
301 }
302
303 // Start server
304 server, err := NewMCPServer(binaryPath, config.Args...)
305 if err != nil {
306 t.Fatalf("Failed to start server: %v", err)
307 }
308 defer server.Close()
309
310 // Test initialization
311 initReq := JSONRPCRequest{
312 JSONRPC: "2.0",
313 ID: 1,
314 Method: "initialize",
315 Params: InitializeParams{
316 ProtocolVersion: "2025-06-18",
317 Capabilities: map[string]interface{}{},
318 ClientInfo: ClientInfo{
319 Name: "test-client",
320 Version: "1.0.0",
321 },
322 },
323 }
324
325 resp, err := server.SendRequest(initReq)
326 if err != nil {
327 t.Fatalf("Failed to send initialize request: %v", err)
328 }
329
330 if resp.Error != nil {
331 t.Fatalf("Initialize request failed: %v", resp.Error)
332 }
333
334 var initResult InitializeResult
335 if err := json.Unmarshal(resp.Result, &initResult); err != nil {
336 t.Fatalf("Failed to parse initialize response: %v", err)
337 }
338
339 if initResult.ProtocolVersion != "2025-06-18" {
340 t.Errorf("Expected protocol version 2025-06-18, got %s", initResult.ProtocolVersion)
341 }
342
343 if initResult.ServerInfo.Name != config.ExpectedServers {
344 t.Errorf("Expected server name %s, got %s", config.ExpectedServers, initResult.ServerInfo.Name)
345 }
346
347 // Test tools/list
348 toolsReq := JSONRPCRequest{
349 JSONRPC: "2.0",
350 ID: 2,
351 Method: "tools/list",
352 }
353
354 resp, err = server.SendRequest(toolsReq)
355 if err != nil {
356 t.Fatalf("Failed to send tools/list request: %v", err)
357 }
358
359 if resp.Error != nil {
360 t.Fatalf("Tools/list request failed: %v", resp.Error)
361 }
362
363 var toolsResult ListToolsResult
364 if err := json.Unmarshal(resp.Result, &toolsResult); err != nil {
365 t.Fatalf("Failed to parse tools/list response: %v", err)
366 }
367
368 // Check that expected tools are present
369 toolNames := make(map[string]bool)
370 for _, tool := range toolsResult.Tools {
371 toolNames[tool.Name] = true
372 }
373
374 for _, expectedTool := range config.ExpectedTools {
375 if !toolNames[expectedTool] {
376 t.Errorf("Expected tool %s not found in tools list", expectedTool)
377 }
378 }
379
380 // Test resources/list
381 resourcesReq := JSONRPCRequest{
382 JSONRPC: "2.0",
383 ID: 3,
384 Method: "resources/list",
385 }
386
387 resp, err = server.SendRequest(resourcesReq)
388 if err != nil {
389 t.Fatalf("Failed to send resources/list request: %v", err)
390 }
391
392 if resp.Error != nil {
393 t.Fatalf("Resources/list request failed: %v", resp.Error)
394 }
395
396 var resourcesResult ListResourcesResult
397 if err := json.Unmarshal(resp.Result, &resourcesResult); err != nil {
398 t.Fatalf("Failed to parse resources/list response: %v", err)
399 }
400
401 if len(resourcesResult.Resources) < config.MinResources {
402 t.Errorf("Expected at least %d resources, got %d", config.MinResources, len(resourcesResult.Resources))
403 }
404
405 // Validate that resources have required fields
406 for _, resource := range resourcesResult.Resources {
407 if resource.URI == "" {
408 t.Error("Resource missing URI")
409 }
410 if resource.Name == "" {
411 t.Error("Resource missing Name")
412 }
413 if !strings.Contains(resource.URI, "://") {
414 t.Errorf("Resource URI should contain scheme: %s", resource.URI)
415 }
416 }
417
418 t.Logf("✅ %s: %d tools, %d resources", config.BinaryName, len(toolsResult.Tools), len(resourcesResult.Resources))
419}
420
421// TestServerStartupPerformance tests that servers start quickly
422func TestServerStartupPerformance(t *testing.T) {
423 tempDir := t.TempDir()
424
425 servers := []string{
426 "mcp-filesystem",
427 "mcp-git",
428 "mcp-memory",
429 "mcp-fetch",
430 "mcp-time",
431 "mcp-sequential-thinking",
432 "mcp-maildir",
433 "mcp-imap",
434 "mcp-bash",
435 }
436
437 for _, serverName := range servers {
438 t.Run(serverName, func(t *testing.T) {
439 binaryPath := getBinaryPath(serverName)
440
441 if _, err := os.Stat(binaryPath); os.IsNotExist(err) {
442 t.Skip("Binary not found")
443 }
444
445 start := time.Now()
446
447 var args []string
448 switch serverName {
449 case "mcp-filesystem":
450 args = []string{tempDir}
451 case "mcp-git":
452 args = []string{getProjectRoot()}
453 case "mcp-maildir":
454 args = []string{tempDir}
455 case "mcp-imap":
456 args = []string{"--server", "example.com", "--username", "test", "--password", "test"}
457 }
458
459 server, err := NewMCPServer(binaryPath, args...)
460 if err != nil {
461 t.Fatalf("Failed to start server: %v", err)
462 }
463 defer server.Close()
464
465 // Send initialize request to confirm server is ready
466 initReq := JSONRPCRequest{
467 JSONRPC: "2.0",
468 ID: 1,
469 Method: "initialize",
470 Params: InitializeParams{
471 ProtocolVersion: "2025-06-18",
472 Capabilities: map[string]interface{}{},
473 ClientInfo: ClientInfo{Name: "test", Version: "1.0.0"},
474 },
475 }
476
477 _, err = server.SendRequest(initReq)
478 if err != nil {
479 t.Fatalf("Server not responding: %v", err)
480 }
481
482 duration := time.Since(start)
483
484 // Servers should start and respond within 500ms for good performance
485 if duration > 500*time.Millisecond {
486 t.Errorf("Server %s took %v to start, expected < 500ms", serverName, duration)
487 } else {
488 t.Logf("✅ %s started in %v", serverName, duration)
489 }
490 })
491 }
492}