Claude Connectors (iOS, Desktop & Web) with Self Hosted MCP Gateway (ContextForge + Pocket‑ID)
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'soauth_configneeds aclient_idor 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:
- Claude
POSTs to the MCP endpoint → gets401withWWW-Authenticate: Bearer resource_metadata="…". - Claude fetches the protected‑resource metadata → learns the authorization server (Pocket‑ID).
- Claude opens Pocket‑ID's
/authorizein a browser → you log in. - Pocket‑ID redirects back to Claude with a code → Claude exchanges it at
/token→ access token. - 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.comthrough a TLS‑terminating reverse proxy (nginx‑proxy‑manager, Caddy, Traefik, …). - Pocket‑ID running at
https://auth.example.comwith 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.com, auth.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 forwardsX-Forwarded-Proto $scheme(it does by default). Any app that buildshttp://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:
| Field | Value |
|---|---|
| Name | Claude |
| Callback URLs | https://claude.ai/api/mcp/auth_callback and https://claude.com/api/mcp/auth_callback |
| PKCE | Enabled |
| Public client | No (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.resourceis 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 theclient_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- theaudvalue to enforce. For Pocket‑ID that's the Claude client ID (its access‑tokenaud), not the resource URL. (ContextForge is a fail‑closed system:trusted_for_api_authwithout a matchingapi_audienceis rejected outright.)
If you don't know your token's realaud, decode any access token Pocket‑ID issues to that client (jwt.decode(token, verify=False)) and look ataud. 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'sclaude.aicallback won't work for logging into ContextForge), and use itsclient_id/client_secretin 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 - sub, aud, exp, iat, iss, jti, type, 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 intry/exceptso a failed patch never blocks startup.Report it upstream. Per OAuth norms a resource server should consult/userinfo; this is worth an issue onmcp-context-forgeso a future release removes the need for the patch entirely.
Also: the ContextForge user the email resolves to (e.g. you@example.com) must 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
| Symptom | Cause | Fix |
|---|---|---|
OAuth metadata shows http:// | App builds URLs from the plain‑HTTP proxy hop | Set APP_DOMAIN=https://…; ensure proxy sends X-Forwarded-Proto |
404 on /.well-known/oauth-protected-resource/servers/{id}/mcp | Virtual server is private | Set server visibility → public (still token‑protected) |
| Claude connects but immediately errors, no server logs | Client used loopback OAuth the gateway doesn't support | Use 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_id | Add client_id to the server oauth_config and set provider api_audience = client_id |
issuer not trusted / provider not found | SSO disabled or provider not registered | SSO_ENABLED=true; create provider with trusted_for_api_auth: true |
| Web SSO: "User creation failed" / unverified email | Pocket‑ID email not verified | Mark user email_verified = true |
| Web SSO: "account‑linking required" | Existing local account, auth_provider mismatch | Set the user's auth_provider to the SSO provider id |
Token verifies, then OAuth token missing valid email claim → 401 | Access token has no email; ContextForge won't call /userinfo | Apply 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 verified, Audience doesn't match, unverified 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.