merge mcp

This commit is contained in:
Nicolò Boschi 2025-11-20 19:16:22 +01:00
parent 99a54aec90
commit 536da41775
27 changed files with 531 additions and 1002 deletions

View file

@ -27,6 +27,8 @@ MEMORA_API_LLM_API_KEY=your_api_key_here
# MEMORA_API_HOST=0.0.0.0
# MEMORA_API_PORT=8080
MEMORA_API_MCP_ENABLED=true
# =============================================================================
# CONTROL PLANE SERVICE (MEMORA_CP_*)
# =============================================================================

View file

@ -278,6 +278,132 @@ class Memora:
response = _run_async(self._agent_api.create_or_update_agent(agent_id, request_obj))
return response.to_dict() if hasattr(response, 'to_dict') else response
# Async methods (native async, no _run_async wrapper)
async def aput_batch(
self,
agent_id: str,
items: List[Dict[str, Any]],
document_id: Optional[str] = None,
) -> Dict[str, Any]:
"""
Store multiple memories in batch (async).
Args:
agent_id: The agent ID
items: List of memory items with 'content' and optional 'event_date', 'context'
document_id: Optional document ID for grouping memories
Returns:
Response with success status and item count
"""
memory_items = [
memory_item.MemoryItem(
content=item["content"],
event_date=item.get("event_date"),
context=item.get("context"),
)
for item in items
]
request_obj = batch_put_request.BatchPutRequest(
items=memory_items,
document_id=document_id,
)
response = await self._memory_api.batch_put_memories(agent_id, request_obj)
return response.to_dict() if hasattr(response, 'to_dict') else response
async def aput(
self,
agent_id: str,
content: str,
event_date: Optional[datetime] = None,
context: Optional[str] = None,
document_id: Optional[str] = None,
) -> Dict[str, Any]:
"""
Store a single memory (async).
Args:
agent_id: The agent ID
content: Memory content
event_date: Optional event timestamp
context: Optional context description
document_id: Optional document ID for grouping
Returns:
Response with success status
"""
return await self.aput_batch(
agent_id=agent_id,
items=[{"content": content, "event_date": event_date, "context": context}],
document_id=document_id,
)
async def asearch(
self,
agent_id: str,
query: str,
fact_type: Optional[List[str]] = None,
max_tokens: int = 4096,
thinking_budget: int = 100,
) -> List[Dict[str, Any]]:
"""
Search memories using semantic similarity (async).
Args:
agent_id: The agent ID
query: Search query
fact_type: Optional list of fact types to filter (world, agent, opinion)
max_tokens: Maximum tokens in results (default: 4096)
thinking_budget: Token budget for search (default: 100)
Returns:
List of search results
"""
request_obj = search_request.SearchRequest(
query=query,
fact_type=fact_type,
thinking_budget=thinking_budget,
max_tokens=max_tokens,
trace=False,
)
response = await self._memory_api.search_memories(agent_id, request_obj)
if hasattr(response, 'results'):
return [r.to_dict() if hasattr(r, 'to_dict') else r for r in response.results]
return []
async def athink(
self,
agent_id: str,
query: str,
thinking_budget: int = 50,
context: Optional[str] = None,
) -> Dict[str, Any]:
"""
Generate a contextual answer based on agent identity and memories (async).
Args:
agent_id: The agent ID
query: The question or prompt
thinking_budget: Token budget for thinking (default: 50)
context: Optional additional context
Returns:
Response with answer text, facts used, and new opinions
"""
request_obj = think_request.ThinkRequest(
query=query,
thinking_budget=thinking_budget,
context=context,
)
response = await self._reasoning_api.think(agent_id, request_obj)
return response.to_dict() if hasattr(response, 'to_dict') else response
# Alias for backward compatibility
MemoraClient = Memora

View file

@ -1,44 +0,0 @@
# Memora MCP Server
Remote MCP server for integrating Memora memory capabilities with Claude Desktop and other MCP clients.
## Configuration
Required environment variables:
- `MEMORA_AGENT_ID`: The agent ID to use for all operations
- `MEMORA_API_URL`: Memora API endpoint (default: http://localhost:8080)
- `MEMORA_API_KEY`: API key for authentication (optional)
## Usage
### Start the HTTP/SSE Server
```bash
export MEMORA_AGENT_ID=your-agent-id
export MEMORA_API_URL=http://localhost:8080
export PORT=8765 # optional, default is 8765
export HOST=127.0.0.1 # optional, default is 127.0.0.1
uv run memora-mcp-server
```
The server will start on `http://127.0.0.1:8765`
### Claude Desktop Integration
Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS):
```json
{
"mcpServers": {
"memora": {
"url": "http://127.0.0.1:8765/sse"
}
}
}
```
Make sure the Memora MCP server is running before starting Claude Desktop.
## Available Tools
- `memora_put`: Store facts/memories with required context
- `memora_search`: Search through memories using semantic search

View file

@ -1,3 +0,0 @@
"""Memora MCP Server - Remote MCP server for Memora memory system."""
__version__ = "0.0.1"

View file

@ -1,62 +0,0 @@
"""Memora API client wrapper."""
import httpx
from typing import Any
class MemoraClient:
"""Client for interacting with Memora API."""
def __init__(self, api_url: str, agent_id: str, api_key: str | None = None):
self.api_url = api_url.rstrip("/")
self.agent_id = agent_id
self.headers = {}
if api_key:
self.headers["Authorization"] = f"Bearer {api_key}"
async def remember(
self, content: str, context: str
) -> dict[str, Any]:
"""Store a memory using batch endpoint."""
async with httpx.AsyncClient() as client:
payload = {
"agent_id": self.agent_id,
"items": [
{
"content": content,
"context": context,
}
]
}
response = await client.post(
f"{self.api_url}/api/memories/batch",
json=payload,
headers=self.headers,
timeout=30.0,
)
response.raise_for_status()
return response.json()
async def search(
self, query: str, max_tokens: int = 4096
) -> dict[str, Any]:
"""Search memories using search endpoint."""
async with httpx.AsyncClient() as client:
payload = {
"agent_id": self.agent_id,
"query": query,
"thinking_budget": 100,
"max_tokens": max_tokens,
"reranker": "heuristic",
"trace": False,
}
response = await client.post(
f"{self.api_url}/api/search",
json=payload,
headers=self.headers,
timeout=30.0,
)
response.raise_for_status()
return response.json()

View file

@ -1,26 +0,0 @@
"""Configuration management for Memora MCP Server."""
import os
from dataclasses import dataclass
@dataclass
class Config:
"""MCP Server configuration."""
agent_id: str
api_url: str = "http://localhost:8080"
api_key: str | None = None
@classmethod
def from_env(cls) -> "Config":
"""Load configuration from environment variables."""
agent_id = os.getenv("MEMORA_AGENT_ID")
if not agent_id:
raise ValueError("MEMORA_AGENT_ID environment variable is required")
return cls(
agent_id=agent_id,
api_url=os.getenv("MEMORA_API_URL", "http://localhost:8080"),
api_key=os.getenv("MEMORA_API_KEY"),
)

View file

@ -1,20 +0,0 @@
[project]
name = "memora-mcp-server"
version = "0.0.1"
description = "Remote MCP server for Memora memory system"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"fastmcp>=0.7.0",
"httpx>=0.28.1",
]
[project.scripts]
memora-mcp-server = "server:main"
[tool.hatch.build.targets.wheel]
packages = ["."]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

View file

@ -1,105 +0,0 @@
"""Memora MCP Server implementation using FastMCP."""
import json
import logging
import os
from fastmcp import FastMCP
from config import Config
from client import MemoraClient
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Load config
config = Config.from_env()
client = MemoraClient(
api_url=config.api_url,
agent_id=config.agent_id,
api_key=config.api_key,
)
# Create FastMCP server
mcp = FastMCP("memora-mcp-server")
@mcp.tool()
async def memora_put(content: str, context: str) -> str:
"""
**CRITICAL: Store important user information to long-term memory.**
Use this tool PROACTIVELY whenever the user shares:
- Personal facts, preferences, or interests (e.g., "I love hiking", "I'm a vegetarian")
- Important events or milestones (e.g., "I got promoted", "My birthday is June 15")
- User history, experiences, or background (e.g., "I used to work at Google", "I studied CS at MIT")
- Decisions, opinions, or stated preferences (e.g., "I prefer Python over JavaScript")
- Goals, plans, or future intentions (e.g., "I'm planning to visit Japan next year")
- Relationships or people mentioned (e.g., "My manager Sarah", "My wife Alice")
- Work context, projects, or responsibilities
- Any other information the user would want remembered for future conversations
**When to use**: Immediately after user shares personal information. Don't ask permission - just store it naturally.
**Context guidelines**: Use descriptive contexts like "personal_preferences", "work_history", "family", "hobbies",
"career_goals", "project_details", etc. This helps organize and retrieve related memories later.
Args:
content: The fact/memory to store (be specific and include relevant details)
context: Categorize the memory (e.g., 'personal_preferences', 'work_history', 'hobbies', 'family')
"""
try:
result = await client.remember(content=content, context=context)
return f"Fact stored successfully: {result.get('message', 'Success')}"
except Exception as e:
logger.error(f"Error storing fact: {e}", exc_info=True)
return f"Error: {str(e)}"
@mcp.tool()
async def memora_search(query: str, max_tokens: int = 4096) -> str:
"""
**CRITICAL: Search user's memory to provide personalized, context-aware responses.**
Use this tool PROACTIVELY at the start of conversations or when making recommendations to:
- Check user's preferences before making suggestions (e.g., "what foods does the user like?")
- Recall user's history to provide continuity (e.g., "what projects has the user worked on?")
- Remember user's goals and context (e.g., "what is the user trying to accomplish?")
- Avoid repeating information or asking questions you should already know
- Personalize responses based on user's background, interests, and past interactions
- Reference past conversations or events the user mentioned
**When to use**:
- Start of conversation: Search for relevant context about the user
- Before recommendations: Check user preferences and past experiences
- When user asks about something they may have mentioned before
- To provide continuity across conversations
**Search tips**: Use natural language queries like "user's programming language preferences",
"user's work experience", "user's dietary restrictions", "what does the user know about X?"
Args:
query: Natural language search query to find relevant memories
max_tokens: Maximum tokens for search context (default: 4096)
"""
try:
result = await client.search(query=query, max_tokens=max_tokens)
return json.dumps(result, indent=2)
except Exception as e:
logger.error(f"Error searching: {e}", exc_info=True)
return f"Error: {str(e)}"
def main():
"""Main entry point."""
port = int(os.getenv("PORT", "8765"))
host = os.getenv("HOST", "127.0.0.1")
logger.info(f"Starting Memora MCP Server for agent: {config.agent_id}")
logger.info(f"MCP server starting on http://{host}:{port}")
mcp.run(transport="sse", host=host, port=port)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,101 @@
"""
Unified API module for Memora.
Provides both HTTP REST API and MCP (Model Context Protocol) server.
"""
import logging
from typing import Optional
from fastapi import FastAPI
from memora import TemporalSemanticMemory
logger = logging.getLogger(__name__)
def create_app(
memory: TemporalSemanticMemory,
http_api_enabled: bool = True,
mcp_api_enabled: bool = False,
mcp_mount_path: str = "/mcp",
run_migrations: bool = True,
initialize_memory: bool = True
) -> FastAPI:
"""
Create and configure the unified Memora API application.
Args:
memory: TemporalSemanticMemory instance (already initialized with required parameters)
http_api_enabled: Whether to enable HTTP REST API endpoints (default: True)
mcp_api_enabled: Whether to enable MCP server (default: False)
mcp_mount_path: Path to mount MCP server (default: /mcp)
run_migrations: Whether to run database migrations on startup (default: True)
initialize_memory: Whether to initialize memory system on startup (default: True)
Returns:
Configured FastAPI application with enabled APIs
Example:
# HTTP only
app = create_app(memory)
# MCP only
app = create_app(memory, http_api_enabled=False, mcp_api_enabled=True)
# Both HTTP and MCP
app = create_app(memory, mcp_api_enabled=True)
"""
# Import and create HTTP API if enabled
if http_api_enabled:
from .http import create_app as create_http_app
app = create_http_app(
memory=memory,
run_migrations=run_migrations,
initialize_memory=initialize_memory
)
logger.info("HTTP REST API enabled")
else:
# Create minimal FastAPI app
app = FastAPI(title="Memora API", version="0.0.7")
logger.info("HTTP REST API disabled")
# Mount MCP server if enabled
if mcp_api_enabled:
try:
from .mcp import create_mcp_server
# Create MCP server with shared memory instance
mcp_server = create_mcp_server(memory=memory)
# Mount at specified path
app.mount(mcp_mount_path, mcp_server.sse_app())
logger.info(f"MCP server enabled at {mcp_mount_path}/sse")
except ImportError as e:
logger.error(f"MCP server requested but dependencies not available: {e}")
logger.error("Install with: pip install memora[mcp]")
raise
return app
# Re-export commonly used items for backwards compatibility
from .http import (
SearchRequest,
SearchResult,
SearchResponse,
MemoryItem,
BatchPutRequest,
ThinkRequest,
ThinkResponse,
)
__all__ = [
"create_app",
"SearchRequest",
"SearchResult",
"SearchResponse",
"MemoryItem",
"BatchPutRequest",
"ThinkRequest",
"ThinkResponse",
]

130
memora/memora/api/mcp.py Normal file
View file

@ -0,0 +1,130 @@
"""Memora MCP Server implementation using FastMCP."""
import json
import logging
from fastmcp import FastMCP
from memora import TemporalSemanticMemory
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def create_mcp_server(memory: TemporalSemanticMemory) -> FastMCP:
"""
Create and configure the Memora MCP server.
Args:
memory: TemporalSemanticMemory instance (required)
Returns:
Configured FastMCP server instance
"""
# Create FastMCP server
mcp = FastMCP("memora-mcp-server")
@mcp.tool()
async def memora_put(agent_id: str, content: str, context: str, explanation: str = "") -> str:
"""
**CRITICAL: Store important user information to long-term memory.**
Use this tool PROACTIVELY whenever the user shares:
- Personal facts, preferences, or interests (e.g., "I love hiking", "I'm a vegetarian")
- Important events or milestones (e.g., "I got promoted", "My birthday is June 15")
- User history, experiences, or background (e.g., "I used to work at Google", "I studied CS at MIT")
- Decisions, opinions, or stated preferences (e.g., "I prefer Python over JavaScript")
- Goals, plans, or future intentions (e.g., "I'm planning to visit Japan next year")
- Relationships or people mentioned (e.g., "My manager Sarah", "My wife Alice")
- Work context, projects, or responsibilities
- Any other information the user would want remembered for future conversations
**When to use**: Immediately after user shares personal information. Don't ask permission - just store it naturally.
**Context guidelines**: Use descriptive contexts like "personal_preferences", "work_history", "family", "hobbies",
"career_goals", "project_details", etc. This helps organize and retrieve related memories later.
Args:
agent_id: The unique identifier for the agent/user storing the memory
content: The fact/memory to store (be specific and include relevant details)
context: Categorize the memory (e.g., 'personal_preferences', 'work_history', 'hobbies', 'family')
explanation: Optional explanation for why this memory is being stored
"""
try:
# Log explanation if provided
if explanation:
logger.debug(f"Explanation: {explanation}")
# Store memory using put_batch_async
await memory.put_batch_async(
agent_id=agent_id,
contents=[{"content": content, "context": context}]
)
return f"Fact stored successfully"
except Exception as e:
logger.error(f"Error storing fact: {e}", exc_info=True)
return f"Error: {str(e)}"
@mcp.tool()
async def memora_search(agent_id: str, query: str, max_tokens: int = 4096, explanation: str = "") -> str:
"""
**CRITICAL: Search user's memory to provide personalized, context-aware responses.**
Use this tool PROACTIVELY at the start of conversations or when making recommendations to:
- Check user's preferences before making suggestions (e.g., "what foods does the user like?")
- Recall user's history to provide continuity (e.g., "what projects has the user worked on?")
- Remember user's goals and context (e.g., "what is the user trying to accomplish?")
- Avoid repeating information or asking questions you should already know
- Personalize responses based on user's background, interests, and past interactions
- Reference past conversations or events the user mentioned
**When to use**:
- Start of conversation: Search for relevant context about the user
- Before recommendations: Check user preferences and past experiences
- When user asks about something they may have mentioned before
- To provide continuity across conversations
**Search tips**: Use natural language queries like "user's programming language preferences",
"user's work experience", "user's dietary restrictions", "what does the user know about X?"
Args:
agent_id: The unique identifier for the agent/user whose memories to search
query: Natural language search query to find relevant memories
max_tokens: Maximum tokens for search context (default: 4096)
explanation: Optional explanation for why this search is being performed
"""
try:
# Log all parameters for debugging
logger.info(f"memora_search called with: query={query!r}, max_tokens={max_tokens}, explanation={explanation!r}")
# Log explanation if provided
if explanation:
logger.debug(f"Explanation: {explanation}")
# Search using search_async
search_result = await memory.search_async(
agent_id=agent_id,
query=query,
fact_type=["world", "agent", "opinion"], # Search all fact types
max_tokens=max_tokens,
thinking_budget=100
)
# Convert results to dict format
results = [
{
"id": fact.id,
"text": fact.text,
"type": fact.fact_type,
"context": fact.context,
"event_date": fact.event_date, # Already a string from the database
"document_id": fact.document_id
}
for fact in search_result.results
]
return json.dumps({"results": results}, indent=2)
except Exception as e:
logger.error(f"Error searching: {e}", exc_info=True)
return json.dumps({"error": str(e), "results": []})
return mcp

View file

@ -22,7 +22,17 @@ _memory = TemporalSemanticMemory(
memory_llm_model=os.getenv("MEMORA_API_LLM_MODEL", "openai/gpt-oss-120b"),
memory_llm_base_url=os.getenv("MEMORA_API_LLM_BASE_URL") or None,
)
app = create_app(_memory)
# Check if MCP should be enabled
mcp_enabled = os.getenv("MEMORA_API_MCP_ENABLED", "true").lower() == "true"
# Create unified app with both HTTP and optionally MCP
app = create_app(
memory=_memory,
http_api_enabled=True,
mcp_api_enabled=mcp_enabled,
mcp_mount_path="/mcp"
)
if __name__ == "__main__":

View file

@ -35,6 +35,9 @@ test = [
"pytest-asyncio>=0.21.0",
"pytest-timeout>=2.4.0",
]
mcp = [
"fastmcp>=2.0.0",
]
[tool.hatch.build.targets.wheel]
packages = ["memora"]

View file

@ -0,0 +1,153 @@
"""Test MCP server with real server and client."""
import asyncio
import os
import pytest
from mcp import ClientSession
from mcp.client.sse import sse_client
# Note: MCP server tests now require the full web server to be running
# with MEMORA_API_MCP_ENABLED=true since there's no standalone MCP server anymore.
# These tests are kept for documentation but may need manual server setup.
pytest.skip("MCP server is now integrated with web server. Run web server with MEMORA_API_MCP_ENABLED=true to test.", allow_module_level=True)
@pytest.mark.asyncio
async def test_mcp_server_tools_via_sse(mcp_server):
"""Test MCP server tools via SSE transport using proper MCP client."""
sse_url = mcp_server
async with sse_client(sse_url) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
# Test 1: List tools
tools_list = await session.list_tools()
print(f"Tools: {tools_list}")
tool_names = [t.name for t in tools_list.tools]
assert "memora_search" in tool_names
assert "memora_put" in tool_names
# Test 2: Call memora_put
put_result = await session.call_tool(
"memora_put",
arguments={
"content": "User loves Python programming",
"context": "programming_preferences",
"explanation": "Storing user's programming language preference"
}
)
print(f"Put result: {put_result}")
assert put_result is not None
# Wait a bit for indexing
await asyncio.sleep(1)
# Test 3: Call memora_search
search_result = await session.call_tool(
"memora_search",
arguments={
"query": "What programming languages does the user like?",
"max_tokens": 4096,
"explanation": "Searching for programming preferences"
}
)
print(f"Search result: {search_result}")
assert search_result is not None
@pytest.mark.asyncio
async def test_multiple_concurrent_requests(mcp_server):
"""Test multiple concurrent requests from a single session."""
sse_url = mcp_server
async with sse_client(sse_url) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
# Fire off 10 concurrent search requests from same session
async def make_search(idx):
try:
result = await session.call_tool(
"memora_search",
arguments={
"query": f"test query {idx}",
"explanation": f"Concurrent test {idx}"
}
)
return idx, "success", result
except Exception as e:
return idx, "error", str(e)
tasks = [make_search(i) for i in range(10)]
results = await asyncio.gather(*tasks, return_exceptions=True)
# Check results
successes = 0
failures = 0
for result in results:
if isinstance(result, Exception):
print(f"Request failed with exception: {result}")
failures += 1
else:
idx, status, data = result
if status == "success":
successes += 1
else:
print(f"Request {idx} failed: {data}")
failures += 1
print(f"Successes: {successes}, Failures: {failures}")
# We expect all requests to succeed
assert successes >= 8, f"Too many failures: {failures}/10"
@pytest.mark.asyncio
async def test_race_condition_with_rapid_requests(mcp_server):
"""Test rapid-fire requests with multiple sessions to trigger race condition."""
sse_url = mcp_server
async def rapid_session_search(idx):
"""Create a new session and immediately make a request."""
try:
async with sse_client(sse_url) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
# Make request immediately after initialization
result = await session.call_tool(
"memora_search",
arguments={
"query": f"rapid query {idx}",
"max_tokens": 2048
}
)
return idx, "success", result
except Exception as e:
return idx, "error", str(e)
# Fire 20 requests with minimal delay, each with its own session
tasks = [rapid_session_search(i) for i in range(20)]
results = await asyncio.gather(*tasks)
# Analyze results
errors = []
for idx, status, data in results:
if status == "error":
errors.append((idx, data))
if errors:
print(f"Found {len(errors)} errors:")
for idx, error_msg in errors:
print(f" Request {idx}: {error_msg}")
# Most requests should succeed
assert len(errors) < 5, f"Too many errors: {len(errors)}/20"
if __name__ == "__main__":
pytest.main([__file__, "-v", "-s"])

View file

@ -1,46 +0,0 @@
# Build artifacts
**/*.pyc
**/__pycache__/
**/.pytest_cache/
**/.venv/
**/venv/
**/*.egg-info/
**/dist/
**/build/
# Node
**/node_modules/
**/.next/
**/npm-debug.log
**/.turbo/
# Environment files
.env
.env.*
!standalone/.env.standalone
# Git
.git/
.gitignore
.gitattributes
# IDE
.vscode/
.idea/
*.swp
*.swo
# Test and dev files
**/tests/
local-db/
logs/
# Documentation (except standalone README)
README.md
!standalone/README.md
# Standalone files
standalone/build-docker.sh
standalone/.dockerignore
standalone/.env.example
standalone/docker-compose.yml

View file

@ -1,9 +0,0 @@
# Environment variables for docker-compose
# Copy this file to .env and customize as needed
# Optional: OpenAI API key
# OPENAI_API_KEY=your-api-key-here
# Optional: Custom embedding model
# EMBEDDING_MODEL_NAME=sentence-transformers/all-MiniLM-L6-v2
# EMBEDDING_DIM=384

View file

@ -1,15 +0,0 @@
# Standalone environment configuration
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/memora
DATAPLANE_API_URL=http://localhost:8080
# Embedding configuration
EMBEDDING_MODEL_NAME=sentence-transformers/all-MiniLM-L6-v2
EMBEDDING_DIM=384
# LLM Provider (set to "none" to disable LLM features)
LLM_PROVIDER=none
# Optional: LLM API Keys
# OPENAI_API_KEY=your-openai-key-here
# ANTHROPIC_API_KEY=your-anthropic-key-here
# GROQ_API_KEY=your-groq-key-here

View file

@ -1,5 +0,0 @@
# Environment files
.env
# Docker volumes
*.log

View file

@ -1,87 +0,0 @@
FROM node:20-alpine AS control-plane-builder
# Build control plane
WORKDIR /app/memora-control-plane
COPY memora-control-plane/package*.json ./
RUN npm ci
COPY memora-control-plane/ ./
# Set env to skip font optimization during build
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build || (echo "Build failed, retrying..." && npm run build)
# Python source stage - just copy files, don't build venv yet
FROM python:3.12-slim AS dataplane-source
WORKDIR /build
COPY pyproject.toml uv.lock ./
COPY memora/ ./memora/
COPY memora-dev/ ./memora-dev/
# Final runtime image
FROM python:3.12-slim
# Install system dependencies and PostgreSQL
RUN apt-get update && apt-get install -y \
gnupg \
lsb-release \
wget \
curl \
ca-certificates \
&& mkdir -p /etc/apt/keyrings \
&& wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /etc/apt/keyrings/pgdg.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list \
&& apt-get update && apt-get install -y \
postgresql-15 \
postgresql-15-pgvector \
postgresql-contrib-15 \
nodejs \
npm \
supervisor \
&& rm -rf /var/lib/apt/lists/*
# Install uv
RUN pip install uv
# Create app directory
WORKDIR /app
# Copy dataplane source from builder
COPY --from=dataplane-source /build /app
# Build venv in the final stage to ensure compatibility
RUN cd /app && uv sync --frozen
# Copy control plane from builder
COPY --from=control-plane-builder /app/memora-control-plane/.next/standalone /app/memora-control-plane
COPY --from=control-plane-builder /app/memora-control-plane/.next/static /app/memora-control-plane/.next/static
COPY memora-control-plane/start-server.sh /app/memora-control-plane/start-server.sh
RUN chmod +x /app/memora-control-plane/start-server.sh
# Copy standalone configuration
COPY standalone/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY standalone/init.sh /app/init.sh
COPY standalone/.env.standalone /app/.env
RUN chmod +x /app/init.sh
# PostgreSQL setup
RUN mkdir -p /var/lib/postgresql/data && \
chown -R postgres:postgres /var/lib/postgresql && \
mkdir -p /var/run/postgresql && \
chown -R postgres:postgres /var/run/postgresql
# Initialize PostgreSQL as postgres user
USER postgres
RUN /usr/lib/postgresql/15/bin/initdb -D /var/lib/postgresql/data
USER root
# Expose ports
# 5432: PostgreSQL
# 8080: Dataplane API
# 3000: Control Plane
EXPOSE 5432 8080 3000
# Start supervisor
CMD ["/app/init.sh"]

View file

@ -1,106 +0,0 @@
# Memora Standalone - Quick Start
## What is this?
A single Docker image containing everything you need to run Memora:
- ✅ PostgreSQL database
- ✅ Dataplane API (FastAPI backend)
- ✅ Control Plane (Next.js web UI)
## Fastest Start (Docker Compose)
```bash
cd standalone
docker-compose up -d
```
Access the UI at: **http://localhost:3000**
## Manual Docker Build & Run
### Build the image:
```bash
./standalone/build-docker.sh
```
### Run with the helper script:
```bash
./standalone/run-docker.sh --persist
```
### Or run directly:
```bash
docker run -d \
--name memora \
-p 3000:3000 \
-p 8080:8080 \
-p 5432:5432 \
-v memora-data:/var/lib/postgresql/data \
memora-standalone:latest
```
## Access Points
| Service | URL | Purpose |
|---------|-----|---------|
| **Control Plane** | http://localhost:3000 | Web UI |
| **Dataplane API** | http://localhost:8080 | REST API |
| **PostgreSQL** | localhost:5432 | Database |
## View Logs
```bash
docker logs -f memora-standalone
```
## Stop & Remove
```bash
# Stop
docker-compose down
# Stop and remove data
docker-compose down -v
```
## Environment Variables
Set in `docker-compose.yml` or pass with `-e`:
- `MEMORA_API_LLM_PROVIDER` - LLM provider (openai, groq, ollama, none) (default: none)
- `MEMORA_API_LLM_API_KEY` - API key for LLM provider
- `MEMORA_API_LLM_MODEL` - LLM model name (default: openai/gpt-oss-120b)
- `MEMORA_API_LLM_BASE_URL` - Optional custom LLM endpoint
- `MEMORA_CP_DATAPLANE_API_URL` - Dataplane API URL (default: http://localhost:8080)
## Troubleshooting
**Container won't start:**
```bash
docker logs memora-standalone
```
**Database issues:**
```bash
docker exec -it memora-standalone su - postgres -c "psql memora"
```
**Reset everything:**
```bash
docker-compose down -v
docker-compose up -d
```
## Production Notes
This standalone image is ideal for:
- ✅ Development
- ✅ Demos
- ✅ Testing
- ✅ Small deployments
For production, consider:
- Separate containers for each service
- External PostgreSQL database
- Kubernetes/Docker Swarm orchestration
- Environment-specific configurations

View file

@ -1,126 +0,0 @@
# Memora Standalone Docker Image
This directory contains the configuration to build a standalone Docker image that includes all Memora components in a single container:
- **PostgreSQL**: Database backend
- **Dataplane**: FastAPI backend service
- **Control Plane**: Next.js web interface
## Quick Start with Docker Compose
The easiest way to run the standalone image:
```bash
cd standalone
docker-compose up -d
```
This will build and start all services with persistent data storage.
To stop:
```bash
docker-compose down
```
To remove data and start fresh:
```bash
docker-compose down -v
```
## Building Manually
```bash
./standalone/build-docker.sh
```
With custom options:
```bash
./standalone/build-docker.sh --name my-memora --tag v1.0.0
./standalone/build-docker.sh --registry docker.io/myuser --tag latest
```
## Running Manually
Using the run script (recommended):
```bash
./standalone/run-docker.sh --persist
```
With custom ports:
```bash
./standalone/run-docker.sh --persist --port-control 3001 --port-api 8081
```
Direct docker run:
```bash
docker run -p 3000:3000 -p 8080:8080 memora-standalone:latest
```
With persistent data:
```bash
docker run -p 3000:3000 -p 8080:8080 \
-v memora-data:/var/lib/postgresql/data \
memora-standalone:latest
```
With custom environment variables:
```bash
docker run -p 3000:3000 -p 8080:8080 \
-e MEMORA_API_LLM_PROVIDER=groq \
-e MEMORA_API_LLM_API_KEY=your-key \
-e MEMORA_API_LLM_MODEL=openai/gpt-oss-120b \
memora-standalone:latest
```
## Accessing Services
Once running, services are available at:
- **Control Plane**: http://localhost:3000
- **Dataplane API**: http://localhost:8080
- **PostgreSQL**: localhost:5432 (username: postgres, password: postgres, database: memora)
## Architecture
The container uses `supervisord` to manage three processes:
1. PostgreSQL (started first)
2. Dataplane API (started after PostgreSQL)
3. Control Plane (started after dataplane)
The `init.sh` script handles:
- PostgreSQL initialization
- Database creation
- Running migrations
- Starting all services via supervisord
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `MEMORA_API_DATABASE_URL` | `postgresql://postgres:postgres@localhost:5432/memora` | PostgreSQL connection string |
| `MEMORA_CP_DATAPLANE_API_URL` | `http://localhost:8080` | Dataplane API URL for control plane |
| `MEMORA_API_LLM_PROVIDER` | `none` | LLM provider (openai, groq, ollama, none) |
| `MEMORA_API_LLM_API_KEY` | - | API key for LLM provider |
| `MEMORA_API_LLM_MODEL` | `openai/gpt-oss-120b` | LLM model name |
| `MEMORA_API_LLM_BASE_URL` | - | Optional custom LLM endpoint |
## Logs
View logs from all services:
```bash
docker logs -f <container-id>
```
## Production Considerations
This standalone image is designed for:
- Development environments
- Demos and testing
- Small deployments
For production use, consider:
- Using separate containers for each service
- External PostgreSQL database
- Load balancing for the control plane
- Persistent volume for PostgreSQL data
- Environment-specific configurations

View file

@ -1,87 +0,0 @@
#!/bin/bash
set -e
cd "$(dirname "$0")/.."
# Default values
IMAGE_NAME="memora-standalone"
IMAGE_TAG="latest"
REGISTRY=""
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
--name)
IMAGE_NAME="$2"
shift 2
;;
--tag)
IMAGE_TAG="$2"
shift 2
;;
--registry)
REGISTRY="$2"
shift 2
;;
--help)
echo "Usage: $0 [OPTIONS]"
echo ""
echo "Options:"
echo " --name NAME Docker image name (default: memora-standalone)"
echo " --tag TAG Docker image tag (default: latest)"
echo " --registry REG Docker registry URL (optional)"
echo " --help Show this help message"
echo ""
echo "Example:"
echo " $0 --name myapp --tag v1.0.0"
echo " $0 --registry docker.io/myuser --name memora-standalone --tag v1.0.0"
exit 0
;;
*)
echo "Unknown option: $1"
echo "Use --help for usage information"
exit 1
;;
esac
done
# Construct full image name
if [ -n "$REGISTRY" ]; then
FULL_IMAGE_NAME="${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}"
else
FULL_IMAGE_NAME="${IMAGE_NAME}:${IMAGE_TAG}"
fi
echo "Building Memora Standalone Docker image: ${FULL_IMAGE_NAME}"
echo "============================================================="
echo "This image includes:"
echo " - PostgreSQL database"
echo " - Dataplane API (FastAPI)"
echo " - Control Plane (Next.js)"
echo ""
# Build the Docker image
docker build -f standalone/Dockerfile -t "${FULL_IMAGE_NAME}" .
echo ""
echo "Build completed successfully!"
echo "Image: ${FULL_IMAGE_NAME}"
echo ""
echo "To run the container:"
echo " docker run -p 3000:3000 -p 8080:8080 ${FULL_IMAGE_NAME}"
echo ""
echo "Services will be available at:"
echo " - Control Plane: http://localhost:3000"
echo " - Dataplane API: http://localhost:8080"
echo " - PostgreSQL: localhost:5432"
echo ""
echo "For persistent data, mount a volume:"
echo " docker run -p 3000:3000 -p 8080:8080 \\"
echo " -v memora-data:/var/lib/postgresql/data \\"
echo " ${FULL_IMAGE_NAME}"
echo ""
if [ -n "$REGISTRY" ]; then
echo "To push to registry:"
echo " docker push ${FULL_IMAGE_NAME}"
echo ""
fi

View file

@ -1,21 +0,0 @@
version: '3.8'
services:
memora-standalone:
build:
context: ..
dockerfile: standalone/Dockerfile
ports:
- "3000:3000" # Control Plane
- "8080:8080" # Dataplane API
- "5432:5432" # PostgreSQL
environment:
- EMBEDDING_MODEL_NAME=sentence-transformers/all-MiniLM-L6-v2
- EMBEDDING_DIM=384
# - OPENAI_API_KEY=${OPENAI_API_KEY} # Uncomment if needed
volumes:
- memora-data:/var/lib/postgresql/data
restart: unless-stopped
volumes:
memora-data:

View file

@ -1,58 +0,0 @@
#!/bin/bash
set -e
echo "🚀 Starting Memora Standalone Container..."
echo "==========================================="
# Start PostgreSQL temporarily for initialization
echo "📦 Starting PostgreSQL for initialization..."
su - postgres -c "/usr/lib/postgresql/15/bin/pg_ctl -D /var/lib/postgresql/data -l /tmp/postgresql-init.log start"
# Wait for PostgreSQL to be ready
echo "⏳ Waiting for PostgreSQL to be ready..."
for i in {1..30}; do
if su - postgres -c "psql -lqt" &>/dev/null; then
echo "✅ PostgreSQL is ready"
break
fi
if [ $i -eq 30 ]; then
echo "❌ PostgreSQL failed to start"
cat /tmp/postgresql-init.log
exit 1
fi
sleep 1
done
# Create database if it doesn't exist
echo "📊 Setting up database..."
su - postgres -c "psql -tc \"SELECT 1 FROM pg_database WHERE datname = 'memora'\" | grep -q 1 || psql -c 'CREATE DATABASE memora;'"
# Run initial migrations
# Note: The API also runs migrations automatically on startup.
# We run them here during initialization to ensure the database
# schema is ready before handing off to supervisord.
echo "🔄 Running initial database migrations..."
cd /app/memora
# Export environment variables
set -a
source /app/.env
set +a
/app/.venv/bin/python -m alembic upgrade head
# Stop PostgreSQL so supervisord can start it cleanly
echo "🔄 Stopping PostgreSQL to hand off to supervisord..."
su - postgres -c "/usr/lib/postgresql/15/bin/pg_ctl -D /var/lib/postgresql/data stop -m fast"
sleep 2
echo "✅ Initialization complete"
echo ""
echo "Starting services via supervisord..."
echo " - PostgreSQL: localhost:5432"
echo " - Dataplane API: http://localhost:8080"
echo " - Control Plane: http://localhost:3000"
echo ""
# Start supervisor to manage all services
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf

View file

@ -1,122 +0,0 @@
#!/bin/bash
set -e
# Default values
IMAGE_NAME="memora-standalone:latest"
CONTAINER_NAME="memora-standalone"
PERSIST_DATA=false
PORT_CONTROL=3000
PORT_API=8080
PORT_DB=5432
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
--image)
IMAGE_NAME="$2"
shift 2
;;
--name)
CONTAINER_NAME="$2"
shift 2
;;
--persist)
PERSIST_DATA=true
shift
;;
--port-control)
PORT_CONTROL="$2"
shift 2
;;
--port-api)
PORT_API="$2"
shift 2
;;
--port-db)
PORT_DB="$2"
shift 2
;;
--help)
echo "Usage: $0 [OPTIONS]"
echo ""
echo "Options:"
echo " --image NAME Docker image name (default: memora-standalone:latest)"
echo " --name NAME Container name (default: memora-standalone)"
echo " --persist Use persistent volume for data"
echo " --port-control PORT Control plane port (default: 3000)"
echo " --port-api PORT Dataplane API port (default: 8080)"
echo " --port-db PORT PostgreSQL port (default: 5432)"
echo " --help Show this help message"
echo ""
echo "Example:"
echo " $0 --persist --port-control 3001"
echo ""
echo "To stop the container:"
echo " docker stop ${CONTAINER_NAME}"
echo ""
echo "To remove the container:"
echo " docker rm ${CONTAINER_NAME}"
exit 0
;;
*)
echo "Unknown option: $1"
echo "Use --help for usage information"
exit 1
;;
esac
done
# Check if container already exists
if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
echo "⚠️ Container '${CONTAINER_NAME}' already exists"
echo ""
read -p "Do you want to remove it and create a new one? (y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo "🗑️ Removing existing container..."
docker rm -f "${CONTAINER_NAME}" 2>/dev/null || true
else
echo "Exiting..."
exit 0
fi
fi
echo "🚀 Starting Memora Standalone Container"
echo "========================================"
echo "Image: ${IMAGE_NAME}"
echo "Container: ${CONTAINER_NAME}"
echo ""
# Build docker run command
DOCKER_CMD="docker run -d --name ${CONTAINER_NAME}"
DOCKER_CMD="${DOCKER_CMD} -p ${PORT_CONTROL}:3000"
DOCKER_CMD="${DOCKER_CMD} -p ${PORT_API}:8080"
DOCKER_CMD="${DOCKER_CMD} -p ${PORT_DB}:5432"
if [ "$PERSIST_DATA" = true ]; then
DOCKER_CMD="${DOCKER_CMD} -v memora-data:/var/lib/postgresql/data"
echo "📦 Using persistent volume: memora-data"
fi
DOCKER_CMD="${DOCKER_CMD} ${IMAGE_NAME}"
# Run the container
eval $DOCKER_CMD
echo ""
echo "✅ Container started successfully!"
echo ""
echo "Services are available at:"
echo " - Control Plane: http://localhost:${PORT_CONTROL}"
echo " - Dataplane API: http://localhost:${PORT_API}"
echo " - PostgreSQL: localhost:${PORT_DB}"
echo ""
echo "View logs:"
echo " docker logs -f ${CONTAINER_NAME}"
echo ""
echo "Stop container:"
echo " docker stop ${CONTAINER_NAME}"
echo ""
echo "Remove container:"
echo " docker rm -f ${CONTAINER_NAME}"
echo ""

View file

@ -1,42 +0,0 @@
[supervisord]
nodaemon=true
user=root
logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid
[program:postgresql]
command=/usr/lib/postgresql/15/bin/postgres -D /var/lib/postgresql/data
user=postgres
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
priority=1
[program:dataplane]
command=/app/.venv/bin/python -m memora.web.server --host 0.0.0.0 --port 8080
directory=/app/memora
environment=PATH="/app/.venv/bin:%(ENV_PATH)s",MEMORA_API_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/memora",MEMORA_API_LLM_PROVIDER="none"
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
startsecs=10
priority=10
[program:memora-control-plane]
command=/app/memora-control-plane/start-server.sh
directory=/app/memora-control-plane
environment=NODE_ENV="production",MEMORA_CP_PORT="3000",MEMORA_CP_HOSTNAME="0.0.0.0",MEMORA_CP_DATAPLANE_API_URL="http://localhost:8080"
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
startsecs=5
priority=20

22
uv.lock
View file

@ -13,7 +13,6 @@ members = [
"memora-client",
"memora-dev",
"memora-langmem",
"memora-mcp-server",
"memora-openai",
]
@ -1717,6 +1716,9 @@ dependencies = [
]
[package.optional-dependencies]
mcp = [
{ name = "fastmcp" },
]
test = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
@ -1728,6 +1730,7 @@ requires-dist = [
{ name = "alembic", specifier = ">=1.17.1" },
{ name = "asyncpg", specifier = ">=0.29.0" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.120.3" },
{ name = "fastmcp", marker = "extra == 'mcp'", specifier = ">=2.0.0" },
{ name = "greenlet", specifier = ">=3.2.4" },
{ name = "httpx", specifier = ">=0.27.0" },
{ name = "langchain-text-splitters", specifier = ">=0.3.0" },
@ -1747,7 +1750,7 @@ requires-dist = [
{ name = "transformers", specifier = ">=4.30.0" },
{ name = "uvicorn", specifier = ">=0.38.0" },
]
provides-extras = ["test"]
provides-extras = ["test", "mcp"]
[[package]]
name = "memora-client"
@ -1814,21 +1817,6 @@ requires-dist = [
]
provides-extras = ["test"]
[[package]]
name = "memora-mcp-server"
version = "0.0.1"
source = { editable = "memora-mcp-server" }
dependencies = [
{ name = "fastmcp" },
{ name = "httpx" },
]
[package.metadata]
requires-dist = [
{ name = "fastmcp", specifier = ">=0.7.0" },
{ name = "httpx", specifier = ">=0.28.1" },
]
[[package]]
name = "memora-openai"
version = "0.1.0"