Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,13 @@ Phase 1 (MVP) = static bearer-token auth. Phase 2 adds OAuth 2.1 + PKCE + DCR en
- **`MEM0_EMBED_DIMS` must match the embedder's real output dimension** (3-small=1536, 3-large=3072). A mismatch causes *silent* search failures, not errors. Changing embedding models requires dropping and recreating the Qdrant collection.
- **FastMCP = the PrefectHQ `fastmcp` PyPI package**, imported `from fastmcp import FastMCP`. It is NOT the older `mcp.server.fastmcp` module.
- **Same `MEM0_API_KEY` protects both** the REST endpoints (`require_bearer` dependency) and the MCP endpoint (`StaticTokenVerifier` in Phase 1).
- **The Phase 2 OAuth `/oauth/authorize` consent step authenticates the resource owner** by requiring `MEM0_API_KEY` (constant-time compare) before issuing a code. Don't remove this — the OAuth endpoints are public, so without it anyone reaching the consent screen could mint a token for the single user's memories.

## Planned layout (per PRD §3)

- `app/config.py` — `Settings` (pydantic-settings); single source of config truth, reject startup on missing required vars.
- `app/memory.py` — mem0 wrapper / `_build_config`; **the most tweak-prone file**.
- `app/mcp_server.py` — the six MCP tools (add/search/list/get/update/delete), each thinly wrapping a mem0 op with `user_id` defaulted.
- `app/mcp_server.py` — the six MCP tools (add/search/list/get/update/delete), each thinly wrapping a mem0 op with `user_id` defaulted. MCP reads (`search`/`list`) must NOT filter by `agent_id` — all agents share one memory; `agent_id` is a write-only provenance tag on `add`. (REST may still filter reads by `agent_id` for explicit scripted use.)
- `app/rest.py` — REST router under `/api/v1`.
- `app/auth.py` — `require_bearer` + `build_verifier()` (Phase 1); composite bearer-or-JWT verifier (Phase 2).
- `app/oauth.py` / `app/oauth_store.py` — Phase 2 OAuth AS endpoints + SQLite store at `/app/data/oauth.db` (CapRover persistent volume).
Expand Down
25 changes: 13 additions & 12 deletions app/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ def add_memory(content: str, agent_id: str | None = None, metadata: dict | None

Use when the user shares preferences, project context, decisions,
or anything they may want recalled in future conversations.

agent_id is an optional provenance tag recording which agent wrote the
memory. It does NOT partition the store — search and list always span
every memory for the user, so all connected agents share one memory.
"""
kwargs: dict = {"user_id": default_user}
if agent_id:
Expand All @@ -26,20 +30,17 @@ def add_memory(content: str, agent_id: str | None = None, metadata: dict | None
return memory.add(content, **kwargs)

@mcp.tool
def search_memories(query: str, agent_id: str | None = None, limit: int = 10) -> dict:
"""Search long-term memory by semantic similarity."""
filters: dict = {"user_id": default_user}
if agent_id:
filters["agent_id"] = agent_id
return memory.search(query=query, filters=filters, top_k=limit)
def search_memories(query: str, limit: int = 10) -> dict:
"""Search long-term memory by semantic similarity.

Searches the single shared memory store for the user, across all agents.
"""
return memory.search(query=query, filters={"user_id": default_user}, top_k=limit)

@mcp.tool
def list_memories(agent_id: str | None = None) -> dict:
"""List all stored memories for the current user."""
filters: dict = {"user_id": default_user}
if agent_id:
filters["agent_id"] = agent_id
return memory.get_all(filters=filters)
def list_memories() -> dict:
"""List all stored memories for the user (shared across all agents)."""
return memory.get_all(filters={"user_id": default_user})

@mcp.tool
def get_memory(memory_id: str) -> dict:
Expand Down
84 changes: 65 additions & 19 deletions app/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers
from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response

from app import oauth_store
from app.config import get_settings
Expand Down Expand Up @@ -161,55 +161,101 @@ async def register(request: Request) -> JSONResponse:
)


@router.get("/oauth/authorize")
def authorize_form(
def _consent_page(
client_id: str,
redirect_uri: str,
code_challenge: str,
code_challenge_method: str = "S256",
response_type: str = "code",
state: str = "",
scope: str = "read write",
) -> HTMLResponse:
if response_type != "code":
raise HTTPException(status_code=400, detail="unsupported response_type")
if code_challenge_method != "S256" or not code_challenge:
raise HTTPException(status_code=400, detail="PKCE S256 required")
client = oauth_store.get_client(client_id)
if not client or redirect_uri not in client["redirect_uris"]:
raise HTTPException(status_code=400, detail="invalid client or redirect_uri")
state: str,
scope: str,
error: str = "",
) -> str:
e_client_id = html.escape(client_id, quote=True)
e_redirect_uri = html.escape(redirect_uri, quote=True)
e_code_challenge = html.escape(code_challenge, quote=True)
e_state = html.escape(state, quote=True)
e_scope = html.escape(scope, quote=True)
page = f"""
error_html = f'<p style="color:red">{html.escape(error, quote=True)}</p>' if error else ""
return f"""
<html><body>
<h2>Authorize mem0</h2>
{error_html}
<p>Enter your mem0 API key to grant this client access to your memories.</p>
<form method="post" action="/oauth/authorize">
<input type="hidden" name="client_id" value="{e_client_id}">
<input type="hidden" name="redirect_uri" value="{e_redirect_uri}">
<input type="hidden" name="code_challenge" value="{e_code_challenge}">
<input type="hidden" name="state" value="{e_state}">
<input type="hidden" name="scope" value="{e_scope}">
<label>API key: <input type="password" name="password" autofocus></label>
<button type="submit">Authorize</button>
</form>
</body></html>
"""
return HTMLResponse(page)


def _owner_authenticated(password: str) -> bool:
# Single-user: the resource owner proves ownership at the consent step with
# the same MEM0_API_KEY that protects the API. An empty configured key must
# never authenticate. Hash both sides to fixed-length digests so the compare
# is genuinely constant-time (compare_digest can leak length for raw strings).
key = get_settings().mem0_api_key
if not key:
return False
provided = hashlib.sha256(password.encode()).digest()
expected = hashlib.sha256(key.encode()).digest()
return secrets.compare_digest(provided, expected)


@router.get("/oauth/authorize")
def authorize_form(
client_id: str,
redirect_uri: str,
code_challenge: str,
code_challenge_method: str = "S256",
response_type: str = "code",
state: str = "",
scope: str = "read write",
) -> HTMLResponse:
if response_type != "code":
raise HTTPException(status_code=400, detail="unsupported response_type")
if code_challenge_method != "S256" or not code_challenge:
raise HTTPException(status_code=400, detail="PKCE S256 required")
client = oauth_store.get_client(client_id)
if not client or redirect_uri not in client["redirect_uris"]:
raise HTTPException(status_code=400, detail="invalid client or redirect_uri")
return HTMLResponse(
_consent_page(client_id, redirect_uri, code_challenge, state, scope)
)


@router.post("/oauth/authorize")
def authorize_submit(
client_id: str = Form(...),
redirect_uri: str = Form(...),
code_challenge: str = Form(...),
code_challenge: str = Form(""),
state: str = Form(""),
scope: str = Form("read write"),
) -> RedirectResponse:
password: str = Form(""),
) -> Response:
client = oauth_store.get_client(client_id)
if not client or redirect_uri not in client["redirect_uris"]:
raise HTTPException(status_code=400, detail="invalid client or redirect_uri")
Comment on lines 240 to 242
# Enforce PKCE here too (the GET form does), so a direct POST can't mint a
# code that could never be exchanged.
if not code_challenge:
raise HTTPException(status_code=400, detail="PKCE S256 required")
# Authenticate the resource owner before issuing a code. Without this, anyone
# who reaches the consent screen could obtain a token for the single user's
# memories just by clicking "Authorize".
if not _owner_authenticated(password):
_log.warning("oauth_consent_rejected", client_id=client_id)
return HTMLResponse(
_consent_page(
client_id, redirect_uri, code_challenge, state, scope,
error="Invalid API key.",
),
status_code=401,
)
code = secrets.token_urlsafe(32)
oauth_store.save_code(code, client_id, redirect_uri, code_challenge)
sep = "&" if "?" in redirect_uri else "?"
Expand Down
5 changes: 4 additions & 1 deletion docs/DEVELOPER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,10 @@ MCP tools default `user_id` to the single configured user and expose a narrower
explicitly to `<PUBLIC_BASE_URL>/mcp` so the advertised resource is correct.

The OAuth flow (`app/oauth.py`) is OAuth 2.1 with PKCE (S256 required) and public clients only — no
client secrets are issued. Endpoints: `/oauth/register` (DCR), `/oauth/authorize` (GET form + POST
client secrets are issued. The `/oauth/authorize` consent step **authenticates the resource owner**:
the POST handler requires the `MEM0_API_KEY` (constant-time compared) before issuing a code. Without
this gate, anyone who reached the public consent screen could mint a token for the single user's
memories just by clicking "Authorize". Endpoints: `/oauth/register` (DCR), `/oauth/authorize` (GET form + POST
Comment on lines 123 to +127
consent), `/oauth/token` (authorization_code + refresh_token grants), `/.well-known/jwks.json`, and
the RFC 8414 / RFC 9728 metadata documents. Tokens live 24h; refresh tokens are single-use and
rotated. `oauth_store.py` persists clients/codes/refresh tokens in SQLite, hashing refresh tokens
Expand Down
29 changes: 20 additions & 9 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ a single service. It gives AI agents and scripts a shared, persistent long-term
two ways from one process:

- **REST API** under `/api/v1/memories…` — for scripts, n8n, curl, and any HTTP client.
- **Streamable HTTP MCP** under `/mcp/` — for Claude Code, Claude Desktop, Claude.ai web, and Cowork.
- **Streamable HTTP MCP** under `/mcp` — for Claude Code, Claude Desktop, Claude.ai web, and Cowork.

Both protocols read and write the **same** memory store, so a fact you save from Claude Code is
searchable from a curl script and vice versa.
Expand All @@ -45,7 +45,10 @@ keyword matches.

Memories can optionally be tagged with:

- `agent_id` — which agent/tool wrote it (e.g. `n8n-flow`, `claude-code`), so you can filter later.
- `agent_id` — a provenance tag for which agent/tool wrote it (e.g. `n8n-flow`, `claude-code`).
Over MCP it is **write-only**: the `search`/`list` tools always span the whole store, so every
connected agent (Claude Code, Codex, Claude.ai web, …) shares one memory. The REST API can still
filter reads by `agent_id` for scripts that explicitly want a slice.
- `run_id` — a session or workflow run identifier.
- `metadata` — arbitrary JSON you attach to a memory.

Expand Down Expand Up @@ -165,7 +168,7 @@ and Cowork, which use OAuth (Phase 2).

```bash
claude mcp add --scope user --transport http mem0-remote \
https://mem0.your-domain.com/mcp/ \
https://mem0.your-domain.com/mcp \
--header "Authorization: Bearer $MEM0_API_KEY"
```

Expand All @@ -175,20 +178,28 @@ Code.
### Claude Desktop

Add an entry under the MCP servers section of Claude Desktop's config, pointing at
`https://mem0.your-domain.com/mcp/` with an `Authorization: Bearer <token>` header (Streamable HTTP
transport). Restart Claude Desktop to pick it up.
`https://mem0.your-domain.com/mcp` with an `Authorization: Bearer <token>` header (Streamable HTTP
transport). Restart Claude Desktop to pick it up. Both `/mcp` and `/mcp/` work; `/mcp` is the
canonical form.

### Claude.ai web / Cowork (OAuth)

This requires **Phase 2** (`OAUTH_SIGNING_KEY` set). In the client's connector settings:

1. Add a **custom connector** pointing at `https://mem0.your-domain.com/mcp/`.
1. Add a **custom connector** pointing at `https://mem0.your-domain.com/mcp`.
2. Leave the client ID and secret **blank** — the server supports Dynamic Client Registration, so
the client registers itself automatically.
3. Complete the consent screen (click **Authorize**) and the redirect back to the client.
3. On the consent screen, **enter your `MEM0_API_KEY`** in the API key field and click
**Authorize**, then let the redirect complete.

The server only allows redirect URIs listed in `OAUTH_ALLOWED_REDIRECT_URIS`, which defaults to the
official claude.ai and Cowork callback URLs.
**Why the API key prompt matters (security):** this server is single-user and the consent step
authenticates *you* as the owner. Because the OAuth endpoints are public, anyone who knows the URL
could otherwise reach the consent screen; requiring `MEM0_API_KEY` at authorization ensures only the
holder of that key can mint an access token to your memories. Treat `MEM0_API_KEY` as the master
credential — anyone with it has full access via either the bearer header or the OAuth flow.

The server also only allows redirect URIs listed in `OAUTH_ALLOWED_REDIRECT_URIS`, which defaults to
the official claude.ai and Cowork callback URLs.

### REST / curl / n8n

Expand Down
17 changes: 13 additions & 4 deletions tests/test_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,23 @@ async def test_add_memory_tool(mcp, mem):
async def test_search_memories_tool(mcp, mem):
mem.search.return_value = {"results": []}
async with Client(mcp) as client:
await client.call_tool(
"search_memories", {"query": "what", "agent_id": "cc", "limit": 7}
)
await client.call_tool("search_memories", {"query": "what", "limit": 7})
_, kwargs = mem.search.call_args
assert kwargs["filters"] == {"user_id": "ian", "agent_id": "cc"}
# Reads are never scoped by agent_id: the store is shared across agents.
assert kwargs["filters"] == {"user_id": "ian"}
assert kwargs["top_k"] == 7


async def test_read_tools_do_not_expose_agent_id(mcp):
# Reads must not be scopable by agent_id, or a client's model can partition
# the shared store and break cross-agent memory.
async with Client(mcp) as client:
tools = {t.name: t for t in await client.list_tools()}
for name in ("search_memories", "list_memories"):
props = (tools[name].inputSchema or {}).get("properties", {})
assert "agent_id" not in props, name


async def test_list_memories_tool(mcp, mem):
mem.get_all.return_value = {"results": []}
async with Client(mcp) as client:
Expand Down
63 changes: 60 additions & 3 deletions tests/test_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ def test_full_authorize_token_flow(oauth_client):
"client_id": client_id,
"redirect_uri": ALLOWED_URI,
"code_challenge": challenge,
"password": "test-bearer-token",
},
follow_redirects=False,
)
Expand Down Expand Up @@ -158,6 +159,59 @@ def test_full_authorize_token_flow(oauth_client):
assert payload["scope"] == "read write"


def test_authorize_rejects_wrong_password(oauth_client):
# The consent step must authenticate the resource owner: a wrong API key
# must not yield an authorization code.
client_id = _register(oauth_client)
_, challenge = _pkce()
resp = oauth_client.post(
"/oauth/authorize",
data={
"client_id": client_id,
"redirect_uri": ALLOWED_URI,
"code_challenge": challenge,
"password": "not-the-key",
},
follow_redirects=False,
)
assert resp.status_code == 401
assert "location" not in resp.headers


def test_authorize_rejects_missing_password(oauth_client):
client_id = _register(oauth_client)
_, challenge = _pkce()
resp = oauth_client.post(
"/oauth/authorize",
data={
"client_id": client_id,
"redirect_uri": ALLOWED_URI,
"code_challenge": challenge,
},
follow_redirects=False,
)
assert resp.status_code == 401
assert "location" not in resp.headers


def test_authorize_rejects_empty_pkce(oauth_client):
# A direct POST with an empty code_challenge must not mint a code that could
# never be exchanged (PKCE is enforced on POST, not just the GET form).
client_id = _register(oauth_client)
resp = oauth_client.post(
"/oauth/authorize",
data={
"client_id": client_id,
"redirect_uri": ALLOWED_URI,
"code_challenge": "",
"password": "test-bearer-token",
},
follow_redirects=False,
)
assert resp.status_code == 400
assert "location" not in resp.headers


def test_authorize_form_escapes_state(oauth_client):
client_id = _register(oauth_client)
_, challenge = _pkce()
Expand Down Expand Up @@ -185,7 +239,8 @@ def test_pkce_mismatch_rejected(oauth_client):
_, challenge = _pkce()
resp = oauth_client.post(
"/oauth/authorize",
data={"client_id": client_id, "redirect_uri": ALLOWED_URI, "code_challenge": challenge},
data={"client_id": client_id, "redirect_uri": ALLOWED_URI,
"code_challenge": challenge, "password": "test-bearer-token"},
follow_redirects=False,
)
code = resp.headers["location"].split("code=")[1].split("&")[0]
Expand Down Expand Up @@ -227,7 +282,8 @@ def test_code_is_single_use(oauth_client):
verifier, challenge = _pkce()
resp = oauth_client.post(
"/oauth/authorize",
data={"client_id": client_id, "redirect_uri": ALLOWED_URI, "code_challenge": challenge},
data={"client_id": client_id, "redirect_uri": ALLOWED_URI,
"code_challenge": challenge, "password": "test-bearer-token"},
follow_redirects=False,
)
code = resp.headers["location"].split("code=")[1].split("&")[0]
Expand All @@ -247,7 +303,8 @@ def _obtain_tokens(oauth_client) -> dict:
verifier, challenge = _pkce()
resp = oauth_client.post(
"/oauth/authorize",
data={"client_id": client_id, "redirect_uri": ALLOWED_URI, "code_challenge": challenge},
data={"client_id": client_id, "redirect_uri": ALLOWED_URI,
"code_challenge": challenge, "password": "test-bearer-token"},
follow_redirects=False,
)
code = resp.headers["location"].split("code=")[1].split("&")[0]
Expand Down
Loading