Long-form companion to our Claude MCP setup guide, for people building their own remote MCP server. Documents, in painful detail, every OAuth-related bug we hit getting mcp-analytics.com/mcp to work as a Claude and ChatGPT custom connector, why each one presented as “the connector silently fails”, and how we fixed each.
Status as of writing (May 2026): our MCP server is in production and works as a custom connector in Claude Desktop, claude.ai, ChatGPT, Cursor, and Claude Code. Anyone can paste the URL and OAuth against it end-to-end. We’ve also submitted to Anthropic’s official MCP connector directory and to ChatGPT’s connector catalog; both submissions are in review at the time of writing, neither has been accepted yet. The bugs documented below are what we hit during that submission grind, written while they’re still fresh. If we get accepted (or rejected) we’ll add a postscript.
Where a quirk maps to a public issue in anthropics/claude-ai-mcp or the MCP spec repo, we link the issue so you can read the original report and follow any subsequent fix. We did not file those issues ourselves; we found them while debugging and are citing them for context. Status (open / closed) reflects the last time this post was updated.
If you’re a user trying to use an MCP server: read the setup guide instead. This article is for server implementers.
At a glance: the 11 quirks, by type and evidence
A note on source quality, since this matters for anyone trying to verify our claims: some of these quirks are spec-defined and reproducible from the RFCs alone, some are confirmed in public bug trackers, and some are things we observed empirically while debugging and can’t tie to a single public issue. The table makes the difference explicit.
| # | Quirk | Type | Public reference | Our confidence |
|---|---|---|---|---|
| 1 | 302 not 303 on consent redirect | Client preference | None we can isolate; #215 tracked the broader “consent succeeds, 0 tools” symptom set (since closed without a definitive fix) | Empirical only |
| 2 | Lowercase "bearer" in token_type |
Compat convention | None specific. RFC 6749 §7.1 says token types are case-insensitive, so capital is spec-legal | Empirical; matches every working reference impl we read |
| 3 | Drop iss from auth response |
Client validation quirk | modelcontextprotocol #2157 (closed; discusses post-callback validation failures generally) | Empirical |
| 4 | DCR response must list refresh_token in grant_types |
Compat convention | None specific | Empirical |
| 5 | OPTIONS preflight on every endpoint | Browser spec requirement | Fetch / CORS spec | Spec-defined; not a client bug |
| 6 | /.well-known/oauth-protected-resource/mcp suffix |
Client bug (ChatGPT) | None we can link | Empirical, reproducible |
| 7 | Referrer-Policy: no-referrer → Origin: null → CSRF 422 |
Browser + Rails interaction | Chromium issue tracker has multiple threads on Origin: null under no-referrer |
Reproducible |
| 8 | CSP form-action 'self' blocks cross-origin redirect |
Spec-defined | CSP3 §6.4 | Spec-defined; reading-comprehension fail on our part originally |
| 9 | RFC 8707 resource arrives in three shapes |
Client variance | RFC 8707 §2 allows repetition; clients differ in how they serialise | Spec-allowed variance |
| 10 | Scopes need discovery AND opt-in | UX trap | None | Self-inflicted; documented for completeness |
| 11 | Refresh-token rotation race | Implementation trap | None | Self-inflicted; we run 24-hour access tokens with 90-day refresh tokens |
“Empirical only” means: we changed the variable, the symptom went away. We don’t claim Anthropic’s frontend has a definitive line of code that reads if status == 303: silently_die(). We can claim that 302 works for us, 303 didn’t, and that’s reproducible against our deployment. If you find a deeper attribution for any of these, we’d like the link.
Why OAuth at all (when bearer tokens work)?
Three reasons remote MCP servers in 2026 should support OAuth 2.1 as the primary auth path:
- Anthropic’s MCP directory requirements. To be listed in Claude’s official connector catalog, OAuth 2.1 with PKCE is mandatory. Bearer tokens are not accepted as the primary mechanism.
- ChatGPT custom connectors require OAuth. ChatGPT’s MCP custom connector flow is OAuth-only. No Bearer token shortcut exists on that platform.
- Revocation, scope separation, audit logs. Bearer tokens have none of these by default. A leaked token is valid forever unless you’ve built the rotation infrastructure yourself. OAuth gives you scope-bound access tokens with per-client revocation built into the spec.
We support all three auth methods (OAuth Bearer, legacy Bearer, legacy ?token= query param), but OAuth is the path that gets you into both major clients.
Build the spec-compliant server first, then add the quirk patches
A useful framing: the OAuth 2.1 plus RFC 7591 (Dynamic Client Registration) plus RFC 8707 (audience-binding) plus RFC 9728 (Protected Resource Metadata) spec stack is well-defined. Implement it correctly and you have a correct server. You won’t have a working one, because the client implementations have known deviations from the spec. Every patch below is a workaround for a client quirk, not for a spec ambiguity.
Reference implementations worth reading before you start:
- Cloudflare’s
workers-oauth-provider: RFC-compliant, every quirk pre-fixed. The closest thing to a canonical implementation. - Sentry’s MCP server: open-source, has gone through the directory submission process.
A reasonable question: why not just use Cloudflare’s workers-oauth-provider and skip this post? Because it’s TypeScript on Cloudflare Workers. Adopting it means moving your whole MCP server onto Workers, which is fine for many projects but a non-starter if (a) your server needs to live next to a database that isn’t Workers-compatible (in our case, ClickHouse on an EU VPS), (b) you have a hard EU-only data residency claim, or (c) your deploy story is a single Docker image and you don’t want a second pipeline. If none of those apply to you, seriously consider using their code. If they do, you re-implement, and you copy Cloudflare’s design choices wherever the spec gives you a choice. The rest of this post is what we copied and why.
If your stack is Rails (like ours), Python+FastAPI, or Node+Express, you’ll write the OAuth flow from scratch but the client behavior you’re targeting is the same.
Quirk 1: claude.ai expects 302, not 303
Symptom: OAuth flow seems to complete (the consent screen returns successfully) but the connector ends up with 0 tools and you can’t tell why.
Cause (what we observed): Our consent endpoint’s POST handler was doing redirect_to(client_redirect_uri, status: :see_other) (HTTP 303, Rails’ default for POST-redirects). The connector consistently failed. We changed it to 302 (“Found”), nothing else, and the connector started working. Both 302 and 303 are spec-valid for an OAuth redirect (RFC 6749 §4.1.2 just says “redirect”); we can’t tell you exactly which line in claude.ai’s frontend treats them differently. We can tell you that Cloudflare’s reference implementation emits 302 and that switching to 302 fixed it for us.
Fix: explicitly return 302:
# Rails:
redirect_to(client_redirect_uri, status: :found) # 302, NOT :see_other (303)
Reference: no specific public bug we can isolate to this fork. claude-ai-mcp #215 tracked the broader “consent succeeds but the client never calls /token” symptom set; our 303 case was one instance of that family. That issue (like #46 and #163 in the same family) has since been closed without a definitive resolution, so the 302 workaround remains the practical guidance. If you find a more specific upstream issue, let us know and we’ll update.
Quirk 2: Lowercase "bearer" in token_type
Symptom: Token exchange completes (200 OK from /oauth/token), but subsequent Authorization: Bearer … calls on the MCP endpoint return 401 from the client side without ever reaching your server.
Honest version of the cause: We started with "token_type": "Bearer" (capital B, the spelling RFC 6750 uses for the Bearer scheme; RFC 6749 §5.1’s own example value is just "example"). The flow failed silently with at least one client we were testing. We switched to lowercase "bearer" to match Cloudflare’s reference, and the flow worked. We did not isolate exactly which client was rejecting capital B, so we can’t name a specific strict implementation here. RFC 6749 §7.1 is explicit that token types are case-insensitive, so a client rejecting capital B is technically out of spec.
The defensible claim is narrower than “strict clients reject capital”: lowercase is what every working reference implementation we read emits (Cloudflare’s workers-oauth-provider, Sentry’s MCP server), and no client we’ve tested has rejected it. Capital may also work for you. Lowercase is the lower-risk option.
Fix: emit lowercase:
render json: {
access_token: token.access_token,
token_type: "bearer", # lowercase, matches Cloudflare's reference
expires_in: 3600,
scope: token.scope_string
}
Quirk 3: No iss parameter in the auth-response redirect
Symptom: Browser-based clients silently fail to complete the OAuth flow. Server logs show the consent POST succeeded and a redirect was issued, but the client never calls /oauth/token.
Cause (what we observed): We added an iss parameter to the authorization-response redirect URL (e.g. ?code=…&state=…&iss=https://your-server.com), following RFC 9207. With iss present, claude.ai’s flow broke silently after the redirect. Without iss, the same flow worked. We did not get a debugger inside claude.ai’s frontend to confirm the failure mode; the empirical fix is “don’t send it.”
Fix: drop the iss param. Cloudflare’s reference implementation also doesn’t include it. On the spec question: within RFC 9207 the iss parameter is a MUST, but only for servers that implement RFC 9207 in the first place. Adopting that RFC is optional, and the MCP spec doesn’t require it, so omitting the parameter is spec-legal.
# Build the redirect URL WITHOUT iss:
redirect_params = { code: code, state: state } # NOT { iss: issuer_url, ... }
Reference: modelcontextprotocol #2157 is the closest public thread. It’s closed and the discussion is about post-callback validation failures in general, not specifically iss. We’re listing it for context, not as a citation that proves the iss link. The empirical signal is ours.
Quirk 4: grant_types MUST include "refresh_token" in the DCR response
Symptom: Some clients refuse to use your server entirely after Dynamic Client Registration. Connector creation never completes.
Cause: Your DCR (Dynamic Client Registration, RFC 7591) response declares grant_types: ["authorization_code"] only. Strict clients infer “no refresh-token support” and decide your server is incompatible with their session model, then skip the entire OAuth flow.
Fix: include refresh_token in the grant_types array, even if your refresh-token implementation is basic:
render json: {
client_id: client.client_id,
client_secret: client.client_secret,
grant_types: ["authorization_code", "refresh_token"], # both
response_types: ["code"],
token_endpoint_auth_method: "client_secret_post",
redirect_uris: client.redirect_uris,
# ...
}
Quirk 5: CORS preflights on EVERY OAuth endpoint
Symptom: Browser-based clients (claude.ai, chatgpt.com) silently fail to complete any OAuth or MCP operation. Server logs show no request at all. Not even an attempt.
Cause (what we observed when we built this): at the time, the connector setup flows visibly ran requests from the browser using fetch(). Modern browsers send an OPTIONS preflight before any non-simple cross-origin POST. If your server returns 404 on OPTIONS (because you only declared POST routes), the browser silently aborts the real request. Your server never sees it. You have no log line to debug from, and the connector status reads as a generic failure.
A caveat for mid-2026: Anthropic’s docs now describe connector traffic as coming from their cloud infrastructure, so how much of the flow still runs in the browser may have changed since we debugged this. The OAuth redirect leg still transits the user’s browser either way, and serving CORS plus preflights costs you a handful of route declarations. Treat it as required.
Fix: declare OPTIONS responders on every cross-origin OAuth and MCP endpoint:
# config/routes.rb:
match "/.well-known/oauth-authorization-server" => "oauth/discovery#preflight", via: :options
match "/.well-known/oauth-protected-resource" => "oauth/discovery#preflight", via: :options
match "/.well-known/oauth-authorization-server/mcp" => "oauth/discovery#preflight", via: :options
match "/.well-known/oauth-protected-resource/mcp" => "oauth/discovery#preflight", via: :options
match "/oauth/register" => "oauth/clients#preflight", via: :options
match "/oauth/token" => "oauth/tokens#preflight", via: :options
match "/oauth/revoke" => "oauth/revocations#preflight", via: :options
match "/mcp" => "mcp#preflight", via: :options
The preflight handler returns:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://claude.ai
Access-Control-Allow-Methods: POST, GET
Access-Control-Allow-Headers: Authorization, Content-Type, MCP-Session-ID
Access-Control-Max-Age: 86400
Be specific about the allowed origin (a list of https://claude.ai, https://chatgpt.com, https://cursor.com, etc.) rather than *, especially for endpoints that may have Authorization headers. The spec forbids Access-Control-Allow-Origin: * with Allow-Credentials: true.
Quirk 6: ChatGPT’s /.well-known/oauth-protected-resource/mcp suffix
Symptom: ChatGPT’s MCP custom connector creation fails with “Failed to resolve OAuth client” immediately after URL entry.
Cause: ChatGPT’s flow tries the path-aware OAuth-protected-resource discovery (/.well-known/oauth-protected-resource/mcp, the resource path suffix per RFC 9728 §3.1) first and doesn’t fall back to the root-level /.well-known/oauth-protected-resource. If only the root path responds, the connector aborts.
Fix: serve both:
# config/routes.rb:
get "/.well-known/oauth-protected-resource" => "oauth/discovery#protected_resource"
get "/.well-known/oauth-protected-resource/mcp" => "oauth/discovery#protected_resource"
Both return the same JSON body: { resource: "https://your-server.com/mcp", authorization_servers: [...] }. The duplicate route is cheap; the breakage cost is high.
Quirk 7: Referrer-Policy: no-referrer breaks same-origin CSRF on the consent page
Symptom: Users click “Approve” on your OAuth consent screen and nothing visibly happens. Browser inspector shows a 422 InvalidAuthenticityToken on the POST, or the redirect never fires.
Cause: You’re emitting Referrer-Policy: no-referrer on the consent page (good hygiene for credential pages). Modern Chromium and Safari 18+ under that policy send Origin: null even on same-origin form POSTs. Rails-style origin-checked CSRF then sees null != base_url and rejects.
Fix: use Referrer-Policy: same-origin (not no-referrer) on consent pages and any page with a same-origin form POST. Same-origin still strips Referer when navigating cross-origin (so the consent URL doesn’t leak to claude.ai), but lets same-origin form POSTs carry a proper Origin.
Set it in three places consistently:
- Response header in the controller:
response.set_header("Referrer-Policy", "same-origin") <meta name="referrer" content="same-origin">in the page’s<head>- (And check
form-actionin CSP, see Quirk 8.)
If you only fix one of the three layers, whatever the page-level meta tag or response header says wins for the next form POST, and you keep seeing Origin: null.
Quirk 8: CSP form-action blocks the cross-origin OAuth redirect
Symptom: Consent POST goes through (no 422), but the 302 redirect back to the OAuth client (https://claude.ai/api/mcp/auth_callback) is blocked silently by the browser. The console shows “Refused to load … because it does not appear in the form-action directive”. claude.ai never receives the authorization code.
Cause: CSP3 §6.4 says form-action covers “navigations from form-submission, including redirects.” So your global CSP form-action 'self' (a perfectly sensible default) blocks the cross-origin redirect that OAuth requires.
Fix: scope a relaxed form-action to the consent page only:
# app/controllers/oauth/authorizations_controller.rb:
content_security_policy(only: [:show, :decide]) do |policy|
policy.form_action :self, :https
end
The relaxation is scoped to the OAuth consent show/decide actions, not site-wide. You allow https: (any HTTPS origin) rather than naming claude.ai/chatgpt.com explicitly, since you don’t always know the client in advance with Dynamic Client Registration.
Quirk 9: Audience-binding (RFC 8707), get the syntax right
Symptom: More subtle. Audience-binding is required by the spec for MCP OAuth, but some clients send the resource parameter as a single string and others as a JSON array.
Cause: RFC 8707 says resource can repeat. claude.ai sends it as repeated query params (?resource=https://...&resource=https://...). Some custom clients send a single value. We’ve also seen comma-separated values from one client we couldn’t pin down.
One Rack/Rails detail worth knowing: by default, Rack does not turn repeated query params without [] notation into an array. ?resource=a&resource=b parses to the String "b" (last value wins), not ["a", "b"]. claude.ai sends without brackets, so if you just read params[:resource] you’ll silently validate only one of the two values the client sent. Use request.GET.each_key plus request.GET.values_at, or accept the array form with brackets, or use the helper below.
Fix: parse all the forms you might receive and validate each:
def parsed_resource_params
# request.query_string preserves repeated params; Rack's params hash does not.
raw = Rack::Utils.parse_query(request.query_string)["resource"]
case raw
when nil then []
when String then raw.split(",").map(&:strip)
when Array then raw
end
end
(Rack::Utils.parse_query returns an Array when a key repeats, a String otherwise. That covers both client styles.) Then validate each against your canonical resource URL (https://your-server.com/mcp) and reject if none match.
Quirk 10: Scopes need to be discoverable AND requested explicitly
Symptom: OAuth completes, tools list loads, but write tools (like add_site) return 403 on call.
Cause: Many MCP servers split read from manage scope. The client may request only read if it doesn’t know manage exists. Or the client may not pass scope at all, and you defaulted to the narrow scope.
Fix: declare both scopes in your oauth-authorization-server metadata:
{
"issuer": "https://your-server.com",
"authorization_endpoint": "https://your-server.com/oauth/authorize",
"token_endpoint": "https://your-server.com/oauth/token",
"scopes_supported": ["analytics:read", "analytics:manage"],
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"]
}
And in your consent screen, present BOTH scopes as checkboxes (default both checked), so users opt into the union by default. Otherwise the client requests narrow scope, the user doesn’t realize they’re losing write capability, and they hit 403 on every write tool a week later.
Quirk 11: Refresh-token rotation has implementation traps
Symptom: Long-lived sessions work for ~24 hours, then the connector silently breaks. Re-adding it fixes for another 24 hours.
Cause: Your access tokens expire after some duration (60 minutes is common). The client requests a refresh-token exchange. If your refresh-token implementation is buggy (e.g. you invalidate the old refresh token before successfully issuing the new one, and the response is then dropped/retried), the client ends up with neither valid token and the next call fails.
Fix (the correct one): implement refresh-token rotation atomically. The old refresh token MUST remain valid until the new one is confirmed delivered. Wrap “issue new, invalidate old” in a DB transaction so both succeed or neither does, and serve idempotent retries of the same refresh-token exchange.
What we actually shipped: 24-hour access tokens with 90-day refresh tokens (the refresh window slides on each use). The refresh exchange runs roughly once a day per connector, which cuts both ways: the rotation path gets exercised constantly, so a rotation bug surfaces within a day instead of hiding for months, and a leaked access token has at most a 24-hour life. The trade-off versus the common 60-minute access token is explicit: our leak window is up to 24 times longer, in exchange for far fewer rotation races in the hot path. If your compliance regime demands one-hour access tokens, don’t copy us; do the atomic rotation properly and tighten the expiry.
What we won’t cover (out of scope here)
- MCP protocol details beyond OAuth. See the Claude MCP setup guide for the user-facing side and modelcontextprotocol.io for the spec.
- Specific framework migrations (Rails to Sinatra, Express to Fastify). Patterns transfer; specifics don’t.
- PKCE deep-dive. PKCE is straightforward and largely problem-free. Implement it per RFC 7636 with S256, move on.
Sanity-check curl commands
Three commands that exercise the full flow without a real client. Useful for regression testing:
# 1. Discovery
curl -s https://your-server.com/.well-known/oauth-authorization-server | jq
curl -s https://your-server.com/.well-known/oauth-protected-resource/mcp | jq
# 2. DCR (register a client)
curl -s -X POST https://your-server.com/oauth/register \
-H 'Content-Type: application/json' \
-d '{"client_name":"test-client","redirect_uris":["https://claude.ai/api/mcp/auth_callback"]}'
# 3. Token exchange (simulated, assuming you've got a real authorization code)
curl -s -X POST https://your-server.com/oauth/token \
-d 'grant_type=authorization_code' \
-d 'code=AUTH_CODE_FROM_REDIRECT' \
-d 'client_id=CLIENT_ID_FROM_DCR' \
-d 'client_secret=CLIENT_SECRET_FROM_DCR' \
-d 'redirect_uri=https://claude.ai/api/mcp/auth_callback' \
-d 'code_verifier=PKCE_VERIFIER' \
-d 'resource=https://your-server.com/mcp'
If all three return spec-compliant JSON, your server is spec-correct. Whether it’s client-compatible is the question this post answered.
Final advice
- Where the spec allows multiple legitimate options, pick the option Cloudflare’s reference picks. 302 vs 303, casing of
bearer, presence ofiss, ordering of grant types: all spec-permissive. claude.ai’s frontend has been de-facto tested against Cloudflare’s emitter style, so any divergence is a non-zero risk for no upside. This is not a recommendation to copy their code; it’s a recommendation to copy their choices in the places the spec lets you choose. - Test in actual Claude Desktop, ChatGPT, and Cursor before you ship. Each client surfaces different bugs. Passing all three is a much stronger signal than passing RFC compliance.
- Log everything during the connector setup flow. The OAuth dance has many steps. When something fails silently in the client UI, your server logs are the only place to see where it died.
- Don’t try to be elegant. Add the suffix-discovery duplicate, drop the
issparameter, lowercase the bearer. The spec is permissive; the clients are not.
We’re running these patches in production. The OAuth flow works end-to-end against Claude Desktop, claude.ai, ChatGPT, Cursor, and Claude Code. Anyone can verify by pasting https://mcp-analytics.com/mcp into any of those clients and OAuth-ing against it. As noted in the status disclosure at the top, we’ve submitted to both official directories but haven’t been accepted yet; the bugs above are what we hit getting the technical flow to work, independent of marketplace placement. If you’re building something similar and run into a bug we didn’t cover here, email us. We’ll add it to this post.