From fbd0a6a0f574197bab5f1555510841be221eb417 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 19:28:58 +0000 Subject: [PATCH 1/2] feat: log rejected DCR redirect_uris to diagnose OAuth connect failures When a Dynamic Client Registration request is rejected because the client's redirect_uri isn't in OAUTH_ALLOWED_REDIRECT_URIS, the server returned a bare 400 with no record of what was actually requested. This is the common cause of Claude.ai web / Cowork connections failing right after discovery succeeds. Log a dcr_redirect_uri_rejected warning with the requested URIs and the active allowlist so the exact value can be added to the env var. Document the symptom and remedy in the user guide. https://claude.ai/code/session_01U3EtN3puoZRq2t7nedcnHY --- app/oauth.py | 14 +++++++++++++- docs/USER_GUIDE.md | 1 + 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/app/oauth.py b/app/oauth.py index 0f4793c..1dbfbd0 100644 --- a/app/oauth.py +++ b/app/oauth.py @@ -6,6 +6,7 @@ from functools import lru_cache import jwt +import structlog from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers from fastapi import APIRouter, Form, HTTPException, Request @@ -15,6 +16,7 @@ from app.config import get_settings router = APIRouter() +_log = structlog.get_logger() TOKEN_TTL = 24 * 3600 KEY_ID = "mem0-oauth-1" @@ -128,7 +130,17 @@ async def register(request: Request) -> JSONResponse: body = await request.json() redirect_uris = body.get("redirect_uris") or [] allowed = set(get_settings().allowed_redirect_uris_list) - if not redirect_uris or any(uri not in allowed for uri in redirect_uris): + rejected = [uri for uri in redirect_uris if uri not in allowed] + if not redirect_uris or rejected: + # Log the exact requested URIs and the active allowlist so a client whose + # callback isn't allowed (the common cause of failed Claude.ai / Cowork + # connections) can be diagnosed and added to OAUTH_ALLOWED_REDIRECT_URIS. + _log.warning( + "dcr_redirect_uri_rejected", + requested=redirect_uris, + rejected=rejected, + allowed=sorted(allowed), + ) raise HTTPException(status_code=400, detail="redirect_uri not allowed") # Register as a public client: PKCE (S256) protects the code exchange, so # no client_secret is issued or verified. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index c03c426..335a198 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -302,4 +302,5 @@ status, and latency. The `Authorization` header is never logged. | Server won't start | A required env var is missing or a provider key is absent. Check the startup logs; `app/config.py` names the missing variable. | | Claude.ai web / Cowork can't connect | OAuth not enabled (`OAUTH_SIGNING_KEY` blank), or the client's redirect URI isn't in `OAUTH_ALLOWED_REDIRECT_URIS`. | | "Couldn't reach the MCP server" on Claude.ai web / Cowork (but Claude Code/Desktop work) | OAuth discovery failure. Confirm `OAUTH_SIGNING_KEY` is set and `PUBLIC_BASE_URL` exactly matches the public HTTPS URL; the server must advertise the protected-resource metadata in the `/mcp/` 401 `WWW-Authenticate` header. | +| Connector fails right after consent; logs show `POST /oauth/register → 400` | The client's callback isn't in `OAUTH_ALLOWED_REDIRECT_URIS`. The server logs a `dcr_redirect_uri_rejected` warning with the exact `requested` URI and the active `allowed` list — add the requested URI to `OAUTH_ALLOWED_REDIRECT_URIS` and redeploy. Claude.ai web/desktop/mobile/Cowork use `https://claude.ai/api/mcp/auth_callback`. | | Backup job not running | Check the backup container: `caprover logs mem0-backup`. | From a1f5a8155c6a2bf65178d41ab3890d6253ebd2ce Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 19:47:12 +0000 Subject: [PATCH 2/2] fix: advertise canonical resource URI without trailing slash The protected-resource metadata advertised the resource as "/mcp/" (trailing slash), but MCP clients (Claude.ai web / Cowork) canonicalize the resource to the no-slash form "/mcp" per the MCP authorization spec ("implementations should consistently omit [trailing slashes]") and send that as the RFC 8707 resource indicator. The mismatch caused Claude to reject authorization after a token was already issued, even though authenticated /mcp/ calls succeeded. Advertise "/mcp" so the advertised resource matches the client's canonical form. https://claude.ai/code/session_01U3EtN3puoZRq2t7nedcnHY --- app/oauth.py | 5 ++++- tests/test_oauth.py | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/oauth.py b/app/oauth.py index 1dbfbd0..6c1889c 100644 --- a/app/oauth.py +++ b/app/oauth.py @@ -91,7 +91,10 @@ def _as_metadata() -> dict: def _protected_resource_metadata() -> dict: base = get_settings().public_base_url.rstrip("/") return { - "resource": f"{base}/mcp/", + # Canonical resource URI per the MCP auth spec: omit the trailing slash. + # MCP clients (Claude.ai / Cowork) canonicalize to the no-slash form and + # reject authorization if the advertised resource doesn't match. + "resource": f"{base}/mcp", "authorization_servers": [base], "scopes_supported": SCOPES, "bearer_methods_supported": ["header"], diff --git a/tests/test_oauth.py b/tests/test_oauth.py index 1639bed..8df661d 100644 --- a/tests/test_oauth.py +++ b/tests/test_oauth.py @@ -69,7 +69,7 @@ def test_protected_resource_metadata(oauth_client): "/.well-known/oauth-protected-resource/mcp", ): meta = oauth_client.get(path).json() - assert meta["resource"] == "https://mem0.test/mcp/" + assert meta["resource"] == "https://mem0.test/mcp" assert meta["authorization_servers"] == ["https://mem0.test"] assert meta["bearer_methods_supported"] == ["header"] assert meta["scopes_supported"] == ["read", "write"] @@ -404,7 +404,7 @@ async def lifespan(app): assert meta_resp.status_code == 200, ( f"{urlparse(rm_url).path} returned {meta_resp.status_code}, not a direct 200" ) - assert meta_resp.json()["resource"].endswith("/mcp/") + assert meta_resp.json()["resource"].endswith("/mcp") finally: # monkeypatch handles env restoration; lru_caches must be cleared manually # so other tests don't observe OAuth-enabled settings.