diff options
Diffstat (limited to 'internal/mcp/server.go')
| -rw-r--r-- | internal/mcp/server.go | 743 |
1 files changed, 2 insertions, 741 deletions
diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 772d311..99cd456 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -1,4 +1,4 @@ -// Summary: MCP server over stdio; manages prompt store, dispatches requests, and handles protocol. +// Summary: MCP server core — struct definition, constructor, main loop, and message dispatch. package mcp import ( @@ -8,9 +8,7 @@ import ( "fmt" "io" "log" - "strings" "sync" - "time" "codeberg.org/snonux/hexai/internal" "codeberg.org/snonux/hexai/internal/promptstore" @@ -50,7 +48,7 @@ func NewServer(r io.Reader, w io.Writer, logger *log.Logger, store promptstore.P syncer: syncer, } - // Initialize dispatch table + // Initialize dispatch table mapping JSON-RPC methods to handler functions s.handlers = map[string]func(Request){ "initialize": s.handleInitialize, "initialized": s.handleInitialized, @@ -166,139 +164,6 @@ func (s *Server) handleInitialized(_ Request) { // No response required for notifications } -// handlePromptsList processes the prompts/list request. -func (s *Server) handlePromptsList(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 ListPromptsRequest - if req.Params != nil && len(req.Params) > 0 { - if err := json.Unmarshal(req.Params, ¶ms); err != nil { - s.sendError(req.ID, ErrCodeInvalidParams, "Invalid prompts/list params") - return - } - } - - // List prompts from store - prompts, nextCursor, err := s.store.List(params.Cursor, 100) - if err != nil { - s.logger.Printf("store list error: %v", err) - s.sendError(req.ID, ErrCodeInternalError, "Failed to list prompts") - return - } - - // Convert to PromptInfo (initialize as empty slice so JSON marshals as [] not null) - infos := make([]PromptInfo, 0, len(prompts)) - for _, p := range prompts { - args := make([]PromptArgument, len(p.Arguments)) - for i, a := range p.Arguments { - args[i] = PromptArgument{ - Name: a.Name, - Description: a.Description, - Required: a.Required, - } - } - infos = append(infos, PromptInfo{ - Name: p.Name, - Title: p.Title, - Description: p.Description, - Arguments: args, - }) - } - - result := ListPromptsResult{ - Prompts: infos, - NextCursor: nextCursor, - } - - s.sendResponse(req.ID, result) -} - -// handlePromptsGet processes the prompts/get request. -func (s *Server) handlePromptsGet(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 GetPromptRequest - if err := json.Unmarshal(req.Params, ¶ms); err != nil { - s.sendError(req.ID, ErrCodeInvalidParams, "Invalid prompts/get params") - return - } - - if params.Name == "" { - s.sendError(req.ID, ErrCodeInvalidParams, "Missing prompt name") - return - } - - // Get prompt from store - prompt, err := s.store.Get(params.Name) - if err != nil { - s.logger.Printf("store get error: %v", err) - s.sendError(req.ID, ErrCodeInvalidParams, fmt.Sprintf("Prompt not found: %s", params.Name)) - return - } - - // Render prompt with arguments - messages, err := s.renderPrompt(prompt, params.Arguments) - if err != nil { - s.logger.Printf("render error: %v", err) - s.sendError(req.ID, ErrCodeInvalidParams, err.Error()) - return - } - - result := GetPromptResult{ - Description: prompt.Description, - Messages: messages, - } - - s.sendResponse(req.ID, result) -} - -// renderPrompt substitutes template arguments in prompt messages. -// Returns error if required arguments are missing. -func (s *Server) renderPrompt(prompt *promptstore.Prompt, args map[string]string) ([]PromptMessage, error) { - // Validate required arguments - for _, arg := range prompt.Arguments { - if arg.Required { - if _, ok := args[arg.Name]; !ok { - return nil, fmt.Errorf("missing required argument: %s", arg.Name) - } - } - } - - // Render each message - var rendered []PromptMessage - for _, msg := range prompt.Messages { - text := msg.Content.Text - - // Simple template substitution: {{arg}} -> value - for key, val := range args { - placeholder := "{{" + key + "}}" - text = strings.ReplaceAll(text, placeholder, val) - } - - rendered = append(rendered, PromptMessage{ - Role: msg.Role, - Content: MessageContent{ - Type: msg.Content.Type, - Text: text, - }, - }) - } - - return rendered, nil -} - // sendResponse sends a successful JSON-RPC response. func (s *Server) sendResponse(id any, result any) { resp := Response{ @@ -326,518 +191,6 @@ func (s *Server) sendError(id any, code int, message string) { } } -// handlePromptsCreate processes the prompts/create request. -// Creates a new custom prompt in user.jsonl. -func (s *Server) handlePromptsCreate(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 CreatePromptRequest - if err := json.Unmarshal(req.Params, ¶ms); err != nil { - s.sendError(req.ID, ErrCodeInvalidParams, "Invalid prompts/create params") - return - } - - // Validate required fields - if err := validateCreateParams(params); err != nil { - s.sendError(req.ID, ErrCodeInvalidParams, err.Error()) - return - } - - // Build prompt from params - prompt := buildPromptFromCreateParams(params) - - if err := s.store.Create(prompt); err != nil { - s.logger.Printf("create prompt error: %v", err) - s.sendError(req.ID, ErrCodeInternalError, fmt.Sprintf("Failed to create prompt: %v", err)) - return - } - - result := PromptOperationResult{ - Success: true, - Message: fmt.Sprintf("Created prompt: %s", params.Name), - } - - s.logger.Printf("created prompt: %s", params.Name) - - // Sync to slash commands if enabled - if s.syncer != nil { - if err := s.syncer.SyncCreate(prompt); err != nil { - s.logger.Printf("slash command sync failed: %v", err) - } - } - - s.sendResponse(req.ID, result) - - // Notify clients that the prompt list has changed - s.sendPromptsListChangedNotification() -} - -// validateCreateParams validates required fields for prompt creation. -func validateCreateParams(params CreatePromptRequest) error { - if params.Name == "" { - return fmt.Errorf("prompt name is required") - } - if params.Title == "" { - return fmt.Errorf("prompt title is required") - } - if len(params.Messages) == 0 { - return fmt.Errorf("at least one message is required") - } - return nil -} - -// buildPromptFromCreateParams converts CreatePromptRequest to Prompt. -func buildPromptFromCreateParams(params CreatePromptRequest) *promptstore.Prompt { - prompt := &promptstore.Prompt{ - Name: params.Name, - Title: params.Title, - Description: params.Description, - Tags: params.Tags, - Created: time.Now(), - Updated: time.Now(), - } - - // Convert arguments - for _, arg := range params.Arguments { - prompt.Arguments = append(prompt.Arguments, promptstore.PromptArgument{ - Name: arg.Name, - Description: arg.Description, - Required: arg.Required, - }) - } - - // Convert messages - for _, msg := range params.Messages { - prompt.Messages = append(prompt.Messages, promptstore.PromptMessage{ - Role: msg.Role, - Content: promptstore.MessageContent{ - Type: msg.Content.Type, - Text: msg.Content.Text, - }, - }) - } - - return prompt -} - -// handlePromptsUpdate processes the prompts/update request. -// Updates an existing custom prompt in user.jsonl. -func (s *Server) handlePromptsUpdate(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 UpdatePromptRequest - if err := json.Unmarshal(req.Params, ¶ms); err != nil { - s.sendError(req.ID, ErrCodeInvalidParams, "Invalid prompts/update params") - return - } - - if params.Name == "" { - s.sendError(req.ID, ErrCodeInvalidParams, "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.sendError(req.ID, ErrCodeInvalidParams, fmt.Sprintf("Prompt not found: %s", params.Name)) - return - } - - // Apply updates to existing prompt - applyPromptUpdates(existing, params) - - if err := s.store.Update(existing); err != nil { - s.logger.Printf("update prompt error: %v", err) - s.sendError(req.ID, ErrCodeInternalError, fmt.Sprintf("Failed to update prompt: %v", err)) - return - } - - result := PromptOperationResult{ - Success: true, - Message: fmt.Sprintf("Updated prompt: %s", params.Name), - } - - s.logger.Printf("updated prompt: %s", params.Name) - - // Sync to slash commands if enabled - if s.syncer != nil { - if err := s.syncer.SyncUpdate(existing); err != nil { - s.logger.Printf("slash command sync failed: %v", err) - } - } - - s.sendResponse(req.ID, result) - - // Notify clients that the prompt list has changed - s.sendPromptsListChangedNotification() -} - -// applyPromptUpdates applies update parameters to an existing prompt. -func applyPromptUpdates(existing *promptstore.Prompt, params UpdatePromptRequest) { - // Update fields (only if provided) - if params.Title != "" { - existing.Title = params.Title - } - if params.Description != "" { - existing.Description = params.Description - } - if len(params.Arguments) > 0 { - existing.Arguments = nil - for _, arg := range params.Arguments { - existing.Arguments = append(existing.Arguments, promptstore.PromptArgument{ - Name: arg.Name, - Description: arg.Description, - Required: arg.Required, - }) - } - } - if len(params.Messages) > 0 { - existing.Messages = nil - for _, msg := range params.Messages { - existing.Messages = append(existing.Messages, promptstore.PromptMessage{ - Role: msg.Role, - Content: promptstore.MessageContent{ - Type: msg.Content.Type, - Text: msg.Content.Text, - }, - }) - } - } - if len(params.Tags) > 0 { - existing.Tags = params.Tags - } - - existing.Updated = time.Now() -} - -// handlePromptsDelete processes the prompts/delete request. -// Deletes a custom prompt from user.jsonl. -func (s *Server) handlePromptsDelete(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 DeletePromptRequest - if err := json.Unmarshal(req.Params, ¶ms); err != nil { - s.sendError(req.ID, ErrCodeInvalidParams, "Invalid prompts/delete params") - return - } - - if params.Name == "" { - s.sendError(req.ID, ErrCodeInvalidParams, "Prompt name is required") - return - } - - if err := s.store.Delete(params.Name); err != nil { - s.logger.Printf("delete prompt error: %v", err) - s.sendError(req.ID, ErrCodeInternalError, fmt.Sprintf("Failed to delete prompt: %v", err)) - return - } - - result := PromptOperationResult{ - Success: true, - Message: fmt.Sprintf("Deleted prompt: %s", params.Name), - } - - s.logger.Printf("deleted prompt: %s", params.Name) - - // Delete slash command file if enabled - if s.syncer != nil { - if err := s.syncer.Delete(params.Name); err != nil { - s.logger.Printf("slash command sync delete failed: %v", err) - } - } - - 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) - - // Sync to slash commands if enabled - if s.syncer != nil { - if err := s.syncer.SyncCreate(prompt); err != nil { - s.logger.Printf("slash command sync failed: %v", err) - } - } - - 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) - - // Sync to slash commands if enabled - if s.syncer != nil { - if err := s.syncer.SyncUpdate(existing); err != nil { - s.logger.Printf("slash command sync failed: %v", err) - } - } - - 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) - - // Delete slash command file if enabled - if s.syncer != nil { - if err := s.syncer.Delete(name); err != nil { - s.logger.Printf("slash command sync delete failed: %v", err) - } - } - - 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{ @@ -856,98 +209,6 @@ func (s *Server) sendToolError(id any, message string) { 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() { |
