diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-10 19:28:27 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-10 19:28:27 +0200 |
| commit | 5551695f3b0d10c9a22cfacdb10c2cf7bd572421 (patch) | |
| tree | 282611eacf1fd4c38d54d5cea87decdf2b1cbdb7 /internal/mcp/server.go | |
| parent | ec745129258ae800065e302a2a40b54488cbca08 (diff) | |
Add MCP server implementation with comprehensive test coverage
Implements a full Model Context Protocol (MCP) server for managing and serving prompts
to LLM applications. The server provides CRUD operations for prompts with automatic
backups and template rendering support.
Key additions:
- cmd/hexai-mcp-server: Main MCP server binary entrypoint
- internal/hexaimcp: Server orchestrator with configuration and setup
- internal/mcp: Core MCP protocol implementation (JSON-RPC 2.0)
- internal/promptstore: Prompt storage with JSONL backend and automatic backups
- Comprehensive test suites achieving 80%+ coverage for all MCP packages
- Magefile targets for building and installing the MCP server
- Complete documentation for setup, API, prompts, and backups
Test coverage:
- internal/hexaimcp: 84.3%
- internal/mcp: 80.3%
- internal/promptstore: 81.2%
- Overall project: 81.5%
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Diffstat (limited to 'internal/mcp/server.go')
| -rw-r--r-- | internal/mcp/server.go | 494 |
1 files changed, 494 insertions, 0 deletions
diff --git a/internal/mcp/server.go b/internal/mcp/server.go new file mode 100644 index 0000000..f6479d9 --- /dev/null +++ b/internal/mcp/server.go @@ -0,0 +1,494 @@ +// Summary: MCP server over stdio; manages prompt store, dispatches requests, and handles protocol. +package mcp + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "log" + "strings" + "sync" + "time" + + "codeberg.org/snonux/hexai/internal" + "codeberg.org/snonux/hexai/internal/promptstore" +) + +// Server implements an MCP server over stdio using JSON-RPC 2.0. +// Follows the same pattern as the LSP server with dispatch table and thread safety. +type Server struct { + in *bufio.Reader + out io.Writer + outMu sync.Mutex + logger *log.Logger + store promptstore.PromptStore + initialized bool + mu sync.RWMutex + + // Dispatch table for JSON-RPC methods + handlers map[string]func(Request) +} + +// NewServer creates a new MCP server with the given store and I/O streams. +// The store provides access to prompts; logger is used for debugging. +func NewServer(r io.Reader, w io.Writer, logger *log.Logger, store promptstore.PromptStore) *Server { + s := &Server{ + in: bufio.NewReader(r), + out: w, + logger: logger, + store: store, + } + + // Initialize dispatch table + s.handlers = map[string]func(Request){ + "initialize": s.handleInitialize, + "initialized": s.handleInitialized, + "prompts/list": s.handlePromptsList, + "prompts/get": s.handlePromptsGet, + "prompts/create": s.handlePromptsCreate, + "prompts/update": s.handlePromptsUpdate, + "prompts/delete": s.handlePromptsDelete, + "notifications/initialized": s.handleInitialized, + } + + return s +} + +// Run starts the server main loop, reading and dispatching requests. +// Returns on EOF or fatal error. +func (s *Server) Run() error { + for { + body, err := s.readMessage() + if err == io.EOF { + return nil + } + if err != nil { + return fmt.Errorf("read message: %w", err) + } + + var req Request + if err := json.Unmarshal(body, &req); err != nil { + s.logger.Printf("invalid JSON: %v", err) + s.sendError(nil, ErrCodeParseError, "Parse error") + continue + } + + if req.Method == "" { + // Response from client; ignore + continue + } + + // Dispatch request + go s.handle(req) + } +} + +// handle dispatches a request to the appropriate handler. +func (s *Server) handle(req Request) { + handler, ok := s.handlers[req.Method] + if !ok { + s.logger.Printf("method not found: %s", req.Method) + s.sendError(req.ID, ErrCodeMethodNotFound, fmt.Sprintf("Method not found: %s", req.Method)) + return + } + + handler(req) +} + +// handleInitialize processes the initialize request and returns server capabilities. +func (s *Server) handleInitialize(req Request) { + var params InitializeRequest + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + s.sendError(req.ID, ErrCodeInvalidParams, "Invalid initialize params") + return + } + + s.logger.Printf("initialize from client: %s %s (protocol: %s)", + params.ClientInfo.Name, params.ClientInfo.Version, params.ProtocolVersion) + + // Validate protocol version (accept both old and new versions for compatibility) + if params.ProtocolVersion != "2024-11-05" && params.ProtocolVersion != "2025-06-18" { + s.logger.Printf("warning: unsupported protocol version: %s", params.ProtocolVersion) + } + + result := InitializeResult{ + ProtocolVersion: "2025-06-18", + Capabilities: ServerCapabilities{ + Prompts: &PromptsCapability{ + ListChanged: false, + Mutable: true, // Advertise that we support create/update/delete + }, + }, + ServerInfo: ServerInfo{ + Name: "hexai-mcp-server", + Version: internal.Version, + }, + } + + s.mu.Lock() + s.initialized = true + s.mu.Unlock() + + s.sendResponse(req.ID, result) +} + +// handleInitialized processes the initialized notification. +// This is sent by the client after receiving initialize response. +func (s *Server) handleInitialized(_ Request) { + s.logger.Printf("client sent initialized notification") + // 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 + var infos []PromptInfo + 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{ + JSONRPC: "2.0", + ID: id, + Result: result, + } + if err := s.writeMessage(resp); err != nil { + s.logger.Printf("write response error: %v", err) + } +} + +// sendError sends an error JSON-RPC response. +func (s *Server) sendError(id any, code int, message string) { + resp := Response{ + JSONRPC: "2.0", + ID: id, + Error: &RespError{ + Code: code, + Message: message, + }, + } + if err := s.writeMessage(resp); err != nil { + s.logger.Printf("write error response error: %v", err) + } +} + +// 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 params.Name == "" { + s.sendError(req.ID, ErrCodeInvalidParams, "Prompt name is required") + return + } + if params.Title == "" { + s.sendError(req.ID, ErrCodeInvalidParams, "Prompt title is required") + return + } + if len(params.Messages) == 0 { + s.sendError(req.ID, ErrCodeInvalidParams, "At least one message is required") + return + } + + // Create 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, + }, + }) + } + + 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) + s.sendResponse(req.ID, result) +} + +// 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 + } + + // 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() + + 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) + s.sendResponse(req.ID, result) +} + +// 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) + s.sendResponse(req.ID, result) +} |
