diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-10 19:28:27 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-10 19:28:27 +0200 |
| commit | 5551695f3b0d10c9a22cfacdb10c2cf7bd572421 (patch) | |
| tree | 282611eacf1fd4c38d54d5cea87decdf2b1cbdb7 /internal/mcp/handlers_test.go | |
| parent | ec745129258ae800065e302a2a40b54488cbca08 (diff) | |
Add MCP server implementation with comprehensive test coverage
Implements a full Model Context Protocol (MCP) server for managing and serving prompts
to LLM applications. The server provides CRUD operations for prompts with automatic
backups and template rendering support.
Key additions:
- cmd/hexai-mcp-server: Main MCP server binary entrypoint
- internal/hexaimcp: Server orchestrator with configuration and setup
- internal/mcp: Core MCP protocol implementation (JSON-RPC 2.0)
- internal/promptstore: Prompt storage with JSONL backend and automatic backups
- Comprehensive test suites achieving 80%+ coverage for all MCP packages
- Magefile targets for building and installing the MCP server
- Complete documentation for setup, API, prompts, and backups
Test coverage:
- internal/hexaimcp: 84.3%
- internal/mcp: 80.3%
- internal/promptstore: 81.2%
- Overall project: 81.5%
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Diffstat (limited to 'internal/mcp/handlers_test.go')
| -rw-r--r-- | internal/mcp/handlers_test.go | 955 |
1 files changed, 955 insertions, 0 deletions
diff --git a/internal/mcp/handlers_test.go b/internal/mcp/handlers_test.go new file mode 100644 index 0000000..79c567e --- /dev/null +++ b/internal/mcp/handlers_test.go @@ -0,0 +1,955 @@ +// Summary: Tests for MCP prompt management handlers +package mcp + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "testing" + "time" + + "codeberg.org/snonux/hexai/internal/promptstore" +) + +func TestServer_PromptsCreate(t *testing.T) { + store := &mockPromptStore{prompts: make(map[string]*promptstore.Prompt)} + server, _, outBuf := createTestServer(t, store) + + // Initialize server + server.mu.Lock() + server.initialized = true + server.mu.Unlock() + + // Send prompts/create request + params := CreatePromptRequest{ + Name: "test_create", + Title: "Test Create Prompt", + Description: "A test prompt", + Arguments: []PromptArgument{ + {Name: "input", Description: "Test input", Required: true}, + }, + Messages: []PromptMessage{ + { + Role: "user", + Content: MessageContent{ + Type: "text", + Text: "Test: {{input}}", + }, + }, + }, + Tags: []string{"test"}, + } + paramsBytes, _ := json.Marshal(params) + req := Request{ + JSONRPC: "2.0", + ID: 10, + Method: "prompts/create", + Params: paramsBytes, + } + + server.handle(req) + + // Read response + resp, err := readResponse(outBuf) + if err != nil { + t.Fatalf("readResponse() error = %v", err) + } + + if resp.Error != nil { + t.Fatalf("Error = %v, want nil", resp.Error) + } + + // Parse result + resultBytes, _ := json.Marshal(resp.Result) + var result PromptOperationResult + if err := json.Unmarshal(resultBytes, &result); err != nil { + t.Fatalf("Unmarshal result error = %v", err) + } + + if !result.Success { + t.Errorf("Success = false, want true") + } +} + +func TestServer_PromptsUpdate(t *testing.T) { + now := time.Now() + store := &mockPromptStore{ + prompts: map[string]*promptstore.Prompt{ + "test_update": { + Name: "test_update", + Title: "Original Title", + Created: now, + Updated: now, + Messages: []promptstore.PromptMessage{ + { + Role: "user", + Content: promptstore.MessageContent{ + Type: "text", + Text: "Original text", + }, + }, + }, + }, + }, + } + + server, _, outBuf := createTestServer(t, store) + + // Initialize server + server.mu.Lock() + server.initialized = true + server.mu.Unlock() + + // Send prompts/update request + params := UpdatePromptRequest{ + Name: "test_update", + Title: "Updated Title", + } + paramsBytes, _ := json.Marshal(params) + req := Request{ + JSONRPC: "2.0", + ID: 11, + Method: "prompts/update", + Params: paramsBytes, + } + + server.handle(req) + + // Read response + resp, err := readResponse(outBuf) + if err != nil { + t.Fatalf("readResponse() error = %v", err) + } + + if resp.Error != nil { + t.Fatalf("Error = %v, want nil", resp.Error) + } + + // Parse result + resultBytes, _ := json.Marshal(resp.Result) + var result PromptOperationResult + if err := json.Unmarshal(resultBytes, &result); err != nil { + t.Fatalf("Unmarshal result error = %v", err) + } + + if !result.Success { + t.Errorf("Success = false, want true") + } +} + +func TestServer_PromptsDelete(t *testing.T) { + now := time.Now() + store := &mockPromptStore{ + prompts: map[string]*promptstore.Prompt{ + "test_delete": { + Name: "test_delete", + Title: "To Be Deleted", + Created: now, + Updated: now, + Messages: []promptstore.PromptMessage{}, + }, + }, + } + + server, _, outBuf := createTestServer(t, store) + + // Initialize server + server.mu.Lock() + server.initialized = true + server.mu.Unlock() + + // Send prompts/delete request + params := DeletePromptRequest{ + Name: "test_delete", + } + paramsBytes, _ := json.Marshal(params) + req := Request{ + JSONRPC: "2.0", + ID: 12, + Method: "prompts/delete", + Params: paramsBytes, + } + + server.handle(req) + + // Read response + resp, err := readResponse(outBuf) + if err != nil { + t.Fatalf("readResponse() error = %v", err) + } + + if resp.Error != nil { + t.Fatalf("Error = %v, want nil", resp.Error) + } + + // Parse result + resultBytes, _ := json.Marshal(resp.Result) + var result PromptOperationResult + if err := json.Unmarshal(resultBytes, &result); err != nil { + t.Fatalf("Unmarshal result error = %v", err) + } + + if !result.Success { + t.Errorf("Success = false, want true") + } +} + +func TestServer_PromptsCreate_MissingName(t *testing.T) { + store := &mockPromptStore{prompts: make(map[string]*promptstore.Prompt)} + server, _, outBuf := createTestServer(t, store) + + // Initialize server + server.mu.Lock() + server.initialized = true + server.mu.Unlock() + + // Send prompts/create request without name + params := CreatePromptRequest{ + Title: "No Name", + Messages: []PromptMessage{ + {Role: "user", Content: MessageContent{Type: "text", Text: "Test"}}, + }, + } + paramsBytes, _ := json.Marshal(params) + req := Request{ + JSONRPC: "2.0", + ID: 13, + Method: "prompts/create", + Params: paramsBytes, + } + + server.handle(req) + + // Read response + resp, err := readResponse(outBuf) + if err != nil { + t.Fatalf("readResponse() error = %v", err) + } + + if resp.Error == nil { + t.Fatal("Expected error for missing name") + } + + if resp.Error.Code != ErrCodeInvalidParams { + t.Errorf("Error code = %d, want %d", resp.Error.Code, ErrCodeInvalidParams) + } +} + +// Update mockPromptStore to support Create, Update, Delete +func (m *mockPromptStore) Create(prompt *promptstore.Prompt) error { + if _, exists := m.prompts[prompt.Name]; exists { + return fmt.Errorf("prompt already exists: %s", prompt.Name) + } + m.prompts[prompt.Name] = prompt + return nil +} + +func (m *mockPromptStore) Update(prompt *promptstore.Prompt) error { + if _, exists := m.prompts[prompt.Name]; !exists { + return fmt.Errorf("prompt not found: %s", prompt.Name) + } + m.prompts[prompt.Name] = prompt + return nil +} + +func (m *mockPromptStore) Delete(name string) error { + if _, exists := m.prompts[name]; !exists { + return fmt.Errorf("prompt not found: %s", name) + } + delete(m.prompts, name) + return nil +} + +func TestServer_PromptsUpdate_NotFound(t *testing.T) { + store := &mockPromptStore{prompts: make(map[string]*promptstore.Prompt)} + server, _, outBuf := createTestServer(t, store) + + // Initialize server + server.mu.Lock() + server.initialized = true + server.mu.Unlock() + + // Send prompts/update request for non-existent prompt + params := UpdatePromptRequest{ + Name: "nonexistent", + Title: "Updated Title", + } + paramsBytes, _ := json.Marshal(params) + req := Request{ + JSONRPC: "2.0", + ID: 20, + Method: "prompts/update", + Params: paramsBytes, + } + + server.handle(req) + + // Read response + resp, err := readResponse(outBuf) + if err != nil { + t.Fatalf("readResponse() error = %v", err) + } + + if resp.Error == nil { + t.Fatal("Expected error for non-existent prompt") + } +} + +func TestServer_PromptsUpdate_MissingName(t *testing.T) { + store := &mockPromptStore{prompts: make(map[string]*promptstore.Prompt)} + server, _, outBuf := createTestServer(t, store) + + // Initialize server + server.mu.Lock() + server.initialized = true + server.mu.Unlock() + + // Send prompts/update request without name + params := UpdatePromptRequest{ + Title: "Updated Title", + } + paramsBytes, _ := json.Marshal(params) + req := Request{ + JSONRPC: "2.0", + ID: 21, + Method: "prompts/update", + Params: paramsBytes, + } + + server.handle(req) + + // Read response + resp, err := readResponse(outBuf) + if err != nil { + t.Fatalf("readResponse() error = %v", err) + } + + if resp.Error == nil { + t.Fatal("Expected error for missing name") + } +} + +func TestServer_PromptsDelete_NotFound(t *testing.T) { + store := &mockPromptStore{prompts: make(map[string]*promptstore.Prompt)} + server, _, outBuf := createTestServer(t, store) + + // Initialize server + server.mu.Lock() + server.initialized = true + server.mu.Unlock() + + // Send prompts/delete request for non-existent prompt + params := DeletePromptRequest{ + Name: "nonexistent", + } + paramsBytes, _ := json.Marshal(params) + req := Request{ + JSONRPC: "2.0", + ID: 22, + Method: "prompts/delete", + Params: paramsBytes, + } + + server.handle(req) + + // Read response + resp, err := readResponse(outBuf) + if err != nil { + t.Fatalf("readResponse() error = %v", err) + } + + if resp.Error == nil { + t.Fatal("Expected error for non-existent prompt") + } +} + +func TestServer_PromptsDelete_MissingName(t *testing.T) { + store := &mockPromptStore{prompts: make(map[string]*promptstore.Prompt)} + server, _, outBuf := createTestServer(t, store) + + // Initialize server + server.mu.Lock() + server.initialized = true + server.mu.Unlock() + + // Send prompts/delete request without name + params := DeletePromptRequest{} + paramsBytes, _ := json.Marshal(params) + req := Request{ + JSONRPC: "2.0", + ID: 23, + Method: "prompts/delete", + Params: paramsBytes, + } + + server.handle(req) + + // Read response + resp, err := readResponse(outBuf) + if err != nil { + t.Fatalf("readResponse() error = %v", err) + } + + if resp.Error == nil { + t.Fatal("Expected error for missing name") + } +} + +func TestServer_PromptsCreate_AlreadyExists(t *testing.T) { + now := time.Now() + store := &mockPromptStore{ + prompts: map[string]*promptstore.Prompt{ + "existing": { + Name: "existing", + Title: "Existing Prompt", + Created: now, + Updated: now, + Messages: []promptstore.PromptMessage{}, + }, + }, + } + server, _, outBuf := createTestServer(t, store) + + // Initialize server + server.mu.Lock() + server.initialized = true + server.mu.Unlock() + + // Send prompts/create request with existing name + params := CreatePromptRequest{ + Name: "existing", + Title: "Duplicate", + Messages: []PromptMessage{ + {Role: "user", Content: MessageContent{Type: "text", Text: "Test"}}, + }, + } + paramsBytes, _ := json.Marshal(params) + req := Request{ + JSONRPC: "2.0", + ID: 24, + Method: "prompts/create", + Params: paramsBytes, + } + + server.handle(req) + + // Read response + resp, err := readResponse(outBuf) + if err != nil { + t.Fatalf("readResponse() error = %v", err) + } + + if resp.Error == nil { + t.Fatal("Expected error for duplicate prompt") + } +} + +func TestServer_PromptsGet_NotFound(t *testing.T) { + store := &mockPromptStore{prompts: make(map[string]*promptstore.Prompt)} + server, _, outBuf := createTestServer(t, store) + + // Initialize server + server.mu.Lock() + server.initialized = true + server.mu.Unlock() + + // Send prompts/get request for non-existent prompt + params := GetPromptRequest{ + Name: "nonexistent", + Arguments: map[string]string{}, + } + paramsBytes, _ := json.Marshal(params) + req := Request{ + JSONRPC: "2.0", + ID: 25, + Method: "prompts/get", + Params: paramsBytes, + } + + server.handle(req) + + // Read response + resp, err := readResponse(outBuf) + if err != nil { + t.Fatalf("readResponse() error = %v", err) + } + + if resp.Error == nil { + t.Fatal("Expected error for non-existent prompt") + } +} + +func TestServer_PromptsList_WithError(t *testing.T) { + store := &mockPromptStore{ + listFn: func(cursor string, limit int) ([]promptstore.Prompt, string, error) { + return nil, "", fmt.Errorf("store error") + }, + } + server, _, outBuf := createTestServer(t, store) + + // Initialize server + server.mu.Lock() + server.initialized = true + server.mu.Unlock() + + // Send prompts/list request + req := Request{ + JSONRPC: "2.0", + ID: 26, + Method: "prompts/list", + Params: json.RawMessage(`{}`), + } + + server.handle(req) + + // Read response + resp, err := readResponse(outBuf) + if err != nil { + t.Fatalf("readResponse() error = %v", err) + } + + if resp.Error == nil { + t.Fatal("Expected error from store") + } +} + +func TestServer_PromptsUpdate_WithMessages(t *testing.T) { + now := time.Now() + store := &mockPromptStore{ + prompts: map[string]*promptstore.Prompt{ + "test": { + Name: "test", + Title: "Original", + Created: now, + Updated: now, + Messages: []promptstore.PromptMessage{ + { + Role: "user", + Content: promptstore.MessageContent{ + Type: "text", + Text: "Original", + }, + }, + }, + }, + }, + } + + server, _, outBuf := createTestServer(t, store) + + // Initialize server + server.mu.Lock() + server.initialized = true + server.mu.Unlock() + + // Send prompts/update request with new messages + params := UpdatePromptRequest{ + Name: "test", + Title: "Updated", + Messages: []PromptMessage{ + { + Role: "user", + Content: MessageContent{ + Type: "text", + Text: "Updated message", + }, + }, + }, + } + paramsBytes, _ := json.Marshal(params) + req := Request{ + JSONRPC: "2.0", + ID: 27, + Method: "prompts/update", + Params: paramsBytes, + } + + server.handle(req) + + // Read response + resp, err := readResponse(outBuf) + if err != nil { + t.Fatalf("readResponse() error = %v", err) + } + + if resp.Error != nil { + t.Fatalf("Error = %v, want nil", resp.Error) + } +} + +func TestServer_PromptsCreate_WithAllFields(t *testing.T) { + store := &mockPromptStore{prompts: make(map[string]*promptstore.Prompt)} + server, _, outBuf := createTestServer(t, store) + + // Initialize server + server.mu.Lock() + server.initialized = true + server.mu.Unlock() + + // Send prompts/create request with all fields + params := CreatePromptRequest{ + Name: "complete", + Title: "Complete Prompt", + Description: "Full description", + Arguments: []PromptArgument{ + {Name: "arg1", Description: "First arg", Required: true}, + {Name: "arg2", Description: "Second arg", Required: false}, + }, + Messages: []PromptMessage{ + { + Role: "user", + Content: MessageContent{ + Type: "text", + Text: "Test {{arg1}} and {{arg2}}", + }, + }, + { + Role: "assistant", + Content: MessageContent{ + Type: "text", + Text: "Response", + }, + }, + }, + Tags: []string{"tag1", "tag2"}, + } + paramsBytes, _ := json.Marshal(params) + req := Request{ + JSONRPC: "2.0", + ID: 28, + Method: "prompts/create", + Params: paramsBytes, + } + + server.handle(req) + + // Read response + resp, err := readResponse(outBuf) + if err != nil { + t.Fatalf("readResponse() error = %v", err) + } + + if resp.Error != nil { + t.Fatalf("Error = %v, want nil", resp.Error) + } + + // Parse result + resultBytes, _ := json.Marshal(resp.Result) + var result PromptOperationResult + if err := json.Unmarshal(resultBytes, &result); err != nil { + t.Fatalf("Unmarshal result error = %v", err) + } + + if !result.Success { + t.Errorf("Success = false, want true") + } +} + +func TestServer_PromptsList_WithCursorAndLimit(t *testing.T) { + now := time.Now() + store := &mockPromptStore{ + prompts: map[string]*promptstore.Prompt{ + "test1": { + Name: "test1", + Title: "Test 1", + Created: now, + Updated: now, + }, + "test2": { + Name: "test2", + Title: "Test 2", + Created: now, + Updated: now, + }, + }, + } + + server, _, outBuf := createTestServer(t, store) + + // Initialize server + server.mu.Lock() + server.initialized = true + server.mu.Unlock() + + // Send prompts/list request with cursor and limit + params := map[string]interface{}{ + "cursor": "test1", + "limit": 10, + } + paramsBytes, _ := json.Marshal(params) + req := Request{ + JSONRPC: "2.0", + ID: 30, + Method: "prompts/list", + Params: paramsBytes, + } + + server.handle(req) + + // Read response + resp, err := readResponse(outBuf) + if err != nil { + t.Fatalf("readResponse() error = %v", err) + } + + if resp.Error != nil { + t.Fatalf("Error = %v, want nil", resp.Error) + } +} + +func TestServer_PromptsUpdate_WithDescription(t *testing.T) { + now := time.Now() + store := &mockPromptStore{ + prompts: map[string]*promptstore.Prompt{ + "test": { + Name: "test", + Title: "Original", + Created: now, + Updated: now, + Messages: []promptstore.PromptMessage{ + { + Role: "user", + Content: promptstore.MessageContent{ + Type: "text", + Text: "Original", + }, + }, + }, + }, + }, + } + + server, _, outBuf := createTestServer(t, store) + + // Initialize server + server.mu.Lock() + server.initialized = true + server.mu.Unlock() + + // Send prompts/update request with description + params := UpdatePromptRequest{ + Name: "test", + Description: "Updated description", + } + paramsBytes, _ := json.Marshal(params) + req := Request{ + JSONRPC: "2.0", + ID: 31, + Method: "prompts/update", + Params: paramsBytes, + } + + server.handle(req) + + // Read response + resp, err := readResponse(outBuf) + if err != nil { + t.Fatalf("readResponse() error = %v", err) + } + + if resp.Error != nil { + t.Fatalf("Error = %v, want nil", resp.Error) + } +} + +func TestServer_PromptsUpdate_WithArguments(t *testing.T) { + now := time.Now() + store := &mockPromptStore{ + prompts: map[string]*promptstore.Prompt{ + "test": { + Name: "test", + Title: "Original", + Created: now, + Updated: now, + Messages: []promptstore.PromptMessage{ + { + Role: "user", + Content: promptstore.MessageContent{ + Type: "text", + Text: "Original", + }, + }, + }, + }, + }, + } + + server, _, outBuf := createTestServer(t, store) + + // Initialize server + server.mu.Lock() + server.initialized = true + server.mu.Unlock() + + // Send prompts/update request with arguments + params := UpdatePromptRequest{ + Name: "test", + Arguments: []PromptArgument{ + {Name: "newarg", Description: "New argument", Required: true}, + }, + } + paramsBytes, _ := json.Marshal(params) + req := Request{ + JSONRPC: "2.0", + ID: 32, + Method: "prompts/update", + Params: paramsBytes, + } + + server.handle(req) + + // Read response + resp, err := readResponse(outBuf) + if err != nil { + t.Fatalf("readResponse() error = %v", err) + } + + if resp.Error != nil { + t.Fatalf("Error = %v, want nil", resp.Error) + } +} + +func TestServer_PromptsUpdate_WithTags(t *testing.T) { + now := time.Now() + store := &mockPromptStore{ + prompts: map[string]*promptstore.Prompt{ + "test": { + Name: "test", + Title: "Original", + Created: now, + Updated: now, + Messages: []promptstore.PromptMessage{ + { + Role: "user", + Content: promptstore.MessageContent{ + Type: "text", + Text: "Original", + }, + }, + }, + }, + }, + } + + server, _, outBuf := createTestServer(t, store) + + // Initialize server + server.mu.Lock() + server.initialized = true + server.mu.Unlock() + + // Send prompts/update request with tags + params := UpdatePromptRequest{ + Name: "test", + Tags: []string{"newtag1", "newtag2"}, + } + paramsBytes, _ := json.Marshal(params) + req := Request{ + JSONRPC: "2.0", + ID: 33, + Method: "prompts/update", + Params: paramsBytes, + } + + server.handle(req) + + // Read response + resp, err := readResponse(outBuf) + if err != nil { + t.Fatalf("readResponse() error = %v", err) + } + + if resp.Error != nil { + t.Fatalf("Error = %v, want nil", resp.Error) + } +} + +func TestServer_Run_InvalidJSON(t *testing.T) { + store := &mockPromptStore{prompts: make(map[string]*promptstore.Prompt)} + inBuf := &bytes.Buffer{} + outBuf := &bytes.Buffer{} + logger := log.New(io.Discard, "", 0) + server := NewServer(inBuf, outBuf, logger, store) + + // Write invalid JSON + msg := []byte(`{invalid json}`) + header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(msg)) + inBuf.WriteString(header) + inBuf.Write(msg) + + // Run in background + done := make(chan error, 1) + go func() { + done <- server.Run() + }() + + // Give time for processing + time.Sleep(50 * time.Millisecond) + + // Should have written error response + if outBuf.Len() == 0 { + t.Error("Expected error response to be written") + } +} + +func TestServer_PromptsCreate_MissingMessages(t *testing.T) { + store := &mockPromptStore{prompts: make(map[string]*promptstore.Prompt)} + server, _, outBuf := createTestServer(t, store) + + // Initialize server + server.mu.Lock() + server.initialized = true + server.mu.Unlock() + + // Send prompts/create request without messages + params := CreatePromptRequest{ + Name: "test", + Title: "Test", + } + paramsBytes, _ := json.Marshal(params) + req := Request{ + JSONRPC: "2.0", + ID: 34, + Method: "prompts/create", + Params: paramsBytes, + } + + server.handle(req) + + // Read response + resp, err := readResponse(outBuf) + if err != nil { + t.Fatalf("readResponse() error = %v", err) + } + + if resp.Error == nil { + t.Fatal("Expected error for missing messages") + } +} + +func TestServer_HandleInitialize_InvalidParams(t *testing.T) { + store := &mockPromptStore{prompts: make(map[string]*promptstore.Prompt)} + server, _, outBuf := createTestServer(t, store) + + // Send initialize request with invalid params + req := Request{ + JSONRPC: "2.0", + ID: 35, + Method: "initialize", + Params: json.RawMessage(`{invalid}`), + } + + server.handle(req) + + // Read response + resp, err := readResponse(outBuf) + if err != nil { + t.Fatalf("readResponse() error = %v", err) + } + + if resp.Error == nil { + t.Fatal("Expected error for invalid params") + } +} |
