cimdkit
OAuth Client ID Metadata Documents (CIMD) for Python authorization servers.
MCP clients such as Claude Code and VS Code no longer register with your OAuth server: they
send a URL as their client_id, and your server has to fetch that URL, validate the JSON it
finds there and remember it. Doing that safely is fiddly (SSRF, redirects, exact-match rules,
caching, the loopback port exception), and every server is writing it again from scratch.
cimdkit does it in one call, with no dependencies.
pip install cimdkit
Python 3.9+. Works with any web framework and any OAuth library (Authlib, FastMCP's OAuth proxy, your own).
Use it in your /authorize endpoint
from cimdkit import ClientResolver, CIMDError, Policy, SERVER_METADATA, is_client_id_url
resolver = ClientResolver(Policy(
forbidden_client_ids=("https://auth.example.com",), # your own issuer
auth_methods=("none", "private_key_jwt"),
))
def authorize(params):
client_id = params["client_id"]
if not is_client_id_url(client_id):
return handle_preregistered_client(params) # your existing clients
try:
client = resolver.resolve(client_id) # fetch + validate + cache
redirect_uri = client.check_redirect_uri(params.get("redirect_uri"))
except CIMDError as err:
# Never redirect on these errors: show them to the user.
return render_error(err.to_oauth_error(), status=err.status_code)
return render_consent(name=client.display_name, host=client.host, logo=client.logo_uri,
redirect_uri=redirect_uri)
# And advertise support in /.well-known/oauth-authorization-server:
metadata.update(SERVER_METADATA) # {"client_id_metadata_document_supported": True}
At the token endpoint, call resolver.resolve(client_id) again (it is served from the cache)
and use client.token_endpoint_auth_method: "none" means a public PKCE client, so reject any
client authentication it sends; "private_key_jwt" means verify its assertion against
client.jwks or client.jwks_uri. On a TransientFetchError answer 503; on any other
CIMDError answer invalid_client, and if err.revoke_tokens is true, revoke the client's
refresh tokens (the document now names a different client).
What it checks
The URL (check_client_id_url): https, a path other than /, no userinfo, no ./..
segments (including %2e), no fragment, a valid port. Compared as an exact string, never
normalized.
The fetch (fetch_document):
- Every address the host resolves to must be public. Loopback, private, link-local (including
the cloud metadata address
169.254.169.254), CGNAT, multicast and reserved ranges are refused, also when hidden as IPv4-mapped IPv6, NAT64 or 6to4. - The connection goes to the exact address that was checked, with SNI and certificate checks for the real host name, so DNS rebinding cannot swap the address between check and connect.
- Redirects are not followed. Only
200is accepted;5xx,408and429are reported as transient. Accept: application/json, at most 5 KB read, one overall deadline,application/json(orapplication/*+json) content type, and a JSON object with no duplicate keys.
The document (validate_document):
client_idmust exactly equal the URL it was fetched from.- No
client_secret, no shared-secret auth methods (client_secret_basic,_post,_jwt), no private key material injwks, not bothjwksandjwks_uri. - With no auth method declared, the method is
none(not RFC 7591'sclient_secret_basic). Withtoken_endpoint_auth_methods_supported, the client's first method that you support is chosen. grant_typesandresponse_typesare intersected with what you support; an empty result is an error.redirect_uris: absolute, no fragment, nojavascript:/data:/file:, plainhttponly on loopback. Private-use schemes for native apps (com.example.app:/cb) are allowed.- Unknown properties are ignored, not rejected. Informative URLs (
logo_uri,client_uri...) that are not https are dropped with a warning instead of failing the whole sign-in.
Redirect URIs (client.check_redirect_uri): exact match, except that
http://127.0.0.1, http://[::1] and http://localhost redirects may use any port, as RFC
8252 and RFC 9700 require for native apps and CLIs that listen on a random port.
Caching (ClientResolver): valid documents are kept for as long as Cache-Control
(s-maxage, max-age, Age, no-store...) or Expires allow, clamped to
[min_ttl, max_ttl] (default 0 s to 24 h, 5 minutes when the response says nothing). Errors
and invalid documents are never cached. A transient failure keeps serving the last good
document; a terminal failure drops it.
Your policy (Policy): supported grant types, response types and auth methods, ids the
client_id must never equal (your issuer, trusted resource servers), allowed_hosts
("*.example.com") to accept only trusted client domains, same-origin redirects, and whether
localhost counts as loopback.
Check your own client's document
If you publish a CIMD for your MCP client, check it the way servers will:
$ cimdkit check https://app.example.com/oauth/client.json --redirect-uri http://127.0.0.1:53682/callback
OK https://app.example.com/oauth/client.json
name Example Agent (host app.example.com)
auth method none
grant types authorization_code, refresh_token
redirect_uri http://127.0.0.1/callback
cache 3600 s
--file client.json checks a local file before you deploy it, and --json prints the result
for CI. Or build the document in Python and get an error if any server would reject it:
from cimdkit import make_document
doc = make_document("https://app.example.com/oauth/client.json",
client_name="Example Agent",
redirect_uris=["http://127.0.0.1/callback"],
logo_uri="https://app.example.com/logo.png")
For development
ClientResolver(allow_loopback=True) (or cimdkit check --allow-loopback) lets a server on
your machine fetch documents from 127.0.0.1/::1. The spec forbids this in production.
Limits
- It resolves and validates clients. It does not issue codes or tokens, verify
private_key_jwtassertions, or fetchjwks_uriand logos for you. - The fetch connects directly and ignores
HTTPS_PROXY. Pass your owntransport=toClientResolver/fetch_documentif your server must use a proxy. - The cache is in memory, per process. Pass
fetch=or wrapresolveto share one. - It follows draft-ietf-oauth-client-id-metadata-document as of November 2026. The draft can still change.
Why this exists
MCP's authorization spec made CIMD the default way for clients to identify themselves (SEP-991), and authorization servers in every language are being asked to support it:
- modelcontextprotocol/python-sdk#1801: implement server-side CIMD support
- authlib/authlib#900: support Client ID Metadata Document?
- ory/hydra#4061, supabase/auth#2850, spring-security#18375, LibreChat#16627, doorkeeper#1875
The first implementations keep hitting the same bugs, each one a test here: unknown properties rejected (keycloak#51236), loopback redirect ports (claude-code#37747), private-use redirect schemes rejected (fastmcp#6336), a malformed port causing a 500 (fastmcp#5764), no way to limit CIMD to trusted domains (fastmcp#6153), and partial client_id URL validation (python-sdk#3768).
License
MIT
Metadata
Release files for cimdkit 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| cimdkit-0.1.0.tar.gz | 26.1 kB | Without provenance | Details for cimdkit-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| cimdkit-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for cimdkit-0.1.0-py3-none-any.whl |
Total release size: 47.9 kB
Release files / cimdkit-0.1.0.tar.gz
| Download URL | cimdkit-0.1.0.tar.gz |
|---|---|
| Size | 26.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
5db79d7259a013f35a1354328156f0f3c68f5301e1fdda1b995efbb01d7dc33e
|
|
BLAKE2b-256 checksum How to use checksums |
126367582c75de2e26bf65d8f7c5ffe64de9cc655c48b344f6aef8dca8957bcf
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Release files / cimdkit-0.1.0-py3-none-any.whl
| Download URL | cimdkit-0.1.0-py3-none-any.whl |
|---|---|
| Size | 21.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
465a9d2277e249b5a127e6e8ff2dc238be164c691ffcb77df52c796f4666c5ac
|
|
BLAKE2b-256 checksum How to use checksums |
6312a55718ae135ad41359a8fc24868e14b40091fe3987b330bd6f002a7f388e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Security for cimdkit 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.