From 36d7c0b01e9e5a01f079784e4bf9f052c974c91e Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 11 Feb 2026 22:43:50 +0200 Subject: feat: add MCP Tools support for prompt management Implements tools/list and tools/call endpoints to expose prompt management operations (create, update, delete) as callable MCP tools. This enables Claude to use the meta-prompts (save_prompt, update_prompt) to actually create and modify prompts. Key changes: - Add Tool type definitions (Tool, ListToolsRequest, CallToolRequest, etc.) - Implement handleToolsList() returning 3 tools with JSON Schemas - Implement handleToolsCall() with tool wrappers for create/update/delete - Add 17 comprehensive unit tests (82.8% coverage maintained) - Update meta-prompts to reference tools instead of JSON-RPC methods - Enable listChanged notifications for immediate prompt availability - Refactor large functions into helpers to stay under 50-line limit Tools advertised alongside Prompts capability. All functions under 50 lines. Backward compatible - existing prompts/* JSON-RPC methods unchanged. Co-Authored-By: Claude Sonnet 4.5 --- internal/mcp/handlers_test.go | 695 ++++++++++++++++++++++++++++++++ internal/mcp/server.go | 382 +++++++++++++++++- internal/mcp/server_test.go | 29 +- internal/mcp/types.go | 42 ++ internal/promptstore/default_prompts.go | 6 +- 5 files changed, 1144 insertions(+), 10 deletions(-) (limited to 'internal') diff --git a/internal/mcp/handlers_test.go b/internal/mcp/handlers_test.go index 79c567e..1c74f98 100644 --- a/internal/mcp/handlers_test.go +++ b/internal/mcp/handlers_test.go @@ -953,3 +953,698 @@ func TestServer_HandleInitialize_InvalidParams(t *testing.T) { t.Fatal("Expected error for invalid params") } } + +// ==================== Tools Tests ==================== + +func TestServer_ToolsList(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 tools/list request + req := Request{ + JSONRPC: "2.0", + ID: 40, + Method: "tools/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.Fatalf("Error = %v, want nil", resp.Error) + } + + // Parse result + resultBytes, _ := json.Marshal(resp.Result) + var result ListToolsResult + if err := json.Unmarshal(resultBytes, &result); err != nil { + t.Fatalf("Unmarshal result error = %v", err) + } + + // Verify 3 tools returned + if len(result.Tools) != 3 { + t.Errorf("Tools count = %d, want 3", len(result.Tools)) + } + + // Verify tool names + toolNames := make(map[string]bool) + for _, tool := range result.Tools { + toolNames[tool.Name] = true + if tool.Description == "" { + t.Errorf("Tool %s has empty description", tool.Name) + } + if tool.InputSchema == nil { + t.Errorf("Tool %s has nil InputSchema", tool.Name) + } + } + + expectedTools := []string{"create_prompt", "update_prompt", "delete_prompt"} + for _, name := range expectedTools { + if !toolNames[name] { + t.Errorf("Missing expected tool: %s", name) + } + } +} + +func TestServer_ToolsCall_CreatePrompt(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 tools/call request + params := CallToolRequest{ + Name: "create_prompt", + Arguments: map[string]interface{}{ + "name": "tool_test", + "title": "Tool Test Prompt", + "messages": []interface{}{ + map[string]interface{}{ + "role": "user", + "content": map[string]interface{}{ + "type": "text", + "text": "Test message", + }, + }, + }, + }, + } + paramsBytes, _ := json.Marshal(params) + req := Request{ + JSONRPC: "2.0", + ID: 41, + Method: "tools/call", + 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 CallToolResult + if err := json.Unmarshal(resultBytes, &result); err != nil { + t.Fatalf("Unmarshal result error = %v", err) + } + + if result.IsError { + t.Errorf("IsError = true, want false. Content: %v", result.Content) + } + + if len(result.Content) == 0 { + t.Fatal("Expected content in result") + } + + // Verify prompt was created + if _, exists := store.prompts["tool_test"]; !exists { + t.Error("Prompt was not created in store") + } +} + +func TestServer_ToolsCall_UpdatePrompt(t *testing.T) { + now := time.Now() + store := &mockPromptStore{ + prompts: map[string]*promptstore.Prompt{ + "tool_update": { + Name: "tool_update", + Title: "Original Title", + 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 tools/call request + params := CallToolRequest{ + Name: "update_prompt", + Arguments: map[string]interface{}{ + "name": "tool_update", + "title": "Updated Title", + }, + } + paramsBytes, _ := json.Marshal(params) + req := Request{ + JSONRPC: "2.0", + ID: 42, + Method: "tools/call", + 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 CallToolResult + if err := json.Unmarshal(resultBytes, &result); err != nil { + t.Fatalf("Unmarshal result error = %v", err) + } + + if result.IsError { + t.Errorf("IsError = true, want false. Content: %v", result.Content) + } + + // Verify prompt was updated + if store.prompts["tool_update"].Title != "Updated Title" { + t.Errorf("Title not updated, got %s", store.prompts["tool_update"].Title) + } +} + +func TestServer_ToolsCall_DeletePrompt(t *testing.T) { + now := time.Now() + store := &mockPromptStore{ + prompts: map[string]*promptstore.Prompt{ + "tool_delete": { + Name: "tool_delete", + Title: "To Delete", + Created: now, + Updated: now, + Messages: []promptstore.PromptMessage{ + { + Role: "user", + Content: promptstore.MessageContent{ + Type: "text", + Text: "Test", + }, + }, + }, + }, + }, + } + + server, _, outBuf := createTestServer(t, store) + + // Initialize server + server.mu.Lock() + server.initialized = true + server.mu.Unlock() + + // Send tools/call request + params := CallToolRequest{ + Name: "delete_prompt", + Arguments: map[string]interface{}{ + "name": "tool_delete", + }, + } + paramsBytes, _ := json.Marshal(params) + req := Request{ + JSONRPC: "2.0", + ID: 43, + Method: "tools/call", + 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 CallToolResult + if err := json.Unmarshal(resultBytes, &result); err != nil { + t.Fatalf("Unmarshal result error = %v", err) + } + + if result.IsError { + t.Errorf("IsError = true, want false. Content: %v", result.Content) + } + + // Verify prompt was deleted + if _, exists := store.prompts["tool_delete"]; exists { + t.Error("Prompt was not deleted from store") + } +} + +func TestServer_ToolsCall_UnknownTool(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 tools/call request with unknown tool + params := CallToolRequest{ + Name: "nonexistent_tool", + Arguments: map[string]interface{}{}, + } + paramsBytes, _ := json.Marshal(params) + req := Request{ + JSONRPC: "2.0", + ID: 44, + Method: "tools/call", + Params: paramsBytes, + } + + server.handle(req) + + // Read response + resp, err := readResponse(outBuf) + if err != nil { + t.Fatalf("readResponse() error = %v", err) + } + + // Should not be a protocol error + if resp.Error != nil { + t.Fatalf("Unexpected protocol error: %v", resp.Error) + } + + // Parse result - should be a tool error + resultBytes, _ := json.Marshal(resp.Result) + var result CallToolResult + if err := json.Unmarshal(resultBytes, &result); err != nil { + t.Fatalf("Unmarshal result error = %v", err) + } + + if !result.IsError { + t.Error("Expected IsError = true for unknown tool") + } +} + +func TestServer_ToolsCall_InvalidArguments(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 tools/call with invalid arguments (missing required fields) + params := CallToolRequest{ + Name: "create_prompt", + Arguments: map[string]interface{}{ + "name": "test", // Missing title and messages + }, + } + paramsBytes, _ := json.Marshal(params) + req := Request{ + JSONRPC: "2.0", + ID: 45, + Method: "tools/call", + Params: paramsBytes, + } + + server.handle(req) + + // Read response + resp, err := readResponse(outBuf) + if err != nil { + t.Fatalf("readResponse() error = %v", err) + } + + // Should not be a protocol error + if resp.Error != nil { + t.Fatalf("Unexpected protocol error: %v", resp.Error) + } + + // Parse result - should be a tool error + resultBytes, _ := json.Marshal(resp.Result) + var result CallToolResult + if err := json.Unmarshal(resultBytes, &result); err != nil { + t.Fatalf("Unmarshal result error = %v", err) + } + + if !result.IsError { + t.Error("Expected IsError = true for invalid arguments") + } +} + +func TestServer_ToolsCall_NotInitialized(t *testing.T) { + store := &mockPromptStore{prompts: make(map[string]*promptstore.Prompt)} + server, _, outBuf := createTestServer(t, store) + + // DO NOT initialize server + + // Send tools/call request + params := CallToolRequest{ + Name: "create_prompt", + Arguments: map[string]interface{}{}, + } + paramsBytes, _ := json.Marshal(params) + req := Request{ + JSONRPC: "2.0", + ID: 46, + Method: "tools/call", + 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 uninitialized server") + } + + if resp.Error.Code != ErrCodeInvalidRequest { + t.Errorf("Error code = %d, want %d", resp.Error.Code, ErrCodeInvalidRequest) + } +} + +func TestServer_ToolsCall_CreatePrompt_AlreadyExists(t *testing.T) { + now := time.Now() + store := &mockPromptStore{ + prompts: map[string]*promptstore.Prompt{ + "existing": { + Name: "existing", + Title: "Existing", + Created: now, + Updated: now, + Messages: []promptstore.PromptMessage{}, + }, + }, + } + + server, _, outBuf := createTestServer(t, store) + + // Initialize server + server.mu.Lock() + server.initialized = true + server.mu.Unlock() + + // Send tools/call to create duplicate + params := CallToolRequest{ + Name: "create_prompt", + Arguments: map[string]interface{}{ + "name": "existing", + "title": "Duplicate", + "messages": []interface{}{ + map[string]interface{}{ + "role": "user", + "content": map[string]interface{}{ + "type": "text", + "text": "Test", + }, + }, + }, + }, + } + paramsBytes, _ := json.Marshal(params) + req := Request{ + JSONRPC: "2.0", + ID: 47, + Method: "tools/call", + Params: paramsBytes, + } + + server.handle(req) + + // Read response + resp, err := readResponse(outBuf) + if err != nil { + t.Fatalf("readResponse() error = %v", err) + } + + // Should not be a protocol error + if resp.Error != nil { + t.Fatalf("Unexpected protocol error: %v", resp.Error) + } + + // Parse result - should be a tool error + resultBytes, _ := json.Marshal(resp.Result) + var result CallToolResult + if err := json.Unmarshal(resultBytes, &result); err != nil { + t.Fatalf("Unmarshal result error = %v", err) + } + + if !result.IsError { + t.Error("Expected IsError = true for duplicate prompt") + } +} + +func TestServer_Initialize_AdvertisesTools(t *testing.T) { + store := &mockPromptStore{prompts: make(map[string]*promptstore.Prompt)} + server, _, outBuf := createTestServer(t, store) + + // Send initialize request + params := InitializeRequest{ + ProtocolVersion: "2025-11-25", + Capabilities: ClientCapabilities{}, + ClientInfo: ClientInfo{ + Name: "test-client", + Version: "1.0", + }, + } + paramsBytes, _ := json.Marshal(params) + req := Request{ + JSONRPC: "2.0", + ID: 48, + Method: "initialize", + 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 InitializeResult + if err := json.Unmarshal(resultBytes, &result); err != nil { + t.Fatalf("Unmarshal result error = %v", err) + } + + // Verify Tools capability is advertised + if result.Capabilities.Tools == nil { + t.Fatal("Tools capability not advertised") + } + + // Verify Prompts capability is also still advertised + if result.Capabilities.Prompts == nil { + t.Fatal("Prompts capability not advertised") + } +} + +func TestServer_ToolsList_NotInitialized(t *testing.T) { + store := &mockPromptStore{prompts: make(map[string]*promptstore.Prompt)} + server, _, outBuf := createTestServer(t, store) + + // DO NOT initialize server + + // Send tools/list request + req := Request{ + JSONRPC: "2.0", + ID: 49, + Method: "tools/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 for uninitialized server") + } + + if resp.Error.Code != ErrCodeInvalidRequest { + t.Errorf("Error code = %d, want %d", resp.Error.Code, ErrCodeInvalidRequest) + } +} + +func TestServer_ToolsCall_UpdatePrompt_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 tools/call to update non-existent prompt + params := CallToolRequest{ + Name: "update_prompt", + Arguments: map[string]interface{}{ + "name": "nonexistent", + "title": "Updated", + }, + } + paramsBytes, _ := json.Marshal(params) + req := Request{ + JSONRPC: "2.0", + ID: 50, + Method: "tools/call", + Params: paramsBytes, + } + + server.handle(req) + + // Read response + resp, err := readResponse(outBuf) + if err != nil { + t.Fatalf("readResponse() error = %v", err) + } + + // Should not be a protocol error + if resp.Error != nil { + t.Fatalf("Unexpected protocol error: %v", resp.Error) + } + + // Parse result - should be a tool error + resultBytes, _ := json.Marshal(resp.Result) + var result CallToolResult + if err := json.Unmarshal(resultBytes, &result); err != nil { + t.Fatalf("Unmarshal result error = %v", err) + } + + if !result.IsError { + t.Error("Expected IsError = true for non-existent prompt") + } +} + +func TestServer_ToolsCall_DeletePrompt_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 tools/call to delete non-existent prompt + params := CallToolRequest{ + Name: "delete_prompt", + Arguments: map[string]interface{}{ + "name": "nonexistent", + }, + } + paramsBytes, _ := json.Marshal(params) + req := Request{ + JSONRPC: "2.0", + ID: 51, + Method: "tools/call", + Params: paramsBytes, + } + + server.handle(req) + + // Read response + resp, err := readResponse(outBuf) + if err != nil { + t.Fatalf("readResponse() error = %v", err) + } + + // Should not be a protocol error + if resp.Error != nil { + t.Fatalf("Unexpected protocol error: %v", resp.Error) + } + + // Parse result - should be a tool error + resultBytes, _ := json.Marshal(resp.Result) + var result CallToolResult + if err := json.Unmarshal(resultBytes, &result); err != nil { + t.Fatalf("Unmarshal result error = %v", err) + } + + if !result.IsError { + t.Error("Expected IsError = true for non-existent prompt") + } +} + +func TestServer_ToolsCall_InvalidParams(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 tools/call with invalid JSON params + req := Request{ + JSONRPC: "2.0", + ID: 52, + Method: "tools/call", + 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 protocol error for invalid params") + } + + if resp.Error.Code != ErrCodeInvalidParams { + t.Errorf("Error code = %d, want %d", resp.Error.Code, ErrCodeInvalidParams) + } +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 6ac7537..58de01d 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -49,6 +49,8 @@ func NewServer(r io.Reader, w io.Writer, logger *log.Logger, store promptstore.P "prompts/create": s.handlePromptsCreate, "prompts/update": s.handlePromptsUpdate, "prompts/delete": s.handlePromptsDelete, + "tools/list": s.handleToolsList, + "tools/call": s.handleToolsCall, "notifications/initialized": s.handleInitialized, } @@ -116,9 +118,12 @@ func (s *Server) handleInitialize(req Request) { ProtocolVersion: negotiatedVersion, Capabilities: ServerCapabilities{ Prompts: &PromptsCapability{ - ListChanged: false, + ListChanged: true, // Server sends notifications when prompt list changes Mutable: true, // Advertise that we support create/update/delete }, + Tools: &ToolsCapability{ + ListChanged: false, // Tool list is static (no dynamic changes) + }, }, ServerInfo: ServerInfo{ Name: "hexai-mcp-server", @@ -350,6 +355,9 @@ func (s *Server) handlePromptsCreate(req Request) { s.logger.Printf("created prompt: %s", params.Name) s.sendResponse(req.ID, result) + + // Notify clients that the prompt list has changed + s.sendPromptsListChangedNotification() } // validateCreateParams validates required fields for prompt creation. @@ -446,6 +454,9 @@ func (s *Server) handlePromptsUpdate(req Request) { s.logger.Printf("updated prompt: %s", params.Name) s.sendResponse(req.ID, result) + + // Notify clients that the prompt list has changed + s.sendPromptsListChangedNotification() } // applyPromptUpdates applies update parameters to an existing prompt. @@ -521,4 +532,373 @@ func (s *Server) handlePromptsDelete(req Request) { s.logger.Printf("deleted prompt: %s", params.Name) s.sendResponse(req.ID, result) + + // Notify clients that the prompt list has changed + s.sendPromptsListChangedNotification() +} + +// handleToolsList processes the tools/list request. +// Returns available tools for prompt management operations. +func (s *Server) handleToolsList(req Request) { + s.mu.RLock() + if !s.initialized { + s.mu.RUnlock() + s.sendError(req.ID, ErrCodeInvalidRequest, "Server not initialized") + return + } + s.mu.RUnlock() + + // Define 3 tools for prompt management + tools := []Tool{ + s.makeCreatePromptTool(), + s.makeUpdatePromptTool(), + s.makeDeletePromptTool(), + } + + result := ListToolsResult{ + Tools: tools, + NextCursor: "", // No pagination needed for 3 tools + } + + s.sendResponse(req.ID, result) +} + +// makeCreatePromptTool returns the JSON Schema definition for create_prompt tool. +func (s *Server) makeCreatePromptTool() Tool { + return Tool{ + Name: "create_prompt", + Description: "Creates a new custom prompt template that can be invoked via the MCP prompts capability. The prompt is saved to user.jsonl and becomes immediately available for use.", + InputSchema: s.buildCreatePromptSchema(), + } +} + +// buildCreatePromptSchema constructs the JSON Schema for create_prompt tool inputs. +func (s *Server) buildCreatePromptSchema() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{ + "type": "string", + "description": "Unique identifier for the prompt (lowercase, underscores allowed)", + }, + "title": map[string]interface{}{ + "type": "string", + "description": "Human-readable display name for the prompt", + }, + "description": map[string]interface{}{ + "type": "string", + "description": "Detailed explanation of what the prompt does", + }, + "arguments": buildArgumentArraySchema(), + "messages": buildMessageArraySchema(), + "tags": map[string]interface{}{ + "type": "array", + "description": "Optional tags for categorizing the prompt", + "items": map[string]interface{}{"type": "string"}, + }, + }, + "required": []string{"name", "title", "messages"}, + } +} + +// makeUpdatePromptTool returns the JSON Schema definition for update_prompt tool. +func (s *Server) makeUpdatePromptTool() Tool { + return Tool{ + Name: "update_prompt", + Description: "Updates an existing custom prompt template. Only custom prompts (stored in user.jsonl) can be updated; built-in prompts cannot be modified.", + InputSchema: s.buildUpdatePromptSchema(), + } +} + +// buildUpdatePromptSchema constructs the JSON Schema for update_prompt tool inputs. +func (s *Server) buildUpdatePromptSchema() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{ + "type": "string", + "description": "Name of the existing prompt to update", + }, + "title": map[string]interface{}{ + "type": "string", + "description": "New display name (optional, only if changing)", + }, + "description": map[string]interface{}{ + "type": "string", + "description": "New description (optional, only if changing)", + }, + "arguments": buildArgumentArraySchema(), + "messages": buildMessageArraySchema(), + "tags": map[string]interface{}{ + "type": "array", + "description": "New tags array (optional, replaces existing if provided)", + "items": map[string]interface{}{"type": "string"}, + }, + }, + "required": []string{"name"}, + } +} + +// makeDeletePromptTool returns the JSON Schema definition for delete_prompt tool. +func (s *Server) makeDeletePromptTool() Tool { + return Tool{ + Name: "delete_prompt", + Description: "Deletes a custom prompt template. Only custom prompts (stored in user.jsonl) can be deleted; built-in prompts cannot be removed.", + InputSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{ + "type": "string", + "description": "Name of the prompt to delete", + }, + }, + "required": []string{"name"}, + }, + } +} + +// handleToolsCall processes the tools/call request. +// Dispatches to appropriate tool wrapper based on tool name. +func (s *Server) handleToolsCall(req Request) { + s.mu.RLock() + if !s.initialized { + s.mu.RUnlock() + s.sendError(req.ID, ErrCodeInvalidRequest, "Server not initialized") + return + } + s.mu.RUnlock() + + var params CallToolRequest + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + s.sendError(req.ID, ErrCodeInvalidParams, "Invalid tools/call params") + return + } + + // Dispatch to appropriate tool wrapper + switch params.Name { + case "create_prompt": + s.callCreatePromptTool(req.ID, params.Arguments) + case "update_prompt": + s.callUpdatePromptTool(req.ID, params.Arguments) + case "delete_prompt": + s.callDeletePromptTool(req.ID, params.Arguments) + default: + s.sendToolError(req.ID, fmt.Sprintf("Unknown tool: %s", params.Name)) + } +} + +// callCreatePromptTool executes the create_prompt tool. +// Converts map arguments to CreatePromptRequest and delegates to store.Create. +func (s *Server) callCreatePromptTool(id any, args map[string]interface{}) { + // Convert map to CreatePromptRequest + params, err := convertToCreatePromptRequest(args) + if err != nil { + s.sendToolError(id, fmt.Sprintf("Invalid arguments: %v", err)) + return + } + + // Validate required fields + if err := validateCreateParams(params); err != nil { + s.sendToolError(id, err.Error()) + return + } + + // Build prompt and create + prompt := buildPromptFromCreateParams(params) + if err := s.store.Create(prompt); err != nil { + s.logger.Printf("create prompt error: %v", err) + s.sendToolError(id, fmt.Sprintf("Failed to create prompt: %v", err)) + return + } + + s.logger.Printf("created prompt via tool: %s", params.Name) + s.sendToolSuccess(id, fmt.Sprintf("Successfully created prompt: %s", params.Name)) + + // Notify clients that the prompt list has changed + s.sendPromptsListChangedNotification() +} + +// callUpdatePromptTool executes the update_prompt tool. +// Converts map arguments to UpdatePromptRequest and delegates to store.Update. +func (s *Server) callUpdatePromptTool(id any, args map[string]interface{}) { + // Convert map to UpdatePromptRequest + params, err := convertToUpdatePromptRequest(args) + if err != nil { + s.sendToolError(id, fmt.Sprintf("Invalid arguments: %v", err)) + return + } + + if params.Name == "" { + s.sendToolError(id, "Prompt name is required") + return + } + + // Get existing prompt + existing, err := s.store.Get(params.Name) + if err != nil { + s.logger.Printf("get prompt error: %v", err) + s.sendToolError(id, fmt.Sprintf("Prompt not found: %s", params.Name)) + return + } + + // Apply updates + applyPromptUpdates(existing, params) + + if err := s.store.Update(existing); err != nil { + s.logger.Printf("update prompt error: %v", err) + s.sendToolError(id, fmt.Sprintf("Failed to update prompt: %v", err)) + return + } + + s.logger.Printf("updated prompt via tool: %s", params.Name) + s.sendToolSuccess(id, fmt.Sprintf("Successfully updated prompt: %s", params.Name)) + + // Notify clients that the prompt list has changed + s.sendPromptsListChangedNotification() +} + +// callDeletePromptTool executes the delete_prompt tool. +// Extracts name from arguments and delegates to store.Delete. +func (s *Server) callDeletePromptTool(id any, args map[string]interface{}) { + // Extract name from arguments + name, ok := args["name"].(string) + if !ok || name == "" { + s.sendToolError(id, "Prompt name is required") + return + } + + if err := s.store.Delete(name); err != nil { + s.logger.Printf("delete prompt error: %v", err) + s.sendToolError(id, fmt.Sprintf("Failed to delete prompt: %v", err)) + return + } + + s.logger.Printf("deleted prompt via tool: %s", name) + s.sendToolSuccess(id, fmt.Sprintf("Successfully deleted prompt: %s", name)) + + // Notify clients that the prompt list has changed + s.sendPromptsListChangedNotification() +} + +// sendToolSuccess sends a successful tool result. +func (s *Server) sendToolSuccess(id any, message string) { + result := CallToolResult{ + Content: []ToolContent{{Type: "text", Text: message}}, + IsError: false, + } + s.sendResponse(id, result) +} + +// sendToolError sends a tool error result (business logic error, not protocol error). +func (s *Server) sendToolError(id any, message string) { + result := CallToolResult{ + Content: []ToolContent{{Type: "text", Text: message}}, + IsError: true, + } + s.sendResponse(id, result) +} + +// convertToCreatePromptRequest converts map arguments to CreatePromptRequest. +func convertToCreatePromptRequest(args map[string]interface{}) (CreatePromptRequest, error) { + // Marshal to JSON then unmarshal to struct for type conversion + data, err := json.Marshal(args) + if err != nil { + return CreatePromptRequest{}, fmt.Errorf("marshal args: %w", err) + } + + var req CreatePromptRequest + if err := json.Unmarshal(data, &req); err != nil { + return CreatePromptRequest{}, fmt.Errorf("unmarshal to CreatePromptRequest: %w", err) + } + + return req, nil +} + +// convertToUpdatePromptRequest converts map arguments to UpdatePromptRequest. +func convertToUpdatePromptRequest(args map[string]interface{}) (UpdatePromptRequest, error) { + // Marshal to JSON then unmarshal to struct for type conversion + data, err := json.Marshal(args) + if err != nil { + return UpdatePromptRequest{}, fmt.Errorf("marshal args: %w", err) + } + + var req UpdatePromptRequest + if err := json.Unmarshal(data, &req); err != nil { + return UpdatePromptRequest{}, fmt.Errorf("unmarshal to UpdatePromptRequest: %w", err) + } + + return req, nil +} + +// buildArgumentArraySchema creates the JSON Schema for prompt arguments array. +func buildArgumentArraySchema() map[string]interface{} { + return map[string]interface{}{ + "type": "array", + "description": "Template variables that can be substituted when invoking the prompt", + "items": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{ + "type": "string", + "description": "Argument name (used in {{name}} placeholders)", + }, + "description": map[string]interface{}{ + "type": "string", + "description": "Description of the argument's purpose", + }, + "required": map[string]interface{}{ + "type": "boolean", + "description": "Whether this argument must be provided", + }, + }, + "required": []string{"name"}, + }, + } +} + +// buildMessageArraySchema creates the JSON Schema for prompt messages array. +func buildMessageArraySchema() map[string]interface{} { + return map[string]interface{}{ + "type": "array", + "description": "Array of message objects (role + content) that form the prompt", + "items": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "role": map[string]interface{}{ + "type": "string", + "description": "Message role: 'user' or 'assistant'", + "enum": []string{"user", "assistant"}, + }, + "content": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "type": map[string]interface{}{ + "type": "string", + "description": "Content type (currently only 'text' is supported)", + "enum": []string{"text"}, + }, + "text": map[string]interface{}{ + "type": "string", + "description": "The message text (can include {{arg}} placeholders)", + }, + }, + "required": []string{"type", "text"}, + }, + }, + "required": []string{"role", "content"}, + }, + } +} + +// sendPromptsListChangedNotification notifies the client that the prompt list has changed. +// This allows clients to refresh their cached prompt lists. +func (s *Server) sendPromptsListChangedNotification() { + notification := map[string]interface{}{ + "jsonrpc": "2.0", + "method": "notifications/prompts/list_changed", + } + + if err := s.writeMessage(notification); err != nil { + s.logger.Printf("failed to send prompts/list_changed notification: %v", err) + } } diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 0944f35..4b43f51 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -73,23 +73,40 @@ func sendRequest(w io.Writer, req Request) error { } // readResponse reads a newline-delimited JSON-RPC response (MCP stdio protocol). +// Skips notification messages (those without an ID) and returns the actual response. func readResponse(r io.Reader) (*Response, error) { data, err := io.ReadAll(r) if err != nil { return nil, err } - // Parse newline-delimited JSON; take the last non-empty line as the response + // Parse newline-delimited JSON; find the first line with an ID (skip notifications) lines := strings.Split(strings.TrimSpace(string(data)), "\n") if len(lines) == 0 { return nil, fmt.Errorf("no response data") } - body := lines[len(lines)-1] - var resp Response - if err := json.Unmarshal([]byte(body), &resp); err != nil { - return nil, fmt.Errorf("unmarshal response: %w, body: %s", err, body) + + // Try to find a response (message with an ID) + for _, line := range lines { + if len(line) == 0 { + continue + } + + // Quick check if this might be a response (has "id" field) + if !strings.Contains(line, `"id"`) { + continue // Skip notifications + } + + var resp Response + if err := json.Unmarshal([]byte(line), &resp); err != nil { + continue // Not a valid response, try next line + } + + // Valid response found + return &resp, nil } - return &resp, nil + + return nil, fmt.Errorf("no response found in output (only notifications)") } func TestServer_Initialize(t *testing.T) { diff --git a/internal/mcp/types.go b/internal/mcp/types.go index 0cd843a..1b11200 100644 --- a/internal/mcp/types.go +++ b/internal/mcp/types.go @@ -192,6 +192,48 @@ type DeletePromptRequest struct { Name string `json:"name"` } +// Tool defines an MCP tool that can be invoked via tools/call. +// Each tool has a name, description, and JSON Schema for input validation. +type Tool struct { + Name string `json:"name"` // Unique tool identifier + Description string `json:"description"` // Human-readable description + InputSchema map[string]interface{} `json:"inputSchema"` // JSON Schema for arguments +} + +// ListToolsRequest contains parameters for tools/list method. +// Supports pagination via cursor-based iteration. +type ListToolsRequest struct { + Cursor string `json:"cursor,omitempty"` // Pagination cursor (empty for first page) +} + +// ListToolsResult contains list of available tools. +// Includes nextCursor for fetching additional pages if needed. +type ListToolsResult struct { + Tools []Tool `json:"tools"` // Available tools + NextCursor string `json:"nextCursor,omitempty"` // Pagination cursor +} + +// CallToolRequest contains parameters for tools/call method. +// Specifies which tool to execute and its input arguments. +type CallToolRequest struct { + Name string `json:"name"` // Tool name to execute + Arguments map[string]interface{} `json:"arguments,omitempty"` // Tool input arguments +} + +// CallToolResult contains the result of a tool execution. +// Returns content (text output) and optional error flag. +type CallToolResult struct { + Content []ToolContent `json:"content"` // Tool output content + IsError bool `json:"isError,omitempty"` // True if tool execution failed +} + +// ToolContent represents content returned by a tool. +// Currently only text content is supported. +type ToolContent struct { + Type string `json:"type"` // Content type (e.g., "text") + Text string `json:"text"` // Text content +} + // PromptOperationResult indicates success/failure of create/update/delete. type PromptOperationResult struct { Success bool `json:"success"` diff --git a/internal/promptstore/default_prompts.go b/internal/promptstore/default_prompts.go index ea8c793..09a998a 100644 --- a/internal/promptstore/default_prompts.go +++ b/internal/promptstore/default_prompts.go @@ -43,7 +43,7 @@ Please help me by: - What tags would help categorize it - Whether multi-turn messages are needed 3) Showing me a complete preview of the prompt structure in a code block -4) Only after I approve, use the MCP prompts/create method to save it +4) Only after I approve, use the create_prompt tool to save it IMPORTANT FORMATTING RULES for clarifying questions: - Use numbered questions: 1), 2), 3) @@ -104,11 +104,11 @@ Start by examining our conversation and asking your clarifying questions using t Text: `I want to update the existing prompt '{{prompt_name}}'. Please help me by: -1) First, retrieve the current prompt using prompts/get to show me what exists +1) First, show me the current prompt (you can access it via the prompts capability) 2) Ask me what changes I want to make (description, arguments, messages, tags) 3) If I reference content from our current conversation, help extract and template it 4) Show me a complete preview of the updated prompt with changes highlighted -5) Only after I approve, use the MCP prompts/update method to save the changes +5) Only after I approve, use the update_prompt tool to save the changes IMPORTANT FORMATTING RULES for clarifying questions: - Use numbered questions: 1), 2), 3) -- cgit v1.2.3