New MCP servers added to the gateway stack: - memory-bank-mcp (port 8700): Persistent key-value memory storage with tags, categories, and search - puppeteer-mcp (port 8800): Headless browser automation via Pyppeteer (navigate, screenshot, click, JS eval, PDF gen) - sequential-thinking-mcp (port 8900): Structured step-by-step reasoning with branching hypotheses and synthesis - docker-mcp (port 9000): Docker container/image/network/volume management via Docker socket All servers follow the existing Python/FastMCP pattern with streamable-http transport. docker-compose.yml updated with service definitions and gateway backend routes.
353 lines
9.6 KiB
Python
Executable file
353 lines
9.6 KiB
Python
Executable file
"""
|
|
Memory Bank MCP Server
|
|
======================
|
|
MCP server providing persistent memory storage for LLM conversations.
|
|
Stores and retrieves key-value memories with metadata, tags, and
|
|
semantic search capabilities. Backed by a local JSON file store.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import time
|
|
import hashlib
|
|
from pathlib import Path
|
|
from typing import Optional, List, Any, Dict
|
|
from datetime import datetime, timezone
|
|
|
|
from mcp.server.fastmcp import FastMCP
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Configuration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
MEMORY_DIR = os.environ.get("MEMORY_DIR", "/data/memories")
|
|
MAX_MEMORIES = int(os.environ.get("MAX_MEMORIES", "10000"))
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# MCP Server
|
|
# ---------------------------------------------------------------------------
|
|
|
|
mcp = FastMCP("memory_bank_mcp")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Storage helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _ensure_dir():
|
|
"""Ensure the memory directory exists."""
|
|
Path(MEMORY_DIR).mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
def _index_path() -> str:
|
|
return os.path.join(MEMORY_DIR, "_index.json")
|
|
|
|
|
|
def _load_index() -> Dict[str, Any]:
|
|
"""Load the memory index."""
|
|
_ensure_dir()
|
|
idx_path = _index_path()
|
|
if os.path.exists(idx_path):
|
|
with open(idx_path, "r") as f:
|
|
return json.load(f)
|
|
return {"memories": {}, "tags": {}}
|
|
|
|
|
|
def _save_index(index: Dict[str, Any]):
|
|
"""Save the memory index."""
|
|
_ensure_dir()
|
|
with open(_index_path(), "w") as f:
|
|
json.dump(index, f, indent=2)
|
|
|
|
|
|
def _memory_path(memory_id: str) -> str:
|
|
return os.path.join(MEMORY_DIR, f"{memory_id}.json")
|
|
|
|
|
|
def _generate_id(content: str) -> str:
|
|
"""Generate a deterministic ID from content."""
|
|
return hashlib.sha256(content.encode()).hexdigest()[:16]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tools
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@mcp.tool()
|
|
async def store_memory(
|
|
key: str,
|
|
content: str,
|
|
tags: Optional[List[str]] = None,
|
|
category: Optional[str] = None,
|
|
metadata: Optional[Dict[str, Any]] = None,
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Store a memory with a key, content, optional tags, category, and metadata.
|
|
If a memory with the same key exists, it will be updated.
|
|
|
|
Args:
|
|
key: Unique key/name for this memory
|
|
content: The content to remember
|
|
tags: Optional list of tags for categorization
|
|
category: Optional category (e.g., 'user_preference', 'fact', 'context')
|
|
metadata: Optional additional metadata dict
|
|
"""
|
|
index = _load_index()
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
|
|
memory = {
|
|
"key": key,
|
|
"content": content,
|
|
"tags": tags or [],
|
|
"category": category or "general",
|
|
"metadata": metadata or {},
|
|
"created_at": now,
|
|
"updated_at": now,
|
|
"access_count": 0,
|
|
}
|
|
|
|
# Update if exists
|
|
if key in index["memories"]:
|
|
existing = _load_memory_file(key)
|
|
if existing:
|
|
memory["created_at"] = existing.get("created_at", now)
|
|
memory["access_count"] = existing.get("access_count", 0)
|
|
|
|
# Save memory file
|
|
_ensure_dir()
|
|
with open(_memory_path(key), "w") as f:
|
|
json.dump(memory, f, indent=2)
|
|
|
|
# Update index
|
|
index["memories"][key] = {
|
|
"category": memory["category"],
|
|
"tags": memory["tags"],
|
|
"updated_at": now,
|
|
}
|
|
|
|
# Update tag index
|
|
for tag in memory["tags"]:
|
|
if tag not in index["tags"]:
|
|
index["tags"][tag] = []
|
|
if key not in index["tags"][tag]:
|
|
index["tags"][tag].append(key)
|
|
|
|
_save_index(index)
|
|
|
|
return {"status": "stored", "key": key, "updated_at": now}
|
|
|
|
|
|
def _load_memory_file(key: str) -> Optional[Dict[str, Any]]:
|
|
"""Load a memory file by key."""
|
|
path = _memory_path(key)
|
|
if os.path.exists(path):
|
|
with open(path, "r") as f:
|
|
return json.load(f)
|
|
return None
|
|
|
|
|
|
@mcp.tool()
|
|
async def recall_memory(key: str) -> Dict[str, Any]:
|
|
"""
|
|
Retrieve a specific memory by its key.
|
|
|
|
Args:
|
|
key: The key of the memory to retrieve
|
|
"""
|
|
memory = _load_memory_file(key)
|
|
if not memory:
|
|
return {"error": f"Memory '{key}' not found"}
|
|
|
|
# Update access count
|
|
memory["access_count"] = memory.get("access_count", 0) + 1
|
|
memory["last_accessed"] = datetime.now(timezone.utc).isoformat()
|
|
with open(_memory_path(key), "w") as f:
|
|
json.dump(memory, f, indent=2)
|
|
|
|
return memory
|
|
|
|
|
|
@mcp.tool()
|
|
async def search_memories(
|
|
query: Optional[str] = None,
|
|
tags: Optional[List[str]] = None,
|
|
category: Optional[str] = None,
|
|
limit: int = 20,
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Search memories by text query, tags, or category.
|
|
|
|
Args:
|
|
query: Optional text to search for in memory content and keys
|
|
tags: Optional list of tags to filter by (memories must have ALL tags)
|
|
category: Optional category to filter by
|
|
limit: Maximum results to return (default 20)
|
|
"""
|
|
index = _load_index()
|
|
results = []
|
|
|
|
# Get candidate keys
|
|
candidate_keys = set(index["memories"].keys())
|
|
|
|
# Filter by tags
|
|
if tags:
|
|
for tag in tags:
|
|
tag_keys = set(index["tags"].get(tag, []))
|
|
candidate_keys &= tag_keys
|
|
|
|
# Filter by category
|
|
if category:
|
|
candidate_keys = {
|
|
k for k in candidate_keys
|
|
if index["memories"].get(k, {}).get("category") == category
|
|
}
|
|
|
|
# Load and search
|
|
for key in candidate_keys:
|
|
memory = _load_memory_file(key)
|
|
if not memory:
|
|
continue
|
|
|
|
if query:
|
|
query_lower = query.lower()
|
|
if (
|
|
query_lower not in memory.get("content", "").lower()
|
|
and query_lower not in memory.get("key", "").lower()
|
|
and not any(query_lower in t.lower() for t in memory.get("tags", []))
|
|
):
|
|
continue
|
|
|
|
results.append({
|
|
"key": memory["key"],
|
|
"content": memory["content"][:200] + ("..." if len(memory.get("content", "")) > 200 else ""),
|
|
"category": memory.get("category"),
|
|
"tags": memory.get("tags", []),
|
|
"updated_at": memory.get("updated_at"),
|
|
"access_count": memory.get("access_count", 0),
|
|
})
|
|
|
|
# Sort by most recently updated
|
|
results.sort(key=lambda x: x.get("updated_at", ""), reverse=True)
|
|
|
|
return {
|
|
"total": len(results),
|
|
"results": results[:limit],
|
|
}
|
|
|
|
|
|
@mcp.tool()
|
|
async def delete_memory(key: str) -> Dict[str, Any]:
|
|
"""
|
|
Delete a specific memory by key.
|
|
|
|
Args:
|
|
key: The key of the memory to delete
|
|
"""
|
|
index = _load_index()
|
|
|
|
if key not in index["memories"]:
|
|
return {"error": f"Memory '{key}' not found"}
|
|
|
|
# Remove from tag index
|
|
mem_info = index["memories"][key]
|
|
for tag in mem_info.get("tags", []):
|
|
if tag in index["tags"] and key in index["tags"][tag]:
|
|
index["tags"][tag].remove(key)
|
|
if not index["tags"][tag]:
|
|
del index["tags"][tag]
|
|
|
|
# Remove from index
|
|
del index["memories"][key]
|
|
_save_index(index)
|
|
|
|
# Remove file
|
|
path = _memory_path(key)
|
|
if os.path.exists(path):
|
|
os.remove(path)
|
|
|
|
return {"status": "deleted", "key": key}
|
|
|
|
|
|
@mcp.tool()
|
|
async def list_memories(
|
|
category: Optional[str] = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
List all stored memories with optional category filter.
|
|
|
|
Args:
|
|
category: Optional category to filter by
|
|
limit: Maximum results (default 50)
|
|
offset: Skip first N results (default 0)
|
|
"""
|
|
index = _load_index()
|
|
memories = []
|
|
|
|
for key, info in index["memories"].items():
|
|
if category and info.get("category") != category:
|
|
continue
|
|
memories.append({
|
|
"key": key,
|
|
"category": info.get("category"),
|
|
"tags": info.get("tags", []),
|
|
"updated_at": info.get("updated_at"),
|
|
})
|
|
|
|
memories.sort(key=lambda x: x.get("updated_at", ""), reverse=True)
|
|
total = len(memories)
|
|
|
|
return {
|
|
"total": total,
|
|
"offset": offset,
|
|
"limit": limit,
|
|
"memories": memories[offset:offset + limit],
|
|
}
|
|
|
|
|
|
@mcp.tool()
|
|
async def get_memory_stats() -> Dict[str, Any]:
|
|
"""Get statistics about the memory bank."""
|
|
index = _load_index()
|
|
|
|
categories = {}
|
|
for info in index["memories"].values():
|
|
cat = info.get("category", "general")
|
|
categories[cat] = categories.get(cat, 0) + 1
|
|
|
|
return {
|
|
"total_memories": len(index["memories"]),
|
|
"total_tags": len(index["tags"]),
|
|
"categories": categories,
|
|
"max_memories": MAX_MEMORIES,
|
|
}
|
|
|
|
|
|
@mcp.tool()
|
|
async def bulk_store_memories(
|
|
memories: List[Dict[str, Any]],
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Store multiple memories at once.
|
|
|
|
Args:
|
|
memories: List of memory objects, each with 'key', 'content', and optional 'tags', 'category', 'metadata'
|
|
"""
|
|
results = []
|
|
for mem in memories:
|
|
result = await store_memory(
|
|
key=mem["key"],
|
|
content=mem["content"],
|
|
tags=mem.get("tags"),
|
|
category=mem.get("category"),
|
|
metadata=mem.get("metadata"),
|
|
)
|
|
results.append(result)
|
|
|
|
return {
|
|
"status": "bulk_stored",
|
|
"count": len(results),
|
|
"results": results,
|
|
}
|