Basic implementation
This commit is contained in:
parent
14fa650783
commit
5b85423b45
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -0,0 +1 @@
|
|||
memory-mcp
|
71
README.md
Normal file
71
README.md
Normal 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
13
go.mod
Normal 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
26
go.sum
Normal 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
289
main.go
Normal 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
102
memory_manager_test.go
Normal 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")
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue