Claude Connectors (iOS, Desktop & Web) with Self Hosted MCP Gateway (ContextForge + Pocket‑ID)

Claude Connectors (iOS, Desktop & Web) with Self Hosted MCP Gateway (ContextForge + Pocket‑ID)
Photo by Solen Feyissa / Unsplash

Anthropic's custom connectors let Claude - including the iOS app - talk to remote Model Context Protocol (MCP) servers. The catch: Claude's remote connectors speak OAuth 2.1 with a hosted redirect (`https://claude.ai/api/mcp/auth_callback`). A lot of self‑hosted MCP gateways only implement the loopback OAuth flow (`http://127.0.0.1:…`) used by desktop/CLI tools, which the mobile and web apps can't use.
This guide shows the combination that does work for iOS/Desktop/Web. It took a long debugging session to get right; hopefully this saves you that time.

  • ContextForge (IBM's mcp-context-forge) - the MCP gateway. Acts as the OAuth resource server.
  • Pocket‑ID - a lightweight self‑hosted OIDC provider. Acts as the OAuth authorization server.
  • Claude - the OAuth client.
TL;DR of the gotchas I ran into (full detail below): the virtual server must be public; the server's oauth_config needs a client_id or the audience check fails; ensure the Pocket‑ID user's email is verified; and - the big one - Pocket‑ID's access tokens don't contain an email, while ContextForge insists identity live in the token, so you need a small patch that falls back to /userinfo.

The mental model

The flow Claude drives automatically:

  1. Claude POSTs to the MCP endpoint → gets 401 with WWW-Authenticate: Bearer resource_metadata="…".
  2. Claude fetches the protected‑resource metadata → learns the authorization server (Pocket‑ID).
  3. Claude opens Pocket‑ID's /authorize in a browser → you log in.
  4. Pocket‑ID redirects back to Claude with a code → Claude exchanges it at /token → access token.
  5. Claude re‑calls the MCP endpoint with Authorization: Bearer <token> → ContextForge validates it → tools flow.

Everything below is about making each of those five steps succeed.


Prerequisites

  • ContextForge running and reachable at https://mcp.example.com through a TLS‑terminating reverse proxy (nginx‑proxy‑manager, Caddy, Traefik, …).
  • Pocket‑ID running at https://auth.example.com with valid TLS.
  • Admin access to both.
  • At least one MCP server/tool registered in ContextForge. This guide uses the built‑in aggregated "All" virtual server; substitute your own server ID where you see {SERVER_ID}.

Throughout, replace mcp.example.comauth.example.com{SERVER_ID}{CLAUDE_CLIENT_ID}, and {CLAUDE_CLIENT_SECRET} with your values.


Step 1 - Make ContextForge emit correct HTTPS metadata

Claude rejects OAuth metadata served over http://. Behind a TLS‑terminating proxy, apps frequently build http:// URLs because they only see the plain‑HTTP hop from the proxy.

ContextForge solves this cleanly with an env var - set it to your public origin:

# docker compose (ContextForge gateway service)
environment:
  APP_DOMAIN: https://mcp.example.com

Verify (the resource and authorization_servers must be https://):

curl -s https://mcp.example.com/.well-known/oauth-protected-resource/servers/{SERVER_ID}/mcp | jq
If you're proxying with nginx-proxy-manager, also make sure it forwards X-Forwarded-Proto $scheme (it does by default). Any app that builds http:// links behind your proxy has this same root cause - worth remembering.

Step 2 - Make the virtual server public and OAuth‑protected

This one cost me an hour. ContextForge serves the RFC 9728 protected‑resource metadata at:

/.well-known/oauth-protected-resource/servers/{SERVER_ID}/mcp

…but it returns 404 "Server not found" for a private server. Claude fetches that endpoint unauthenticated, so the server has to be public to be discoverable. Making it public does not make the tools open - the MCP endpoint still requires a valid token.

In the ContextForge admin UI, set the server's visibility → Public.

Your MCP URL for Claude is now:

https://mcp.example.com/servers/{SERVER_ID}/mcp

Step 3 - Create the Claude OAuth client in Pocket‑ID

Pocket‑ID does not support Dynamic Client Registration (RFC 7591). Claude supports that gracefully - you just pre‑register the client and paste its credentials into Claude's "Advanced settings."

In Pocket‑ID → OIDC Clients → Add:

FieldValue
NameClaude
Callback URLshttps://claude.ai/api/mcp/auth_callback and https://claude.com/api/mcp/auth_callback
PKCEEnabled
Public clientNo (confidential - it needs a secret)

Save, then copy the generated Client ID and Client Secret. These are your {CLAUDE_CLIENT_ID} / {CLAUDE_CLIENT_SECRET}.


Step 4 - Point the server at Pocket‑ID and fix the audience

Configure the virtual server's oauth_config. The obvious two fields are authorization_servers and issuer - but you must also add client_id, and here's why:

ContextForge's MCP endpoint enforces the token's aud claim like this:

  • If oauth_config.resource is set → require exactly that.
  • Otherwise → accept the canonical resource URL and oauth_config.client_id.

Pocket‑ID ignores the RFC 8707 resource parameter Claude sends and instead sets the access token's aud to the client_id. So if you don't list client_id, ContextForge only accepts the resource URL, the token's aud (the client_id) doesn't match, and you get:

OAuth access token verification failed (issuer=…): Audience doesn't match

Set it (admin UI → server → OAuth, or via API):

{
  "authorization_servers": ["https://auth.example.com"],
  "issuer": "https://auth.example.com",
  "client_id": "{CLAUDE_CLIENT_ID}"
}
Watch out: editing this config in the UI can overwrite the whole object - re‑adding scopes shouldn't cost you the client_id/issuer. Double‑check all three fields survive each edit.

Step 5 - Register Pocket‑ID as an API‑trusted provider in ContextForge

For ContextForge to accept a token minted by an external IdP, that issuer must be registered as a trusted SSO provider with trusted_for_api_auth = true. First enable SSO:

environment:
  SSO_ENABLED: "true"

Restart the gateway, then create the provider. Get an admin token and grab Pocket‑ID's endpoints from its discovery doc first:

curl -s https://auth.example.com/.well-known/openid-configuration | jq \
  '{authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri, issuer}'

Create the provider (POST /auth/sso/admin/providers):

{
  "id": "pocketid",
  "name": "pocketid",
  "display_name": "Pocket-ID",
  "provider_type": "oidc",
  "client_id": "{CLAUDE_CLIENT_ID}",
  "client_secret": "{CLAUDE_CLIENT_SECRET}",
  "authorization_url": "https://auth.example.com/authorize",
  "token_url": "https://auth.example.com/api/oidc/token",
  "userinfo_url": "https://auth.example.com/api/oidc/userinfo",
  "issuer": "https://auth.example.com",
  "jwks_uri": "https://auth.example.com/.well-known/jwks.json",
  "scope": "openid profile email",
  "trusted_for_api_auth": true,
  "api_audience": "{CLAUDE_CLIENT_ID}"
}

The two fields that make token acceptance work:

  • trusted_for_api_auth: true - accept this IdP's tokens as MCP bearer credentials.
  • api_audience - the aud value to enforce. For Pocket‑ID that's the Claude client ID (its access‑token aud), not the resource URL. (ContextForge is a fail‑closed system: trusted_for_api_auth without a matching api_audience is rejected outright.)
If you don't know your token's real aud, decode any access token Pocket‑ID issues to that client (jwt.decode(token, verify=False)) and look at aud. Guessing wastes round‑trips - I guessed the resource URL twice before confirming it's the client_id.

Step 6 - (Optional) Pocket‑ID login to ContextForge itself, and two linking gotchas

Handy but not required for Claude. If you want to sign into the ContextForge admin UI with Pocket‑ID:

  • ContextForge's SSO callback is https://mcp.example.com/auth/sso/callback/{provider_id} (e.g. …/callback/pocketid). Register a dedicated Pocket‑ID client with that callback (the Claude client's claude.ai callback won't work for logging into ContextForge), and use its client_id/client_secret in the provider from Step 5.

Two failures you'll likely see:

"User creation failed" / unverified email claim. ContextForge refuses to create an SSO user whose email isn't verified. In Pocket‑ID, mark the user's email as verified (email_verified = true). If your UI doesn't expose the toggle, it's a one‑row DB update.

"account‑linking required". If your ContextForge admin already exists as a local (email+password) account, SSO login fails because the incoming provider (pocketid) differs from the stored auth_provider (local). Set that user's auth_provider to pocketid (email/password login still works - it only checks the password hash).


Step 7 - The big one: access tokens without an email

With everything above, the token now fully verifies - and still gets rejected:

OAuth access token verified (issuer=…, sub=…)
POST /servers/{SERVER_ID}/mcp - 401

The reason: after verifying the token, ContextForge maps it to a user with

user_email = claims.get("email") or claims.get("preferred_username") or claims.get("sub")
if "@" not in user_email:  →  "OAuth token missing valid email claim"  →  401

It requires the email to be inside the access token. But Pocket‑ID's access tokens are minimal by design - subaudexpiatissjtitype, and nothing else. Email lives in the id_token and /userinfo, never the access token, and no scope or custom‑claim setting changes that (I checked the schema - there's no way to target a claim to the access token). Pocket‑ID follows the convention that resource servers call /userinfo; ContextForge (for the MCP endpoint) doesn't.

This is a genuine mismatch between the two tools. The clean, standards‑aligned fix is to make ContextForge fall back to /userinfo (using the same bearer token) when the access token has no email. Here's the patch to mcpgateway/transports/streamablehttp_transport.py, right where user_email is resolved:

# after:  user_email = claims.get("email") or claims.get("preferred_username") or claims.get("sub")
if not user_email or not isinstance(user_email, str) or "@" not in user_email:
    # Minimal access tokens (e.g. Pocket-ID) omit email; fetch it from the IdP userinfo endpoint.
    try:
        from mcpgateway.services.sso_service import resolve_trusted_provider_by_issuer
        from mcpgateway.services.http_client_service import get_http_client
        _uurl = None
        async with get_db() as _db:
            _prov = resolve_trusted_provider_by_issuer(claims.get("iss"), _db)
            _uurl = getattr(_prov, "userinfo_url", None) if _prov else None
        if _uurl:
            _cli = await get_http_client()
            _r = await _cli.get(_uurl, headers={"Authorization": "Bearer " + token})
            if _r.status_code == 200:
                _e = _r.json().get("email")
                if _e and isinstance(_e, str) and "@" in _e:
                    user_email = _e
    except Exception:
        logger.warning("userinfo email fallback failed", exc_info=True)
# ...then the original "if not user_email … missing valid email claim" check runs

It reuses the userinfo_url you configured on the trusted provider in Step 5, so it's generic - not hardcoded to Pocket‑ID.

Important - durability. This edits code inside the running container, so a ContextForge image update recreates the container and silently drops the patch. Two ways to keep it:Auto‑reapply on start - override the gateway's entrypoint to run the patch script before launching, e.g. entrypoint: ["python3","-c","<apply patch>; os.execvp('mcpgateway', [...])"], wrapped in try/except so a failed patch never blocks startup.Report it upstream. Per OAuth norms a resource server should consult /userinfo; this is worth an issue on mcp-context-forge so a future release removes the need for the patch entirely.

Also: the ContextForge user the email resolves to (e.g. you@example.commust already exist - there's no auto‑creation on the MCP path. Doing the Step 6 SSO login once creates it.


Step 8 - Add the connector in Claude

On iOS, Desktop, or Web → Settings → Connectors → Add custom connector:

  • URL: https://mcp.example.com/servers/{SERVER_ID}/mcp
  • Advanced settings → OAuth Client ID / Secret: {CLAUDE_CLIENT_ID} / {CLAUDE_CLIENT_SECRET}

Click connect → you're bounced to Pocket‑ID to log in → back to Claude → the tools appear. 🎉

You should see, server‑side:

OAuth access token verified (issuer=https://auth.example.com, sub=…)
POST /servers/{SERVER_ID}/mcp - 200

Troubleshooting cheat‑sheet

SymptomCauseFix
OAuth metadata shows http://App builds URLs from the plain‑HTTP proxy hopSet APP_DOMAIN=https://…; ensure proxy sends X-Forwarded-Proto
404 on /.well-known/oauth-protected-resource/servers/{id}/mcpVirtual server is privateSet server visibility → public (still token‑protected)
Claude connects but immediately errors, no server logsClient used loopback OAuth the gateway doesn't supportUse a hosted‑redirect flow (ContextForge + real IdP) - not loopback‑only gateways
Audience doesn't match (token is signed correctly)oauth_config missing client_id; Pocket‑ID puts aud = client_idAdd client_id to the server oauth_config and set provider api_audience = client_id
issuer not trusted / provider not foundSSO disabled or provider not registeredSSO_ENABLED=true; create provider with trusted_for_api_auth: true
Web SSO: "User creation failed" / unverified emailPocket‑ID email not verifiedMark user email_verified = true
Web SSO: "account‑linking required"Existing local account, auth_provider mismatchSet the user's auth_provider to the SSO provider id
Token verifies, then OAuth token missing valid email claim → 401Access token has no email; ContextForge won't call /userinfoApply the Step 7 /userinfo fallback patch

Turning on debug logging while you troubleshoot

ContextForge is quiet by default. LOG_LEVEL=DEBUG surfaces the SSO/token flow (OAuth access token verifiedAudience doesn't matchunverified email claim, etc.) - invaluable here. Remember to set it back afterward.


Why this stack (and not the "loopback" gateways)

If you've tried wiring Claude's connectors to a gateway and it "just times out from the app," it's almost always because that gateway only implements the loopback OAuth flow (http://127.0.0.1:<port>), which is what desktop/CLI MCP tools use. The Claude iOS and web apps redirect to a hosted https://claude.ai/... callback, which those gateways reject. Pairing a real OIDC authorization server (Pocket‑ID) with a resource‑server gateway (ContextForge) is what makes the hosted‑redirect flow work - and therefore what gets your tools onto your phone.


Once it's up, Claude on your phone can call your self‑hosted MCP tools directly. Worth the yak‑shave.