summaryrefslogtreecommitdiff
path: root/internal/promptstore/store.go
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-10 19:28:27 +0200
committerPaul Buetow <paul@buetow.org>2026-02-10 19:28:27 +0200
commit5551695f3b0d10c9a22cfacdb10c2cf7bd572421 (patch)
tree282611eacf1fd4c38d54d5cea87decdf2b1cbdb7 /internal/promptstore/store.go
parentec745129258ae800065e302a2a40b54488cbca08 (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/promptstore/store.go')
-rw-r--r--internal/promptstore/store.go547
1 files changed, 547 insertions, 0 deletions
diff --git a/internal/promptstore/store.go b/internal/promptstore/store.go
new file mode 100644
index 0000000..c1fcb9f
--- /dev/null
+++ b/internal/promptstore/store.go
@@ -0,0 +1,547 @@
+// Summary: Prompt storage interface and JSONL-based implementation.
+package promptstore
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+)
+
+// PromptStore defines the interface for prompt storage operations.
+// Allows easy mocking in tests.
+type PromptStore interface {
+ // List returns prompts with pagination support.
+ // cursor is the pagination token (empty for first page).
+ // limit is the max prompts to return per page.
+ List(cursor string, limit int) ([]Prompt, string, error)
+
+ // Get retrieves a prompt by name.
+ Get(name string) (*Prompt, error)
+
+ // Create adds a new prompt.
+ Create(prompt *Prompt) error
+
+ // Update modifies an existing prompt.
+ Update(prompt *Prompt) error
+
+ // Delete removes a prompt by name.
+ Delete(name string) error
+
+ // SearchByTags finds prompts matching all given tags (AND logic).
+ SearchByTags(tags []string) ([]Prompt, error)
+}
+
+// JSONLStore is a file-based prompt store using JSONL format.
+// Stores prompts in multiple JSONL files (default.jsonl for built-ins, user.jsonl for custom).
+// Automatically creates backups before any write operation.
+type JSONLStore struct {
+ dataDir string
+ mu sync.RWMutex
+
+ // File operation functions (can be mocked for testing)
+ readFileFn func(string) ([]byte, error)
+ writeFileFn func(string, []byte, os.FileMode) error
+
+ // Backup settings
+ maxBackups int // Maximum number of backups to keep (0 = unlimited)
+}
+
+// NewJSONLStore creates a new JSONL-based prompt store.
+// dataDir should be an absolute path (e.g., ~/.local/share/hexai/prompts/).
+func NewJSONLStore(dataDir string) (PromptStore, error) {
+ // Ensure directory exists
+ if err := os.MkdirAll(dataDir, 0o755); err != nil {
+ return nil, fmt.Errorf("cannot create prompts directory: %w", err)
+ }
+
+ // Create backups subdirectory
+ backupDir := filepath.Join(dataDir, "backups")
+ if err := os.MkdirAll(backupDir, 0o755); err != nil {
+ return nil, fmt.Errorf("cannot create backups directory: %w", err)
+ }
+
+ store := &JSONLStore{
+ dataDir: dataDir,
+ readFileFn: os.ReadFile,
+ writeFileFn: os.WriteFile,
+ maxBackups: 10, // Keep last 10 backups
+ }
+
+ // Initialize default.jsonl with built-in prompts if it doesn't exist
+ defaultPath := filepath.Join(dataDir, "default.jsonl")
+ if _, err := os.Stat(defaultPath); os.IsNotExist(err) {
+ if err := store.writeBuiltinPrompts(); err != nil {
+ return nil, fmt.Errorf("cannot write built-in prompts: %w", err)
+ }
+ }
+
+ return store, nil
+}
+
+// writeBuiltinPrompts writes the built-in prompts to default.jsonl.
+func (s *JSONLStore) writeBuiltinPrompts() error {
+ prompts := GetBuiltinPrompts()
+ defaultPath := filepath.Join(s.dataDir, "default.jsonl")
+
+ var lines []byte
+ for _, p := range prompts {
+ data, err := json.Marshal(p)
+ if err != nil {
+ return fmt.Errorf("marshal built-in prompt: %w", err)
+ }
+ lines = append(lines, data...)
+ lines = append(lines, '\n')
+ }
+
+ if err := s.writeFileFn(defaultPath, lines, 0o644); err != nil {
+ return fmt.Errorf("write default.jsonl: %w", err)
+ }
+
+ return nil
+}
+
+// List returns prompts with pagination.
+// cursor format: "<file>:<offset>" where file is "default" or "user", offset is line number.
+func (s *JSONLStore) List(cursor string, limit int) ([]Prompt, string, error) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ if limit <= 0 {
+ limit = 100 // Default limit
+ }
+
+ // Load all prompts from both files
+ allPrompts, err := s.loadAllPrompts()
+ if err != nil {
+ return nil, "", err
+ }
+
+ // Sort by name for consistent ordering
+ sort.Slice(allPrompts, func(i, j int) bool {
+ return allPrompts[i].Name < allPrompts[j].Name
+ })
+
+ // Handle pagination
+ startIdx := 0
+ if cursor != "" {
+ // Simple cursor: index as string
+ fmt.Sscanf(cursor, "%d", &startIdx)
+ }
+
+ if startIdx >= len(allPrompts) {
+ return []Prompt{}, "", nil
+ }
+
+ endIdx := startIdx + limit
+ if endIdx > len(allPrompts) {
+ endIdx = len(allPrompts)
+ }
+
+ result := allPrompts[startIdx:endIdx]
+ nextCursor := ""
+ if endIdx < len(allPrompts) {
+ nextCursor = fmt.Sprintf("%d", endIdx)
+ }
+
+ return result, nextCursor, nil
+}
+
+// Get retrieves a prompt by name.
+func (s *JSONLStore) Get(name string) (*Prompt, error) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ allPrompts, err := s.loadAllPrompts()
+ if err != nil {
+ return nil, err
+ }
+
+ for _, p := range allPrompts {
+ if p.Name == name {
+ return &p, nil
+ }
+ }
+
+ return nil, fmt.Errorf("prompt not found: %s", name)
+}
+
+// Create adds a new prompt to user.jsonl.
+func (s *JSONLStore) Create(prompt *Prompt) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ // Backup before write
+ if err := s.backupUserPrompts(); err != nil {
+ return fmt.Errorf("backup failed: %w", err)
+ }
+
+ // Check if prompt already exists (use internal method to avoid deadlock)
+ allPrompts, err := s.loadAllPrompts()
+ if err != nil {
+ return fmt.Errorf("load prompts: %w", err)
+ }
+ for _, p := range allPrompts {
+ if p.Name == prompt.Name {
+ return fmt.Errorf("prompt already exists: %s", prompt.Name)
+ }
+ }
+
+ // Append to user.jsonl
+ userPath := filepath.Join(s.dataDir, "user.jsonl")
+ data, err := json.Marshal(prompt)
+ if err != nil {
+ return fmt.Errorf("marshal prompt: %w", err)
+ }
+ data = append(data, '\n')
+
+ f, err := os.OpenFile(userPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
+ if err != nil {
+ return fmt.Errorf("open user.jsonl: %w", err)
+ }
+ defer f.Close()
+
+ if _, err := f.Write(data); err != nil {
+ return fmt.Errorf("write user.jsonl: %w", err)
+ }
+
+ return nil
+}
+
+// Update modifies an existing prompt in user.jsonl.
+// Note: This rewrites the entire user.jsonl file.
+func (s *JSONLStore) Update(prompt *Prompt) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ // Backup before write
+ if err := s.backupUserPrompts(); err != nil {
+ return fmt.Errorf("backup failed: %w", err)
+ }
+
+ // Load user prompts
+ userPrompts, err := s.loadPromptsFromFile("user.jsonl")
+ if err != nil && !os.IsNotExist(err) {
+ return err
+ }
+
+ // Find and update prompt
+ found := false
+ for i, p := range userPrompts {
+ if p.Name == prompt.Name {
+ userPrompts[i] = *prompt
+ found = true
+ break
+ }
+ }
+
+ if !found {
+ return fmt.Errorf("prompt not found in user.jsonl: %s", prompt.Name)
+ }
+
+ // Rewrite user.jsonl
+ return s.writePromptsToFile("user.jsonl", userPrompts)
+}
+
+// Delete removes a prompt from user.jsonl.
+func (s *JSONLStore) Delete(name string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ // Backup before write
+ if err := s.backupUserPrompts(); err != nil {
+ return fmt.Errorf("backup failed: %w", err)
+ }
+
+ // Load user prompts
+ userPrompts, err := s.loadPromptsFromFile("user.jsonl")
+ if err != nil && !os.IsNotExist(err) {
+ return err
+ }
+
+ // Filter out deleted prompt
+ var filtered []Prompt
+ found := false
+ for _, p := range userPrompts {
+ if p.Name != name {
+ filtered = append(filtered, p)
+ } else {
+ found = true
+ }
+ }
+
+ if !found {
+ return fmt.Errorf("prompt not found: %s", name)
+ }
+
+ // Rewrite user.jsonl
+ return s.writePromptsToFile("user.jsonl", filtered)
+}
+
+// SearchByTags finds prompts matching all given tags.
+func (s *JSONLStore) SearchByTags(tags []string) ([]Prompt, error) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ allPrompts, err := s.loadAllPrompts()
+ if err != nil {
+ return nil, err
+ }
+
+ if len(tags) == 0 {
+ return allPrompts, nil
+ }
+
+ var results []Prompt
+ for _, p := range allPrompts {
+ if s.hasAllTags(p.Tags, tags) {
+ results = append(results, p)
+ }
+ }
+
+ return results, nil
+}
+
+// hasAllTags checks if promptTags contains all searchTags.
+func (s *JSONLStore) hasAllTags(promptTags, searchTags []string) bool {
+ tagSet := make(map[string]bool)
+ for _, t := range promptTags {
+ tagSet[t] = true
+ }
+
+ for _, t := range searchTags {
+ if !tagSet[t] {
+ return false
+ }
+ }
+ return true
+}
+
+// loadAllPrompts loads prompts from both default.jsonl and user.jsonl.
+func (s *JSONLStore) loadAllPrompts() ([]Prompt, error) {
+ defaultPrompts, err := s.loadPromptsFromFile("default.jsonl")
+ if err != nil && !os.IsNotExist(err) {
+ return nil, err
+ }
+
+ userPrompts, err := s.loadPromptsFromFile("user.jsonl")
+ if err != nil && !os.IsNotExist(err) {
+ return nil, err
+ }
+
+ // Merge (user prompts override built-ins by name)
+ promptMap := make(map[string]Prompt)
+ for _, p := range defaultPrompts {
+ promptMap[p.Name] = p
+ }
+ for _, p := range userPrompts {
+ promptMap[p.Name] = p
+ }
+
+ var all []Prompt
+ for _, p := range promptMap {
+ all = append(all, p)
+ }
+
+ return all, nil
+}
+
+// loadPromptsFromFile reads prompts from a JSONL file.
+func (s *JSONLStore) loadPromptsFromFile(filename string) ([]Prompt, error) {
+ path := filepath.Join(s.dataDir, filename)
+ data, err := s.readFileFn(path)
+ if err != nil {
+ return nil, err
+ }
+
+ var prompts []Prompt
+ lines := splitLines(data)
+ for i, line := range lines {
+ if len(line) == 0 {
+ continue
+ }
+
+ var p Prompt
+ if err := json.Unmarshal(line, &p); err != nil {
+ // Log error but continue parsing
+ fmt.Fprintf(os.Stderr, "warning: cannot parse prompt at %s:%d: %v\n", filename, i+1, err)
+ continue
+ }
+ prompts = append(prompts, p)
+ }
+
+ return prompts, nil
+}
+
+// writePromptsToFile writes prompts to a JSONL file.
+func (s *JSONLStore) writePromptsToFile(filename string, prompts []Prompt) error {
+ path := filepath.Join(s.dataDir, filename)
+
+ var lines []byte
+ for _, p := range prompts {
+ data, err := json.Marshal(p)
+ if err != nil {
+ return fmt.Errorf("marshal prompt: %w", err)
+ }
+ lines = append(lines, data...)
+ lines = append(lines, '\n')
+ }
+
+ if err := s.writeFileFn(path, lines, 0o644); err != nil {
+ return fmt.Errorf("write %s: %w", filename, err)
+ }
+
+ return nil
+}
+
+// splitLines splits data into lines (handles both \n and \r\n).
+// Copied from tmuxedit/history.go pattern.
+func splitLines(data []byte) [][]byte {
+ var lines [][]byte
+ start := 0
+ for i := 0; i < len(data); i++ {
+ if data[i] == '\n' {
+ end := i
+ if end > start && data[end-1] == '\r' {
+ end--
+ }
+ lines = append(lines, data[start:end])
+ start = i + 1
+ }
+ }
+ if start < len(data) {
+ lines = append(lines, data[start:])
+ }
+ return lines
+}
+
+// backupUserPrompts creates a timestamped backup of user.jsonl before any write operation.
+// Automatically manages backup retention based on maxBackups setting.
+func (s *JSONLStore) backupUserPrompts() error {
+ userPath := filepath.Join(s.dataDir, "user.jsonl")
+
+ // Check if user.jsonl exists
+ if _, err := os.Stat(userPath); os.IsNotExist(err) {
+ return nil // No file to backup
+ }
+
+ // Read current user.jsonl
+ data, err := s.readFileFn(userPath)
+ if err != nil {
+ return fmt.Errorf("read user.jsonl: %w", err)
+ }
+
+ // Create backup with timestamp
+ timestamp := time.Now().Format("20060102-150405")
+ backupDir := filepath.Join(s.dataDir, "backups")
+ backupPath := filepath.Join(backupDir, fmt.Sprintf("user.jsonl.%s", timestamp))
+
+ // Write backup
+ if err := s.writeFileFn(backupPath, data, 0o644); err != nil {
+ return fmt.Errorf("write backup: %w", err)
+ }
+
+ // Clean old backups if maxBackups is set
+ if s.maxBackups > 0 {
+ if err := s.cleanOldBackups(); err != nil {
+ // Log but don't fail - backup succeeded
+ fmt.Fprintf(os.Stderr, "warning: failed to clean old backups: %v\n", err)
+ }
+ }
+
+ return nil
+}
+
+// cleanOldBackups removes old backup files, keeping only the most recent maxBackups.
+func (s *JSONLStore) cleanOldBackups() error {
+ backupDir := filepath.Join(s.dataDir, "backups")
+
+ // List all backups
+ entries, err := os.ReadDir(backupDir)
+ if err != nil {
+ return fmt.Errorf("read backup dir: %w", err)
+ }
+
+ // Filter backup files
+ var backups []string
+ for _, entry := range entries {
+ if !entry.IsDir() && strings.HasPrefix(entry.Name(), "user.jsonl.") {
+ backups = append(backups, entry.Name())
+ }
+ }
+
+ // Sort backups (newest last due to timestamp format)
+ sort.Strings(backups)
+
+ // Remove old backups
+ if len(backups) > s.maxBackups {
+ toRemove := len(backups) - s.maxBackups
+ for i := 0; i < toRemove; i++ {
+ backupPath := filepath.Join(backupDir, backups[i])
+ if err := os.Remove(backupPath); err != nil {
+ return fmt.Errorf("remove backup %s: %w", backups[i], err)
+ }
+ }
+ }
+
+ return nil
+}
+
+// ListBackups returns a list of available backup files with timestamps.
+func (s *JSONLStore) ListBackups() ([]string, error) {
+ backupDir := filepath.Join(s.dataDir, "backups")
+
+ entries, err := os.ReadDir(backupDir)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return []string{}, nil
+ }
+ return nil, fmt.Errorf("read backup dir: %w", err)
+ }
+
+ var backups []string
+ for _, entry := range entries {
+ if !entry.IsDir() && strings.HasPrefix(entry.Name(), "user.jsonl.") {
+ backups = append(backups, entry.Name())
+ }
+ }
+
+ // Sort newest first
+ sort.Sort(sort.Reverse(sort.StringSlice(backups)))
+
+ return backups, nil
+}
+
+// RestoreBackup restores user.jsonl from a backup file.
+func (s *JSONLStore) RestoreBackup(backupName string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ backupPath := filepath.Join(s.dataDir, "backups", backupName)
+ userPath := filepath.Join(s.dataDir, "user.jsonl")
+
+ // Read backup
+ data, err := s.readFileFn(backupPath)
+ if err != nil {
+ return fmt.Errorf("read backup: %w", err)
+ }
+
+ // Create a safety backup of current state before restore
+ if _, err := os.Stat(userPath); err == nil {
+ timestamp := time.Now().Format("20060102-150405")
+ safetyBackup := filepath.Join(s.dataDir, "backups", fmt.Sprintf("user.jsonl.%s.pre-restore", timestamp))
+ currentData, _ := s.readFileFn(userPath)
+ s.writeFileFn(safetyBackup, currentData, 0o644)
+ }
+
+ // Restore from backup
+ if err := s.writeFileFn(userPath, data, 0o644); err != nil {
+ return fmt.Errorf("write restored user.jsonl: %w", err)
+ }
+
+ return nil
+}