summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-11 22:43:50 +0200
committerPaul Buetow <paul@buetow.org>2026-02-11 22:43:50 +0200
commit36d7c0b01e9e5a01f079784e4bf9f052c974c91e (patch)
treeca6bc80c74b7b892516d1164fe1cdd005ab2cbaf /internal
parentd3810ca268f8db2867ae838d0655fb7a56e67252 (diff)
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 <noreply@anthropic.com>
Diffstat (limited to 'internal')
-rw-r--r--internal/mcp/handlers_test.go695
-rw-r--r--internal/mcp/server.go382
-rw-r--r--internal/mcp/server_test.go29
-rw-r--r--internal/mcp/types.go42
-rw-r--r--internal/promptstore/default_prompts.go6
5 files changed, 1144 insertions, 10 deletions
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, &params); 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)