CIMD, step by step: mock and real
With a Client ID Metadata Document the app's client_id is a web address. The authorization server fetches that address, checks what it finds, and only then lets the sign-in go ahead. Below, every request and response, once as a labelled mock of what Muse's connector would do, and once as captured from real servers.
Real ChatGPT, Codex and Claude Code, against Linear and Notion
Captured on 29 October 2026 from the public internet. The apps are the real ones: OpenAI hosts ChatGPT's and Codex's documents, Anthropic hosts Claude Code's. The servers are the real MCP servers of Linear and Notion, both of which advertise CIMD. Nothing here needed an account, because an authorize request only starts a sign-in.
Not captured: what happens after Approve. That needs a signed-in Linear or Notion user, so the code and token steps are not shown on this tab.
1. The three apps' own documents
Request, no credentials, same for all three
GET /oauth/client.json HTTP/2 Host: chatgpt.com GET /oauth/codex/client.json HTTP/2 Host: chatgpt.com GET /oauth/claude-code-client-metadata HTTP/2 Host: claude.ai
ChatGPT: 200, https://chatgpt.com/oauth/client.json
{
"client_id": "https://chatgpt.com/oauth/client.json",
"client_name": "ChatGPT",
"client_uri": "https://chatgpt.com/",
"logo_uri": "https://persistent.oaistatic.com/sonic/misc/openai-logo.png",
"redirect_uris": ["https://chatgpt.com/connector_platform_oauth_redirect"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "private_key_jwt",
"token_endpoint_auth_methods_supported": ["none", "private_key_jwt"],
"token_endpoint_auth_signing_alg": "RS256",
"jwks_uri": "https://chatgpt.com/oauth/jwks.json"
}
Codex: 200, https://chatgpt.com/oauth/codex/client.json
{
"client_id": "https://chatgpt.com/oauth/codex/client.json",
"client_name": "Codex",
"client_uri": "https://chatgpt.com/codex",
"application_type": "native",
"logo_uri": "https://persistent.oaistatic.com/sonic/misc/openai-logo.png",
"redirect_uris": ["http://127.0.0.1/callback", "http://localhost/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
"token_endpoint_auth_methods_supported": ["none"]
}
Claude Code: 200, https://claude.ai/oauth/claude-code-client-metadata
{
"client_id": "https://claude.ai/oauth/claude-code-client-metadata",
"client_name": "Claude Code",
"client_uri": "https://claude.ai",
"redirect_uris": ["http://localhost/callback", "http://127.0.0.1/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}
Two different kinds of client. Codex and Claude Code are public clients: no secret, PKCE only, a loopback callback with no port written in the document. ChatGPT is a confidential client: it asks for private_key_jwt, so it proves who it is at the token step by signing with a key published at its jwks_uri. (Its document also lists none.)
2. A real server says it understands CIMD
Request
GET /.well-known/oauth-authorization-server HTTP/2 Host: mcp.linear.app
Response (200, seven fields kept out of the full document)
{
"issuer": "https://mcp.linear.app",
"authorization_endpoint": "https://mcp.linear.app/authorize",
"token_endpoint": "https://mcp.linear.app/token",
"registration_endpoint": "https://mcp.linear.app/register",
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "none"],
"client_id_metadata_document_supported": true
}
Linear also keeps its registration endpoint, so a client that has no document can still register (DCR). Notion's metadata is the same shape, with private_key_jwt added to the methods it accepts, the one ChatGPT asks for.
3. ChatGPT asks Linear to sign someone in
Request (the code_challenge is a sample)
GET /authorize ?response_type=code &client_id=https://chatgpt.com/oauth/client.json &redirect_uri=https://chatgpt.com/connector_platform_oauth_redirect &code_challenge=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa &code_challenge_method=S256&state=x Host: mcp.linear.app
Response
HTTP/2 200 content-type: text/html; charset=UTF-8 <title>ChatGPT | Authorization Request</title> (page preloads https://persistent.oaistatic.com/sonic/misc/openai-logo.png) Website: https://chatgpt.com/ [ Approve ]
Nobody registered ChatGPT with Linear. Linear fetched the document at the client_id address and built its consent page from it: the name in the title, OpenAI's logo from logo_uri, and the website from client_uri. The page is Linear's own, not ChatGPT's.
4. Codex asks, with a callback port nobody listed
Request
GET /authorize ?response_type=code &client_id=https://chatgpt.com/oauth/codex/client.json &redirect_uri=http://127.0.0.1:1455/callback &code_challenge=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa &code_challenge_method=S256&state=x Host: mcp.linear.app
Response
HTTP/2 200 content-type: text/html; charset=UTF-8 <title>Codex | Authorization Request</title> Website: https://chatgpt.com/codex Callback: http://127.0.0.1:1455/callback [ Approve ]
The document lists http://127.0.0.1/callback with no port. Linear accepted port 1455 anyway. For a loopback address any port matches, because a command-line app picks a free port when it starts (Codex lets you fix it with mcp_oauth_callback_port). Linear shows the exact callback on its page.
5. A callback the document does not list is refused
Request: the same Codex request, with redirect_uri=https://evil.example/cb
Linear
HTTP/2 400 Invalid redirect URI
Notion
HTTP/2 400
{"error":"invalid_request","error_description":"Invalid redirect URI"}
This is what stops anyone from borrowing ChatGPT's or Codex's address: a code is only ever sent to a callback in the document its owner published. Neither server redirected anywhere.
6. Notion accepts the same two apps
GET /authorize?client_id=https://chatgpt.com/oauth/client.json&redirect_uri=https://chatgpt.com/connector_platform_oauth_redirect&... Host: mcp.notion.com HTTP/2 302 location: https://api.notion.com/v1/oauth/authorize?client_id=1f8d872b-594c-80a4-...&redirect_uri=https%3A%2F%2Fmcp.notion.com%2Fcallback&...
The Codex request got the same 302. Notion accepted the client and handed the user on to api.notion.com with a Notion client ID and a callback back on mcp.notion.com. That looks like the MCP server acting as the authorization server the app talks to, and signing people in with Notion's own login behind it. That reading is my inference from the redirect, not something Notion documents here.
| Server | Authorization server metadata, 29 Oct 2026 | Authorize with ChatGPT and Codex documents |
|---|---|---|
Linear mcp.linear.app | client_id_metadata_document_supported: true | Accepted (consent page). Unlisted callback refused. |
Notion mcp.notion.com | true, and private_key_jwt accepted | Accepted (302 to Notion sign-in). Unlisted callback refused. |
| Sentry, Vercel, Cloudflare, Canva, Monday, Shopify, WorkOS, Stytch | true | Not tried. |
Also tried: real PingFederate with client.dev's example document
Host names of the PingFederate servers are replaced with .example names here. The responses are as captured.
PingFederate 13.1 added CIMD. Both a local 13.1.3 and the SE one (pf.example) advertise client_id_metadata_document_supported: true and list none and private_key_jwt.
First try: refused. An authorize request with client_id=https://client.dev/oauth/metadata.json returned 400 - Invalid client_id. Reading the SE server's configuration showed why: CIMD was on, but only three policies existed, each allowing one exact metadata URL (PKCE required), and client.dev was in none of them. PingFederate rejects a CIMD request when no enabled policy matches.
GET /as/authorization.oauth2?client_id=https://client.dev/oauth/metadata.json&response_type=code&redirect_uri=https://client.dev/oauth/callback&code_challenge=E9Melho...&code_challenge_method=S256&state=x Host: pf-local.example:9031 HTTP/2 400 400 - Invalid client_id
After adding a policy for client.dev only (one new policy and an opaque-token access token manager, with the owner's approval; the other three untouched), the same request on the SE server:
GET /as/authorization.oauth2?client_id=https://client.dev/oauth/metadata.json&response_type=code&redirect_uri=https://client.dev/oauth/callback&code_challenge=E9Melho...&code_challenge_method=S256&state=x Host: pf.example HTTP/2 302 location: https://client.dev/oauth/callback?error=server_error&error_description=There+are+no+authentication+methods+available+for+OAuth.&state=x
The 400 is gone, so PingFederate fetched and validated the document: the client is accepted. It then could not finish the sign-in and sent the error back to the callback the document lists. The cause is on the new access token manager I describe above, not on CIMD: it has no access-token mapping for the login adapter. The fix is one additive mapping that is waiting for the owner's approval, and nothing shared would be edited. Not captured yet: the sign-in and the code. A second PingFederate (pf-acme.example) does not advertise CIMD at all.
Mock What Muse's connector would do
This is scripted, not captured. Muse's connector does not do CIMD yet, so nothing below has been run. The app is Codex, using its real document from the other tab, so the shapes match. Codes, tokens and the PKCE values are samples. Codex needs only PKCE. ChatGPT's real document asks for private_key_jwt, so supporting ChatGPT would also mean fetching its key set and checking a signed assertion at the token step, which this walk-through does not show.
1. Muse advertises CIMD
GET /.well-known/oauth-authorization-server Host: muse.example 200 OK { "issuer": "https://muse.example", "authorization_endpoint": ".../connector/authorize", "token_endpoint": ".../connector/token", "code_challenge_methods_supported": ["S256"], "token_endpoint_auth_methods_supported": ["none"], "client_id_metadata_document_supported": true <- the one new line }
2. The app sends the user to authorize
GET /connector/authorize ?client_id=https://chatgpt.com/oauth/codex/client.json &redirect_uri=http://127.0.0.1:1455/callback &response_type=code&scope=mcp &code_challenge=SAMPLE_CHALLENGE&code_challenge_method=S256&state=SAMPLE_STATE
Today Muse would answer 400 here, because that client_id is not registered. The new behaviour starts when the client_id is an HTTPS address.
3. Muse fetches and checks the document
GET /oauth/codex/client.json Host: chatgpt.com Accept: application/json 200 OK (same body as the real capture)
| Check | Pass when | On failure |
|---|---|---|
| Address | HTTPS, a real host, and the address does not resolve to a private or internal network | invalid_client, nothing fetched |
| Fetch | Answers in a few seconds, small body, JSON | invalid_client |
| Same address | Document client_id equals the address it was fetched from | invalid_client |
| Callback | The request's redirect_uri is one of redirect_uris. For a loopback address any port matches. | 400, no redirect (never send a code to an unlisted address) |
| Public client | token_endpoint_auth_method is none | invalid_client |
| Allow-list | The host is one Muse trusts (a Muse setting, like PingFederate's CIMD policy) | invalid_client |
4. Sign-in goes ahead as it does today
With the app accepted, Muse sends the user to PingOne through the DaVinci flow with Protect, the same as for any connector. The user signs in and approves, then Muse redirects to the callback the document listed:
302 Location: http://127.0.0.1:1455/callback?code=SAMPLE_CODE&state=SAMPLE_STATE
5. The app swaps the code for a token
POST /connector/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&code=SAMPLE_CODE
&client_id=https://chatgpt.com/oauth/codex/client.json
&redirect_uri=http://127.0.0.1:1455/callback
&code_verifier=SAMPLE_VERIFIER
200 OK
{ "access_token": "SAMPLE_TOKEN", "token_type": "Bearer", "scope": "mcp" }
No client secret anywhere. PKCE proves the app that asked is the app that gets the token, and the document's callback list proves the code went where its owner said.
6. Refusals worth showing
| Request | Muse answers |
|---|---|
client_id=http://chatgpt.com/... (not HTTPS) | invalid_client |
Document says a different client_id than its address | invalid_client |
redirect_uri=https://evil.example/cb, not in the document | 400 error page, no redirect |
client_id on a host that resolves to 127.0.0.1 or a cluster address | invalid_client, never fetched |
What each side does
- Real ChatGPT, Codex and Claude Code host their own documents. Linear and Notion accept ChatGPT's and Codex's, and refuse a callback that is not in the document.
- Real PingFederate 13.1.x advertises CIMD. It refused client.dev until a policy allowed it, then fetched and validated the document. The sign-in then failed on a missing token mapping, which is not CIMD.
- Mock Muse's connector accepting a document address. Not built. Muse does not do CIMD today.
- Open The token step with a real signed-in user, for ChatGPT (
private_key_jwt) and Codex. Whether ChatGPT falls back tononewhen a server lacksprivate_key_jwtis not verified.
PingFederate's CIMD support follows an evolving draft specification, and its documentation warns later changes may not be backward compatible. See PingFederate CIMD.