Key takeaways
- Accept every MCP protocol version you implement. The spec tells servers to answer an unsupported one with a 400.
- Serve OAuth discovery documents at the domain root as well as at the path-inserted URL.
- Cursor registers three redirect URIs, one on its own scheme. Don’t refuse a whole registration over one URI.
- Every login method on the authorization page has to return the user to the pending request.
- Enforce what third-party clients may do on the server, because tool annotations are only hints.
One remote MCP server can serve Claude, ChatGPT, Cursor, VS Code and command-line clients from a single URL. We’ve built servers like this for two products, one of them a fashion commerce platform. Almost everything that broke on the way was in OAuth and in protocol-version handling.
Five things broke. Sign-in on the OAuth page worked with only one of the product’s login methods. Cursor’s client registration was refused. Command-line clients couldn’t find the OAuth discovery documents. Requests on the newer protocol version got 400s. And ChatGPT’s custom GPTs wouldn’t take the REST mirror of the tools as one OpenAPI file.
This post covers the server’s shape, each failure with the spec behind it and the fix, what changes for a store, how we tested it, and a checklist.
The shape every client accepted
The shape has six parts.
| Part | What it gives clients |
|---|---|
| Stateless JSON-RPC over HTTP | Any instance can answer any request, with no session to keep |
| Support for older and newer MCP protocol versions | Clients on either side of a spec change can connect |
| An OAuth 2.1 authorization server with dynamic client registration (RFC 7591) and PKCE (RFC 7636) | Clients register themselves and sign the user in through a browser |
| Redirect handling that follows RFC 8252 | Desktop and command-line clients can receive the sign-in result |
| Hashed personal API keys | Scripts can authenticate without a browser |
| A REST mirror of the tools with a generated OpenAPI file | ChatGPT custom GPT Actions can call the same tools |
Statelessness has aged well. Up to the 2025-11-25 revision, a Streamable HTTP server could assign a session ID when the client initialised, and didn’t have to.1 The 2026-07-28 revision removed protocol-level sessions and the initialize handshake altogether. Every request now carries its own protocol version and client capabilities.2
The same revision deprecates dynamic client registration in favour of Client ID Metadata Documents, where the client ID is an HTTPS URL pointing at a JSON description of the client.3 ChatGPT prefers them, and Claude uses them when your authorization server metadata advertises support and accepts public clients at the token endpoint.45 Anthropic notes that with DCR, Claude registers a new client on every fresh connection.5 DCR stays in the spec for compatibility, and public reports show Cursor and VS Code registering that way.67
Accept every protocol version you implement
MCP versions are dates. Revisions up to 2025-11-25 open with an initialize request in which client and server agree on a version.8 Since 2025-06-18, HTTP clients also send the agreed version in an MCP-Protocol-Version header on every later request, and a server that gets an unsupported version must answer 400 Bad Request.9 The current revision, 2026-07-28, drops the handshake. Each request declares its version, and a server that doesn’t implement it answers 400 with an error listing the versions it does support.1 The spec calls a server that handles both styles “dual-era” and lets it serve both on one endpoint.10
Our server returned 400 to requests on the newer protocol version until we added that version to the ones it accepts. To the person connecting, a 400 at that stage looks like a broken server.
Three habits prevent it:
- Keep one list of supported versions and use it for both the
initializereply and the per-request header check. In a stateless server those happen in different requests, possibly on different instances. - Put the supported versions in the error body, as the 2026-07-28 revision requires, so clients and anyone reading logs can see what to retry with.
- Log the version header and client name on each request, so you see clients move to a new revision before they fail.
# Only list revisions you have implemented.
LEGACY = ["2025-11-25", "2025-06-18", "2025-03-26"] # agreed in initialize
MODERN = ["2026-07-28"] # declared on every request
SUPPORTED = MODERN + LEGACY
def negotiate(requested: str) -> str:
"""initialize: echo the client's version if we speak it, else our newest legacy one."""
return requested if requested in LEGACY else LEGACY[0]
def version_error(header: str | None) -> dict | None:
"""Every request: None if the version is fine, else the JSON-RPC error for a 400."""
version = header or "2025-03-26" # clients before 2025-06-18 send no header
if version in SUPPORTED:
return None
return {
"code": -32022,
"message": "Unsupported protocol version",
"data": {"supported": SUPPORTED, "requested": version},
}
Treating a missing header as 2025-03-26 is allowed if you support clients that old.1
Serve the OAuth discovery documents where clients look
A client finds your authorization server in two hops. It reads your protected resource metadata (RFC 9728), which names the authorization server, then reads that server’s metadata (RFC 8414).11 Both RFCs insert the well-known segment between the host and the path.1213 For an MCP endpoint at https://example.com/mcp, the resource metadata belongs at https://example.com/.well-known/oauth-protected-resource/mcp. Documents served under the MCP path, such as /mcp/.well-known/oauth-authorization-server, sit where most clients never look.
Clients also differ in what they try. The 2025-03-26 revision told clients to drop the path from the MCP URL and fetch /.well-known/oauth-authorization-server from the root of the host.14 Current clients take the resource metadata URL from the WWW-Authenticate header of a 401 when it’s there, and otherwise try the path-inserted URL, then the root.11 Command-line clients went to the domain root, and our documents lived under the MCP path. Nginx rewrites closed the gap. A generic version:
# The app serves its OAuth metadata under /mcp/.well-known/.
# Answer the root and path-inserted URLs that clients request.
location ~ ^/\.well-known/oauth-(protected-resource|authorization-server)(/mcp)?$ {
rewrite ^/\.well-known/oauth-([a-z-]+)(/mcp)?$ /mcp/.well-known/oauth-$1 break;
proxy_pass http://mcp_app;
}
Clients then check what they find, so four details matter:
- Answer unauthenticated requests with 401 and a
WWW-Authenticateheader whoseresource_metadataparameter points at the document. Claude needs the 401 to start sign-in and ignores the header on a 200.5 - Set
resourcein that document to the MCP URL exactly as users paste it, path included.5 - Keep the
issuerin your authorization server metadata identical to the issuer clients start from, because clients must reject metadata whose issuer doesn’t match.11 A bare-origin issuer such ashttps://example.computs the metadata at the root, where old and new clients both look. - Advertise
S256incode_challenge_methods_supported. ChatGPT treats a server whose metadata leaves it out as unsupported.4
Redirect URIs: what Cursor, VS Code and Claude Code register
With dynamic client registration, a client registers itself by posting its metadata, including its redirect URIs.15 Cursor posts three. One uses its own cursor:// scheme, one is an https URL on cursor.com, and one is on localhost.616 Our validator rejected the custom scheme and, with it, the whole registration, so Cursor couldn’t connect.
RFC 8252, the best current practice for OAuth in native apps, covers each kind of redirect a desktop client uses,17 and the OAuth 2.1 draft adopts its matching rule.18
- Loopback. The authorization server must allow any port on a loopback IP redirect, because the app picks a free port when the flow starts (section 7.3).
- Private-use schemes. These are allowed, and the app must use a reversed domain name it controls, such as
com.example.app(section 7.1). Section 8.4 says servers should reject a private-use scheme with no period in it, which describescursor://. - Exact matching. Apart from the loopback port, the redirect URI in a request must match a registered one exactly (section 8.4).
That leaves a decision about schemes like Cursor’s. Cursor’s staff have called its redirect shape a known issue they’re tracking.6 Refusing the scheme keeps a server strictly inside RFC 8252, and it also breaks Cursor whenever Cursor uses that URI. Whatever you decide, don’t let one URI sink the registration. RFC 7591 lets the authorization server reject or replace requested metadata values, as long as it returns what it actually registered.15 Then try the result with the real client.
VS Code registers four redirect URIs. Two are vscode.dev pages, and the others are http://127.0.0.1/ and http://127.0.0.1:33418/. When that port is taken, for example by a second VS Code window, it listens on another and sends that port in the authorization request. A server that matched ports exactly turned it away, and VS Code’s maintainers closed the report by pointing to section 7.3.7 Claude Code also uses a loopback redirect whose port changes per session. Anthropic asks servers to match both localhost and 127.0.0.1 with the port ignored, even though RFC 8252 discourages localhost.5
| Client | How it registers | Redirect URIs |
|---|---|---|
| Claude on the web, desktop and mobile | Client ID Metadata Document, or DCR as a fallback | https://claude.ai/api/mcp/auth_callback |
| Claude Code | Its own Client ID Metadata Document | http://localhost/callback and http://127.0.0.1/callback, on any port |
| ChatGPT | Client ID Metadata Document preferred, DCR supported | https://chatgpt.com/connector_platform_oauth_redirect, or a per-connection callback URL |
| Cursor | DCR, or a static client in mcp.json |
cursor://anysphere.cursor-mcp/oauth/callback, https://www.cursor.com/agents/mcp/oauth/callback, http://localhost:8787/callback |
| VS Code | DCR | https://vscode.dev/redirect, https://insiders.vscode.dev/redirect, http://127.0.0.1/, http://127.0.0.1:33418/, or another loopback port when that one is busy |
The table is as of 8 October 2026, from Anthropic’s and OpenAI’s documentation,54 Cursor’s documentation and forum,166 and a VS Code issue.7 A matcher that applies the loopback rule is short:
from urllib.parse import urlsplit
LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost"}
def redirect_allowed(requested: str, registered: list[str]) -> bool:
if requested in registered:
return True
req = urlsplit(requested)
if req.scheme != "http" or req.hostname not in LOOPBACK_HOSTS:
return False
# RFC 8252 section 7.3: ignore the port on loopback redirects
for uri in registered:
reg = urlsplit(uri)
if (reg.scheme, reg.hostname, reg.path, reg.query) == (
req.scheme, req.hostname, req.path, req.query
):
return True
return False
The authorization page is a new way into your login
Every OAuth authorization server for MCP has a page where the user signs in and approves the client. On one of our servers, sign-in there worked at first with only one of the product’s login methods.
The authorization request arrives with the client’s ID, its redirect URI, a state value, the PKCE challenge19 and the resource. The user then leaves to log in, and every login method has to bring them back to that pending request with all of it intact. Login methods come back by different routes. A password form posts back to your own server. A social sign-in returns through the identity provider’s callback, after a detour through someone else’s domain. Each route needs a way to find the waiting request.
Store the pending request on the server under a random ID, carry only that ID through login, and resume from it whichever method succeeds. Then walk every login method through a real client, end to end.
The consent screen needs the same care. Anthropic’s guidance, citing the MCP spec, is to show the redirect URI’s hostname clearly, and to warn when every registered redirect is a loopback address, since any local process can listen on a port.5
Personal API keys for scripts
OAuth sign-in needs a browser, and a script doesn’t have one. For scripts, the server accepts personal API keys and stores only a hash of each.
Accept keys only in a request header. The MCP spec forbids access tokens in the query string,20 and Anthropic’s connector docs explain why. URLs end up in server logs, proxies and browser history.5 Two smaller rules help. A key stored as a hash can be shown only once, when it’s created, so design that screen around copying it. And let people revoke one key without touching their other keys or their OAuth sign-ins.
Clients can send such a key without any OAuth. Cursor’s mcp.json takes headers for each remote server,16 and Claude lets an organisation’s Owner set a static request header, a beta feature for a limited set of organisations.5
A REST mirror for custom GPTs, in two OpenAPI files
Custom GPTs call external APIs through Actions, which are described by an OpenAPI schema.21 For them, the server exposes its tools as REST endpoints with a generated OpenAPI file.
The GPT editor accepts at most 30 operations in one schema. You meet the limit as an error message, “OpenAPI spec can have a maximum of 30 operations”.22 OpenAI’s production notes for Actions list other limits, such as a 45-second timeout and payloads under 100,000 characters, but not this one.21 So the REST mirror is published as two OpenAPI files. Generate both from the same tool definitions the MCP server uses, so the mirror and the tools stay in step.
Check the calendar before building this part. OpenAI is retiring custom GPTs. Press coverage gives 11 December 2026 as the date, with deferrals for some Enterprise workspaces.23 Custom actions don’t carry over to the plugins that replace GPTs, and OpenAI’s migration guide says to rebuild them as a supported connector or a custom MCP connection.24 If you’re starting now, build the MCP server first and add a REST mirror only for callers that need plain HTTP.
Product cards, checkout handoff and switched-off order actions
On the fashion commerce platform, product cards render inside ChatGPT and Claude. Both support MCP Apps, an MCP extension in which a tool points to a ui:// resource holding HTML that the host renders in a sandboxed iframe.25 ChatGPT implements the standard and still honours its older openai/outputTemplate key as an alias,26 so one card can serve both hosts.
Checkout leaves the chat. It is handed off to the store’s own site through the shareable bag links the store already had, so no payment happens inside ChatGPT or Claude. OpenAI’s current guidelines for commerce in ChatGPT ask for external checkout on your own domain, and limit in-chat Instant Checkout to selected marketplace partners.27
The store’s shopping assistant can also be called through the server. When the caller is a third-party client, the assistant runs with its order-placing actions switched off. Put a rule like that on the server. Tool annotations such as readOnlyHint and destructiveHint describe a tool to the client, and the MCP spec says clients must treat them as untrusted unless the server is trusted.28 OpenAI’s guidelines add that annotations inform client safeguards and don’t replace authorization checks.27 The spec also allows a server to return a different tool list depending on the authorization on the request.28
Wrapping an assistant as a tool makes its response time part of every tool call, and clients enforce timeouts. GPT Actions, for example, give up after 45 seconds.21
How we tested it
The fashion commerce platform’s MCP server passes a 42-check end-to-end test. We also made one real model call through it, and that call cost $0.51.
One call is a single sample, so read $0.51 as an order of magnitude. Tool results go back to the model as input on the next step, which makes the size of what your tools return a cost lever. This post doesn’t report latency, per-client success rates or an average cost.
Checklist before you ship a remote MCP server
- Keep one list of protocol versions, use it for
initializeand for the header check, and log what each client sends. - Answer unauthenticated requests with 401 and a
resource_metadatapointer. - Serve resource metadata at the path-inserted URL and at the root, with
resourceequal to the URL users paste. - Serve authorization server metadata at the root with a matching issuer, and advertise
S256. - Accept loopback redirects on any port, for both
127.0.0.1andlocalhost. - Decide what to do about private-use schemes, and never refuse a whole registration over one URI.
- Support Client ID Metadata Documents as well as dynamic client registration.
- Walk every login method through the authorization page with a real client.
- Take API keys only in headers, and store only their hashes.
- Enforce limits for third-party callers on the server.
- Connect Claude, ChatGPT, Cursor, VS Code and a command-line client before you call it done.
-
Model Context Protocol specification 2026-07-28, “Streamable HTTP”, https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http ↩↩↩
-
Model Context Protocol specification 2026-07-28, “Key Changes”, https://modelcontextprotocol.io/specification/2026-07-28/changelog ↩
-
Model Context Protocol specification 2026-07-28, “Client Registration”, https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration ↩
-
OpenAI, “Authentication”, Apps SDK documentation, https://developers.openai.com/apps-sdk/build/auth ↩↩↩
-
Anthropic, “Authentication for connectors”, https://claude.com/docs/connectors/building/authentication ↩↩↩↩↩↩↩↩↩
-
Cursor community forum, “Cursor MCP DCR still omits application_type and uses a non-RFC-8252-compliant private redirect URI (3.14.27)”, August 2026, https://forum.cursor.com/t/cursor-mcp-dcr-still-omits-application-type-and-uses-a-non-rfc-8252-compliant-private-redirect-uri-3-14-27/167608 ↩↩↩↩
-
microsoft/vscode issue #278512, “MCP: OAuth Server Redirect URI Mismatch Bug”, https://github.com/microsoft/vscode/issues/278512 ↩↩↩
-
Model Context Protocol specification 2025-11-25, “Lifecycle: Version Negotiation”, https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle ↩
-
Model Context Protocol specification 2025-06-18, “Transports: Protocol Version Header”, https://modelcontextprotocol.io/specification/2025-06-18/basic/transports ↩
-
Model Context Protocol specification 2026-07-28, “Versioning and Compatibility”, https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning ↩
-
Model Context Protocol specification 2026-07-28, “Authorization Server Discovery”, https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/authorization-server-discovery ↩↩↩
-
RFC 9728, “OAuth 2.0 Protected Resource Metadata”, https://www.rfc-editor.org/rfc/rfc9728.html ↩
-
RFC 8414, “OAuth 2.0 Authorization Server Metadata”, https://www.rfc-editor.org/rfc/rfc8414.html ↩
-
Model Context Protocol specification 2025-03-26, “Authorization: Authorization Base URL”, https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization ↩
-
RFC 7591, “OAuth 2.0 Dynamic Client Registration Protocol”, sections 2 and 3.2.1, https://www.rfc-editor.org/rfc/rfc7591.html ↩↩
-
Cursor, “Model Context Protocol (MCP)”, https://cursor.com/docs/context/mcp ↩↩↩
-
RFC 8252, “OAuth 2.0 for Native Apps”, https://www.rfc-editor.org/rfc/rfc8252.html ↩
-
“The OAuth 2.1 Authorization Framework”, draft-ietf-oauth-v2-1-13, section 2.3.1, https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13 ↩
-
RFC 7636, “Proof Key for Code Exchange by OAuth Public Clients”, https://www.rfc-editor.org/rfc/rfc7636.html ↩
-
Model Context Protocol specification 2026-07-28, “Authorization: Access Token Usage”, https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization ↩
-
OpenAI, “Production notes on GPT Actions”, https://developers.openai.com/api/docs/actions/production ↩↩↩
-
OpenAI Developer Community, “OpenAPI spec can have a maximum of 30 operations”, https://community.openai.com/t/openapi-spec-can-have-a-maximum-of-30-operations/586484 ↩
-
OpenAI Help Center, “Custom GPT retirement and migration FAQ”, https://help.openai.com/en/articles/20001519-custom-gpt-retirement-and-migration-faq, and ETIH, “OpenAI to retire custom GPTs and move creators to plugins”, https://www.edtechinnovationhub.com/news/openai-to-retire-custom-gpts-in-december-as-creators-move-to-plugins ↩
-
OpenAI, “Moving your custom GPT workflows to plugins”, https://learn.chatgpt.com/docs/migrate-custom-gpts ↩
-
Model Context Protocol, “MCP Apps”, https://modelcontextprotocol.io/extensions/apps/overview ↩
-
OpenAI, “MCP Apps compatibility in ChatGPT”, https://developers.openai.com/apps-sdk/mcp-apps-in-chatgpt ↩
-
OpenAI, “App submission guidelines”, https://developers.openai.com/apps-sdk/app-submission-guidelines ↩↩
-
Model Context Protocol specification 2026-07-28, “Tools”, https://modelcontextprotocol.io/specification/2026-07-28/server/tools ↩↩
Frequently asked questions
Which OAuth redirect URIs do MCP clients use?
As of 8 October 2026, Claude’s hosted apps use https://claude.ai/api/mcp/auth_callback, Claude Code uses loopback URLs on a changing port, ChatGPT uses chatgpt.com callback URLs, Cursor registers a cursor:// URI alongside an https and a localhost one, and VS Code registers vscode.dev and loopback URLs.
Do I still need Dynamic Client Registration for an MCP server?
The 2026-07-28 MCP spec deprecates it in favour of Client ID Metadata Documents and keeps it for compatibility. Claude and ChatGPT accept either, and public reports show Cursor and VS Code registering through DCR, so support both for now.
Why does my MCP server return 400 to some clients?
One cause is a protocol version the server doesn’t accept. The spec tells servers to answer an unsupported MCP-Protocol-Version with 400 Bad Request, so a server that doesn’t list a newer revision rejects every client that uses it.
Where should an MCP server’s OAuth discovery documents live?
At the root of the host. RFC 8414 and RFC 9728 put the well-known segment between the host and any path, and older MCP clients drop the path entirely. Serve the documents at the root and at the path-inserted URL.
Can a custom GPT use an MCP server?
Custom GPTs call APIs through Actions described by OpenAPI schemas. OpenAI is retiring custom GPTs, with 11 December 2026 reported as the date, and custom actions don’t migrate. OpenAI’s migration guide points to MCP connections instead.
Building something like this?
9io is a small team of senior engineers with a fractional CTO, and we work by the hour. Send us a note about your product. The reply comes from the person who'd do the work.