Basic implementation

This commit is contained in:
Tim Jagenberg 2025-05-14 22:12:57 +02:00
parent 14fa650783
commit 5b85423b45
6 changed files with 502 additions and 0 deletions

1
.gitignore vendored
View file

@ -0,0 +1 @@
memory-mcp

71
README.md Normal file
View file

@ -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

13
go.mod Normal file
View file

@ -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
)

26
go.sum Normal file
View file

@ -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=

289
main.go Normal file
View file

@ -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)
}
}

102
memory_manager_test.go Normal file
View file

@ -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")
}
}