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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ Phase 1 (MVP) = static bearer-token auth. Phase 2 adds OAuth 2.1 + PKCE + DCR en
- **One `Memory` instance per process.** REST and MCP both share a single `mem0.Memory` (lazily built, `@lru_cache`) so the LLM/embedder clients aren't duplicated. This is the whole reason for the single-process, dual-protocol design — don't split them.
- **FastMCP lifespan must be passed to FastAPI's constructor.** FastMCP is mounted into FastAPI via `mcp.http_app(...)`; its lifespan has to be wired into the FastAPI `lifespan` or the first MCP request fails with `Task group is not initialized`. See PRD §8.2.
- **`stateless_http=True`** is required on `mcp.http_app()` — non-negotiable when running uvicorn with `--workers > 1`, or you get session-not-found errors.
- **MCP is mounted at the root (`app.mount("/", mcp_app)`) and must be registered LAST.** The endpoint is built at `path="/mcp"` with an extra `/mcp/` alias route so both `/mcp` and `/mcp/` serve directly (no 307) — strict OAuth clients POST to the exact resource URL and won't follow a redirect. The root mount is a catch-all, so every other route must be registered before it.
- **The OAuth protected-resource `resource` is the canonical URI without a trailing slash** (`<base>/mcp`). MCP clients canonicalize away the trailing slash and reject auth on a mismatch.
- **`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).
Expand Down
35 changes: 31 additions & 4 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from fastapi import FastAPI, Request, Response
from fastapi.responses import JSONResponse
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
from starlette.routing import Route

from app.config import get_settings
from app.logging_setup import configure_logging
Expand All @@ -21,7 +22,22 @@

mcp = build_mcp()
# stateless_http=True is required to avoid session-not-found errors with >1 worker.
mcp_app = mcp.http_app(path="/", stateless_http=True, transport="streamable-http")
# The endpoint is served at /mcp (not /mcp/): mounting at the root below, plus the
# /mcp/ alias route added here, lets BOTH /mcp and /mcp/ resolve directly without a
# 307 redirect. Strict MCP clients (Claude.ai web / Cowork) POST to the exact
# advertised resource URL and don't follow the redirect, so a redirect breaks them.
mcp_app = mcp.http_app(path="/mcp", stateless_http=True, transport="streamable-http")
_mcp_route = next(
(r for r in mcp_app.router.routes if getattr(r, "path", None) == "/mcp"), None
)
if _mcp_route is None:
raise RuntimeError(
"FastMCP did not register the expected /mcp route; cannot add the /mcp/ alias. "
"Check the fastmcp version and the http_app(path=...) argument."
)
mcp_app.router.routes.append(
Route("/mcp/", _mcp_route.endpoint, methods=list(_mcp_route.methods))
)


@asynccontextmanager
Expand Down Expand Up @@ -52,7 +68,14 @@ async def log_requests(request: Request, call_next):
# keep label cardinality bounded. Unmatched (404) requests have no route,
# so bucket them under a fixed label instead of the arbitrary raw path.
route = request.scope.get("route")
metric_path = getattr(route, "path", None) or "__unmatched__"
metric_path = getattr(route, "path", None)
if not metric_path:
# Requests served by the root-mounted MCP app have no route at this
# outer level. Bucket the two MCP path variants under a single stable
# label; anything else that fell through is genuinely unmatched.
metric_path = (
"/mcp" if request.url.path.rstrip("/") == "/mcp" else "__unmatched__"
)
observe_request(request.method, metric_path, status, elapsed)
_log.info(
"request",
Expand All @@ -74,8 +97,6 @@ async def log_requests(request: Request, call_next):
oauth_store.init_db()
app.include_router(oauth_router)

app.mount("/mcp", mcp_app)


@app.get("/metrics")
def metrics() -> Response:
Expand All @@ -101,3 +122,9 @@ async def healthz() -> JSONResponse:
return JSONResponse(
content={"ok": True, "version": app.version, "qdrant": "reachable"}
)


# Mounted at the root LAST so the specific routes above (/api/v1, /oauth,
# /.well-known, /metrics, /healthz) take precedence; the MCP app only owns
# /mcp and /mcp/ and 404s everything else.
app.mount("/", mcp_app)
Comment on lines +127 to +130
12 changes: 9 additions & 3 deletions docs/DEVELOPER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,18 @@ also enumerated in `CLAUDE.md`.)
raises `Task group is not initialized`.
3. **`stateless_http=True` on `mcp.http_app()`** is required because the container runs uvicorn with
`--workers 2`. Stateful sessions would produce session-not-found errors across workers.
4. **`MEM0_EMBED_DIMS` must equal the embedder's real output dimension.** A mismatch produces
4. **The MCP app is mounted at the root (`app.mount("/", mcp_app)`), registered LAST**, with the
FastMCP endpoint built at `path="/mcp"` plus an explicit `/mcp/` alias route. This serves both
`/mcp` and `/mcp/` directly (no 307 redirect) — strict clients like Claude.ai web POST to the
exact resource URL and won't follow a redirect. Because the root mount is a catch-all, every
other route (`/api/v1`, `/oauth`, `/.well-known`, `/metrics`, `/healthz`) MUST be registered
before it or it will be shadowed.
5. **`MEM0_EMBED_DIMS` must equal the embedder's real output dimension.** A mismatch produces
*silent* empty searches, not an exception. Changing the embedding model requires dropping and
recreating the Qdrant collection.
5. **FastMCP is the PrefectHQ `fastmcp` PyPI package** (`from fastmcp import FastMCP`), **not** the
6. **FastMCP is the PrefectHQ `fastmcp` PyPI package** (`from fastmcp import FastMCP`), **not** the
older `mcp.server.fastmcp` module.
6. **The same `MEM0_API_KEY` protects both protocols** — `require_bearer` for REST and the token
7. **The same `MEM0_API_KEY` protects both protocols** — `require_bearer` for REST and the token
verifier for MCP. Keep them in sync.

## Project layout
Expand Down
6 changes: 5 additions & 1 deletion tests/test_logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,15 @@ def test_request_is_logged_without_token(app_instance):

def test_request_logged_as_500_when_handler_raises(app_instance):
import structlog
from fastapi.routing import APIRoute

@app_instance.get("/boom-test")
async def _boom():
raise RuntimeError("kaboom")

# Insert at the front: the app mounts the MCP sub-app at "/" last, which would
# otherwise shadow a route appended after it.
app_instance.router.routes.insert(0, APIRoute("/boom-test", _boom, methods=["GET"]))

client = TestClient(app_instance, raise_server_exceptions=False)
with capture_logs() as logs:
client.get("/boom-test")
Expand Down
20 changes: 20 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,26 @@
from fastapi.testclient import TestClient


def test_mcp_endpoint_served_at_both_slash_variants(app_instance, auth_header):
# Claude.ai web / Cowork POST to the exact resource URL and do not follow
# redirects, so both /mcp and /mcp/ must resolve directly (no 307).
headers = {**auth_header, "Accept": "application/json, text/event-stream"}
body = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "t", "version": "1"},
},
}
with TestClient(app_instance) as client:
for path in ("/mcp", "/mcp/"):
resp = client.post(path, json=body, headers=headers, follow_redirects=False)
assert resp.status_code == 200, (path, resp.status_code)


def test_oauth_routes_not_mounted_when_disabled(app_instance):
# The test env sets no OAUTH_SIGNING_KEY, so OAuth is disabled and its
# routes must not be exposed. A regression here would leak unconfigured
Expand Down
14 changes: 14 additions & 0 deletions tests/test_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,17 @@ def test_metrics_endpoint_reports_requests(app_instance):
# The matched route template is used as the path label, not the raw id.
assert 'path="/api/v1/memories/{memory_id}"' in body
assert "abc123" not in body


def test_mcp_requests_get_stable_metric_label(app_instance):
# The MCP app is mounted at the root, so its requests have no matched route
# at the middleware level. They must still get a stable "/mcp" label (both
# slash variants), while genuine fallthrough 404s bucket under __unmatched__.
with TestClient(app_instance) as client:
headers = {"Accept": "application/json, text/event-stream"}
client.post("/mcp", json={}, headers=headers, follow_redirects=False)
client.post("/mcp/", json={}, headers=headers, follow_redirects=False)
client.get("/totally-unknown-path")
body = client.get("/metrics").text
assert 'path="/mcp"' in body
assert 'path="__unmatched__"' in body
Loading