From 5b85423b452a414c806598716fc675d9764c6213 Mon Sep 17 00:00:00 2001 From: Tim Jagenberg Date: Wed, 14 May 2025 22:12:57 +0200 Subject: [PATCH] Basic implementation --- .gitignore | 1 + README.md | 71 ++++++++++ go.mod | 13 ++ go.sum | 26 ++++ main.go | 289 +++++++++++++++++++++++++++++++++++++++++ memory_manager_test.go | 102 +++++++++++++++ 6 files changed, 502 insertions(+) create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 memory_manager_test.go diff --git a/.gitignore b/.gitignore index e69de29..c3b46bc 100644 --- a/.gitignore +++ b/.gitignore @@ -0,0 +1 @@ +memory-mcp diff --git a/README.md b/README.md new file mode 100644 index 0000000..030a398 --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# Memory MCP - Fact Management System + +A command-line application that runs an MCP (Model Context Protocol) server for managing and retrieving facts with associated keywords. + +## Features + +- Store facts with associated keywords in a simple line-based format +- List all stored facts with their keywords +- Get a list of all unique keywords +- Find facts by specific keywords + +## Installation + +1. Ensure you have Go installed (version 1.21 or later recommended) +2. Clone this repository +3. Build the application: + +```bash +go build -o memory-mcp +``` + +## Usage + +1. Start the MCP server: + +```bash +./memory-mcp +``` + +2. Connect to the server using any MCP client or integrate with LLMs that support the MCP protocol. + +## Tools Available + +The server provides the following tools: + +### 1. Add Memory + +Add a new fact with associated keywords. + +Parameters: +- `fact`: The fact to remember (cannot contain semicolons) +- `keywords`: Comma-separated list of keywords + +### 2. List All Memories + +List all stored facts with their keywords. + +### 3. List Unique Keywords + +List all unique keywords used across all memories. + +### 4. Find By Keyword + +Find all facts associated with a specific keyword. + +Parameters: +- `keyword`: Keyword to search for + +## Memory Storage + +Facts are stored in a simple text file located at `$XDG_STATE_HOME/memories.txt` or `~/.local/state/memories.txt` if the environment variable is not set. + +Each memory is stored in the format: +``` +fact;keyword1,keyword2,keyword3,... +``` + +## Limitations + +- Facts cannot contain semicolons (`;`) as this character is used as a separator +- The MCP server currently only supports stdio communication \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..76ae98e --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module memory-mcp + +go 1.23 + +toolchain go1.24.3 + +require github.com/mark3labs/mcp-go v0.27.0 + +require ( + github.com/google/uuid v1.6.0 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fee4cab --- /dev/null +++ b/go.sum @@ -0,0 +1,26 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mark3labs/mcp-go v0.27.0 h1:iok9kU4DUIU2/XVLgFS2Q9biIDqstC0jY4EQTK2Erzc= +github.com/mark3labs/mcp-go v0.27.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..41829c6 --- /dev/null +++ b/main.go @@ -0,0 +1,289 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// MemoryManager handles storage and retrieval of facts with keywords +type MemoryManager struct { + filePath string +} + +// Memory represents a fact with associated keywords +type Memory struct { + Fact string + Keywords []string +} + +// NewMemoryManager creates a new memory manager +func NewMemoryManager() (*MemoryManager, error) { + // Get XDG_STATE_HOME or use default + stateHome := os.Getenv("XDG_STATE_HOME") + if stateHome == "" { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("failed to get home directory: %w", err) + } + stateHome = filepath.Join(homeDir, ".local", "state") + } + + // Create directory if it doesn't exist + err := os.MkdirAll(stateHome, 0755) + if err != nil { + return nil, fmt.Errorf("failed to create state directory: %w", err) + } + + return &MemoryManager{ + filePath: filepath.Join(stateHome, "memories.txt"), + }, nil +} + +// AddMemory adds a new memory +func (m *MemoryManager) AddMemory(fact string, keywords []string) error { + if strings.Contains(fact, ";") { + return errors.New("fact cannot contain semicolon (;)") + } + + // Format: fact;keyword1,keyword2,... + line := fact + ";" + strings.Join(keywords, ",") + "\n" + + // Open file in append mode + file, err := os.OpenFile(m.filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("failed to open memory file: %w", err) + } + defer file.Close() + + _, err = file.WriteString(line) + if err != nil { + return fmt.Errorf("failed to write memory: %w", err) + } + + return nil +} + +// GetAllMemories returns all stored memories +func (m *MemoryManager) GetAllMemories() ([]Memory, error) { + return m.loadMemories() +} + +// GetUniqueKeywords returns a list of all unique keywords +func (m *MemoryManager) GetUniqueKeywords() ([]string, error) { + memories, err := m.loadMemories() + if err != nil { + return nil, err + } + + // Use a map to track unique keywords + keywordMap := make(map[string]bool) + for _, memory := range memories { + for _, keyword := range memory.Keywords { + keywordMap[keyword] = true + } + } + + // Convert map keys to slice + keywords := make([]string, 0, len(keywordMap)) + for keyword := range keywordMap { + keywords = append(keywords, keyword) + } + + return keywords, nil +} + +// GetMemoriesByKeyword returns all memories that have the given keyword +func (m *MemoryManager) GetMemoriesByKeyword(keyword string) ([]Memory, error) { + memories, err := m.loadMemories() + if err != nil { + return nil, err + } + + // Filter memories by keyword + var filtered []Memory + for _, memory := range memories { + for _, k := range memory.Keywords { + if k == keyword { + filtered = append(filtered, memory) + break + } + } + } + + return filtered, nil +} + +// loadMemories loads all memories from the file +func (m *MemoryManager) loadMemories() ([]Memory, error) { + // Check if file exists + if _, err := os.Stat(m.filePath); os.IsNotExist(err) { + // Return empty slice if file doesn't exist + return []Memory{}, nil + } + + // Read file content + content, err := os.ReadFile(m.filePath) + if err != nil { + return nil, fmt.Errorf("failed to read memory file: %w", err) + } + + var memories []Memory + lines := strings.Split(string(content), "\n") + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + parts := strings.Split(line, ";") + if len(parts) != 2 { + continue // Skip invalid lines + } + + fact := parts[0] + keywords := strings.Split(parts[1], ",") + + // Filter out empty keywords + var cleanKeywords []string + for _, k := range keywords { + k = strings.TrimSpace(k) + if k != "" { + cleanKeywords = append(cleanKeywords, k) + } + } + + memories = append(memories, Memory{ + Fact: fact, + Keywords: cleanKeywords, + }) + } + + return memories, nil +} + +func main() { + fmt.Println("Memory MCP - Management Control Program") + + // Create memory manager + memoryManager, err := NewMemoryManager() + if err != nil { + fmt.Printf("Error initializing memory manager: %v\n", err) + os.Exit(1) + } + + // Create MCP server + s := server.NewMCPServer( + "Memory Manager", + "1.0.0", + server.WithToolCapabilities(true), + server.WithRecovery(), + ) + + // Tool to add a new memory + addMemoryTool := mcp.NewTool("add_memory", + mcp.WithDescription("Add a new fact with associated keywords"), + mcp.WithString("fact", mcp.Required(), mcp.Description("The fact to remember (cannot contain semicolons)")), + mcp.WithString("keywords", mcp.Required(), mcp.Description("Comma-separated list of keywords")), + ) + + s.AddTool(addMemoryTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + fact := request.Params.Arguments["fact"].(string) + keywordsStr := request.Params.Arguments["keywords"].(string) + + keywords := strings.Split(keywordsStr, ",") + for i, k := range keywords { + keywords[i] = strings.TrimSpace(k) + } + + err := memoryManager.AddMemory(fact, keywords) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to add memory: %v", err)), nil + } + + return mcp.NewToolResultText(fmt.Sprintf("Memory added: %s", fact)), nil + }) + + // Tool to list all memories + listMemoriesTool := mcp.NewTool("list_all_memories", + mcp.WithDescription("List all stored facts with their keywords"), + ) + + s.AddTool(listMemoriesTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + memories, err := memoryManager.GetAllMemories() + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to retrieve memories: %v", err)), nil + } + + if len(memories) == 0 { + return mcp.NewToolResultText("No memories stored yet."), nil + } + + var result strings.Builder + for i, memory := range memories { + result.WriteString(fmt.Sprintf("%d. Fact: %s\n Keywords: %s\n", + i+1, memory.Fact, strings.Join(memory.Keywords, ", "))) + } + + return mcp.NewToolResultText(result.String()), nil + }) + + // Tool to list unique keywords + listKeywordsTool := mcp.NewTool("list_unique_keywords", + mcp.WithDescription("List all unique keywords used across all memories"), + ) + + s.AddTool(listKeywordsTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + keywords, err := memoryManager.GetUniqueKeywords() + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to retrieve keywords: %v", err)), nil + } + + if len(keywords) == 0 { + return mcp.NewToolResultText("No keywords found."), nil + } + + return mcp.NewToolResultText(fmt.Sprintf("Unique keywords: %s", strings.Join(keywords, ", "))), nil + }) + + // Tool to find memories by keyword + findByKeywordTool := mcp.NewTool("find_by_keyword", + mcp.WithDescription("Find all facts associated with a specific keyword"), + mcp.WithString("keyword", mcp.Required(), mcp.Description("Keyword to search for")), + ) + + s.AddTool(findByKeywordTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + keyword := request.Params.Arguments["keyword"].(string) + memories, err := memoryManager.GetMemoriesByKeyword(keyword) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to search memories: %v", err)), nil + } + + if len(memories) == 0 { + return mcp.NewToolResultText(fmt.Sprintf("No memories found with keyword '%s'.", keyword)), nil + } + + var result strings.Builder + result.WriteString(fmt.Sprintf("Found %d memories with keyword '%s':\n\n", len(memories), keyword)) + + for i, memory := range memories { + result.WriteString(fmt.Sprintf("%d. Fact: %s\n Keywords: %s\n", + i+1, memory.Fact, strings.Join(memory.Keywords, ", "))) + } + + return mcp.NewToolResultText(result.String()), nil + }) + + // Start the server + fmt.Println("Starting Memory Manager MCP server...") + if err := server.ServeStdio(s); err != nil { + fmt.Printf("Server error: %v\n", err) + } +} diff --git a/memory_manager_test.go b/memory_manager_test.go new file mode 100644 index 0000000..bd57ffe --- /dev/null +++ b/memory_manager_test.go @@ -0,0 +1,102 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestMemoryManager(t *testing.T) { + // Create a temporary directory for testing + tmpDir, err := os.MkdirTemp("", "memory-test") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Set XDG_STATE_HOME to our temp directory + oldStateHome := os.Getenv("XDG_STATE_HOME") + os.Setenv("XDG_STATE_HOME", tmpDir) + defer os.Setenv("XDG_STATE_HOME", oldStateHome) + + // Create memory manager + memoryManager, err := NewMemoryManager() + if err != nil { + t.Fatalf("Failed to create memory manager: %v", err) + } + + // Test adding memories + testData := []struct { + fact string + keywords []string + }{ + {"The sky is blue", []string{"sky", "blue", "nature"}}, + {"Cats are mammals", []string{"cats", "mammals", "animals"}}, + {"Water freezes at 0°C", []string{"water", "ice", "temperature", "science"}}, + } + + for _, data := range testData { + err := memoryManager.AddMemory(data.fact, data.keywords) + if err != nil { + t.Errorf("Failed to add memory '%s': %v", data.fact, err) + } + } + + // Test retrieving all memories + memories, err := memoryManager.GetAllMemories() + if err != nil { + t.Fatalf("Failed to retrieve memories: %v", err) + } + + if len(memories) != len(testData) { + t.Errorf("Expected %d memories, got %d", len(testData), len(memories)) + } + + // Test retrieving unique keywords + keywords, err := memoryManager.GetUniqueKeywords() + if err != nil { + t.Fatalf("Failed to retrieve keywords: %v", err) + } + + // Expected number of unique keywords + expectedKeywords := map[string]bool{ + "sky": true, "blue": true, "nature": true, + "cats": true, "mammals": true, "animals": true, + "water": true, "ice": true, "temperature": true, "science": true, + } + + if len(keywords) != len(expectedKeywords) { + t.Errorf("Expected %d unique keywords, got %d", len(expectedKeywords), len(keywords)) + } + + // Test retrieving memories by keyword + testKeyword := "science" + byKeyword, err := memoryManager.GetMemoriesByKeyword(testKeyword) + if err != nil { + t.Fatalf("Failed to retrieve memories by keyword: %v", err) + } + + if len(byKeyword) != 1 { + t.Errorf("Expected 1 memory with keyword '%s', got %d", testKeyword, len(byKeyword)) + } + + if len(byKeyword) > 0 && byKeyword[0].Fact != "Water freezes at 0°C" { + t.Errorf("Expected to find 'Water freezes at 0°C' with keyword '%s', got '%s'", + testKeyword, byKeyword[0].Fact) + } + + // Verify the file is created and contains the correct data + filePath := filepath.Join(tmpDir, "memories.txt") + if _, err := os.Stat(filePath); os.IsNotExist(err) { + t.Errorf("Memory file was not created") + } + + content, err := os.ReadFile(filePath) + if err != nil { + t.Fatalf("Failed to read memory file: %v", err) + } + + if len(content) == 0 { + t.Errorf("Memory file is empty") + } +} \ No newline at end of file