The problem: one registration per install
An MCP client is installed per desktop, and it meets authorization servers it has never seen. Under Dynamic Client Registration (RFC 7591), each install posts its metadata to each server, and each server stores a client record it keeps forever. A company with 2,000 developers, each running two MCP clients against ten servers, ends up with 40,000 client registrations. None of them can be told apart from an impostor, because the registration endpoint takes the client’s claimed client_name on trust, and anyone can post "client_name": "Claude Desktop".
Client ID Metadata Documents remove the record. The client publishes a JSON document at an HTTPS URL it controls, and that URL is its client_id. The authorization server reads the document when the flow starts and stores nothing. The client’s identity now rests on control of its domain, which TLS and DNS already verify.
The standard is draft-ietf-oauth-client-id-metadata-document. MCP adopted it as SEP-991, and the current MCP specification deprecates Dynamic Client Registration in favor of it.
What the document holds
This is a complete document for a desktop MCP client, served at https://app.example.com/oauth/client.json:
{
"client_id": "https://app.example.com/oauth/client.json",
"client_name": "Example MCP Client",
"client_uri": "https://app.example.com",
"redirect_uris": [
"http://127.0.0.1:3000/callback",
"http://localhost:3000/callback"
],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}
| Field | What the authorization server does with it |
|---|---|
client_id | Compares it with the URL it fetched. They must be identical. |
client_name | Shows it to the user on the consent screen. |
redirect_uris | Sends authorization codes only to these addresses. Required. |
grant_types | Must include authorization_code. Add refresh_token if you want refresh tokens. |
token_endpoint_auth_method | none makes a public client that relies on PKCE. private_key_jwt makes a confidential one (see below). |
The self-reference rule
The client_id inside the document must equal the URL the document was fetched from, character for character. This rule is what makes the URL an identity. A document at https://evil.example/client.json cannot claim to be https://app.example.com/oauth/client.json, because its own client_id field would not match the address the server fetched.
The second guard is redirect_uris. Anyone can type a published client_id URL into an authorization request. The server will still send the code only to a redirect URI listed in the real document, so an attacker who copies your URL cannot collect your users’ codes on the attacker’s own host.
Host it
Any static HTTPS origin you control works: your product’s website, a docs site, or a storage bucket behind your own domain. Put the file at a stable path:
public/
oauth/
client.json -> https://app.example.com/oauth/client.json
Keep these rules, because authorization servers enforce them:
- Use
https, a non-root path, and no query string, fragment, user info, or.and..segments. - Answer
200with the JSON directly. A redirect is a failed lookup, not a pointer to follow. - Keep it small. ZeroID, for example, refuses documents over 5 KiB by default.
- Set a
Cache-Control: max-age. Servers cache the document, and a shortmax-agelets a change to yourredirect_uristake effect sooner. - Do not use a host where other people can publish paths, such as a shared raw-file CDN. An authorization server that allow-lists your host would admit them too.
Then check what a server will see:
curl -sS https://app.example.com/oauth/client.json | jq -r .client_id
# https://app.example.com/oauth/client.json
Connect a stock MCP client
The official MCP Python SDK supports CIMD through one argument: client_metadata_url. The code below uses mcp 2.x (pip install mcp). It opens the browser for sign-in and catches the code on the loopback redirect URI that the document lists, which is how desktop clients receive authorization codes.
import asyncio
import webbrowser
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs, urlparse
import httpx2
from mcp.client import Client
from mcp.client.auth import OAuthClientProvider
from mcp.client.streamable_http import streamable_http_client
from mcp.shared.auth import AuthorizationCodeResult, OAuthClientMetadata
MCP_URL = "https://mcp.example.com/mcp"
CLIENT_ID_URL = "https://app.example.com/oauth/client.json" # your hosted document
REDIRECT_URI = "http://127.0.0.1:3000/callback" # listed in the document
class MemoryStorage:
"""Keeps tokens in memory. Use a keychain or file in a real client."""
def __init__(self):
self.tokens = None
self.client_info = None
async def get_tokens(self):
return self.tokens
async def set_tokens(self, tokens):
self.tokens = tokens
async def get_client_info(self):
return self.client_info
async def set_client_info(self, client_info):
self.client_info = client_info
async def open_browser(authorization_url: str) -> None:
webbrowser.open(authorization_url)
async def wait_for_code() -> AuthorizationCodeResult:
"""Serve one request on the loopback redirect URI and read the code from it."""
params: dict[str, str] = {}
class Callback(BaseHTTPRequestHandler):
def do_GET(self):
query = parse_qs(urlparse(self.path).query)
params.update({k: v[0] for k, v in query.items()})
self.send_response(200)
self.end_headers()
self.wfile.write(b"Signed in. You can close this tab.")
def log_message(self, *args):
pass
server = HTTPServer(("127.0.0.1", 3000), Callback)
await asyncio.to_thread(server.handle_request)
server.server_close()
return AuthorizationCodeResult(code=params["code"], state=params.get("state"), iss=params.get("iss"))
async def main() -> None:
oauth = OAuthClientProvider(
server_url=MCP_URL,
client_metadata=OAuthClientMetadata(
client_name="Example MCP Client",
redirect_uris=[REDIRECT_URI],
grant_types=["authorization_code", "refresh_token"],
response_types=["code"],
token_endpoint_auth_method="none",
),
storage=MemoryStorage(),
redirect_handler=open_browser,
callback_handler=wait_for_code,
client_metadata_url=CLIENT_ID_URL,
)
async with httpx2.AsyncClient(auth=oauth) as http:
async with Client(streamable_http_client(MCP_URL, http_client=http)) as client:
tools = await client.list_tools()
print("tools:", [tool.name for tool in tools.tools])
asyncio.run(main())
The SDK does the discovery for you. The first request returns 401 with a pointer to the server’s protected resource metadata, which names the authorization server. The SDK then reads that server’s metadata, and it uses your URL as the client_id only when the metadata says client_id_metadata_document_supported: true. When the flag is missing, the SDK falls back to Dynamic Client Registration, so check it first:
curl -sS https://auth.example.com/.well-known/oauth-authorization-server \
| jq .client_id_metadata_document_supported
# true
What the authorization server checks
A server that accepts CIMD is fetching a URL that anyone can hand it, so the checks matter as much as the document. A careful server does all of these:
- Checks the URL shape before fetching anything:
https, a path, no query or fragment, no user info, no dot segments. - Applies an optional host allow-list. A deployment that admits only pre-approved clients lists the publisher hosts it trusts. An empty list admits any public HTTPS host.
- Fetches safely. It resolves the host once, refuses private, loopback, and cloud-metadata addresses, follows no redirects, and caps the size and the time.
- Enforces self-reference. The
client_idfield must equal the fetched URL. - Validates the redirect URIs. Each one must be
https, loopbackhttp, or a private-use scheme for native apps. The requestedredirect_urimust match the list exactly, except that a loopback callback may use any port, as RFC 8252 allows for desktop apps. - Checks the grants.
authorization_codemust be present. ZeroID ignores grants it does not support instead of rejecting the document, so one document can serve every server you talk to. - Caches the result, for successes and failures, so a busy client or a dead URL does not cause a fetch on every request.
Public or confidential: which client you get
The document decides. With "token_endpoint_auth_method": "none", you get a public client: no secret, and PKCE proves that the party finishing the flow is the party that started it. That is the right choice for a desktop install.
If you want the client authenticated, not only identified, declare private_key_jwt and publish exactly one key set:
{
"client_id": "https://app.example.com/oauth/client.json",
"client_name": "Example MCP Client",
"redirect_uris": ["http://127.0.0.1:3000/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"token_endpoint_auth_method": "private_key_jwt",
"jwks_uri": "https://app.example.com/oauth/jwks.json"
}
At the token endpoint the client then signs a client_assertion with one of those keys, and the server refuses the request without it. Prefer jwks_uri over an inline jwks if you rotate keys: the server caches the document, but it refreshes the key set behind jwks_uri on its own schedule. No CIMD client ever gets a shared secret, because there is no registration response to deliver one in.
Where CIMD stops
CIMD answers one question: which client is this? It does not sign the user in. The authorization server still runs its own login, and with Enterprise Managed Authorization that login is your corporate identity provider deciding which MCP servers the user’s agent may reach. CIMD also says nothing about what the client may do once it holds a token. A valid token for the right server can still be steered into the wrong tool call, which is why MCP authorization has to be paired with per-call authorization at runtime.
One more limit: a public CIMD client has no proof of possession. Anyone can present your URL. The redirect URIs keep the codes out of an attacker’s hands, but if your document lists only a loopback callback, a code goes to whichever machine started the flow. If that matters for your client, publish keys and use private_key_jwt.
How Highflame supports CIMD
ZeroID, Highflame’s open-source identity engine, implements CIMD and turns it on by default. It accepts public clients that use PKCE and confidential clients that use private_key_jwt with jwks or jwks_uri. It never writes a CIMD client to its database, and a deployment can limit which hosts may publish documents with an allow-list. The ZeroID CIMD reference covers every check and setting. To confirm that any authorization server accepts CIMD, including one you run, look for client_id_metadata_document_supported in its metadata.