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}