cimdkit 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cimdkit-0.1.0/.gitignore +12 -0
- cimdkit-0.1.0/CHANGELOG.md +20 -0
- cimdkit-0.1.0/LICENSE +21 -0
- cimdkit-0.1.0/PKG-INFO +188 -0
- cimdkit-0.1.0/README.md +163 -0
- cimdkit-0.1.0/pyproject.toml +46 -0
- cimdkit-0.1.0/src/cimdkit/__init__.py +53 -0
- cimdkit-0.1.0/src/cimdkit/__main__.py +5 -0
- cimdkit-0.1.0/src/cimdkit/cli.py +95 -0
- cimdkit-0.1.0/src/cimdkit/document.py +327 -0
- cimdkit-0.1.0/src/cimdkit/errors.py +68 -0
- cimdkit-0.1.0/src/cimdkit/fetch.py +213 -0
- cimdkit-0.1.0/src/cimdkit/resolver.py +143 -0
- cimdkit-0.1.0/src/cimdkit/urls.py +146 -0
- cimdkit-0.1.0/tests/conftest.py +52 -0
- cimdkit-0.1.0/tests/test_cli.py +71 -0
- cimdkit-0.1.0/tests/test_document.py +256 -0
- cimdkit-0.1.0/tests/test_fetch.py +167 -0
- cimdkit-0.1.0/tests/test_resolver.py +172 -0
- cimdkit-0.1.0/tests/test_tls.py +123 -0
- cimdkit-0.1.0/tests/test_urls.py +94 -0
cimdkit-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (2026-11-08)
|
|
4
|
+
|
|
5
|
+
First release.
|
|
6
|
+
|
|
7
|
+
- `ClientResolver`: resolve a URL `client_id` to validated client metadata, cached by the
|
|
8
|
+
response's HTTP cache headers, keeping the last good document on transient failures and
|
|
9
|
+
dropping it on terminal ones.
|
|
10
|
+
- `fetch_document`: https only, no redirects, `Accept: application/json`, 5 KB limit, one
|
|
11
|
+
deadline, SSRF guard on every resolved address (IPv4-mapped, NAT64 and 6to4 included) with
|
|
12
|
+
the connection pinned to the checked address.
|
|
13
|
+
- `validate_document`: exact client_id match, no secrets or shared-secret auth methods, no
|
|
14
|
+
private keys, auth method selection with the `none` default, grant and response type
|
|
15
|
+
intersection, redirect URI rules, unknown properties ignored.
|
|
16
|
+
- `redirect_uri_matches` / `ClientMetadata.check_redirect_uri`: exact match with the loopback
|
|
17
|
+
any-port exception.
|
|
18
|
+
- `Policy`: supported methods and grants, forbidden ids (your issuer), `allowed_hosts`,
|
|
19
|
+
same-origin redirects.
|
|
20
|
+
- `make_document` and the `cimdkit check` command for client authors publishing a document.
|
cimdkit-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sri Krishna Lingamaneni
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
cimdkit-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: cimdkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: OAuth Client ID Metadata Documents (CIMD) for authorization servers: safe fetch, spec validation, caching and redirect_uri matching for MCP clients
|
|
5
|
+
Project-URL: Homepage, https://github.com/srikrishnaL/kitforge/tree/main/packages/cimdkit
|
|
6
|
+
Project-URL: Source, https://github.com/srikrishnaL/kitforge
|
|
7
|
+
Project-URL: Issues, https://github.com/srikrishnaL/kitforge/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/srikrishnaL/kitforge/blob/main/packages/cimdkit/CHANGELOG.md
|
|
9
|
+
Author: Sri Krishna Lingamaneni
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: authorization server,cimd,client id metadata document,client_id,dynamic client registration,mcp,model context protocol,oauth,oauth2,ssrf
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
19
|
+
Classifier: Topic :: Security
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Provides-Extra: test
|
|
23
|
+
Requires-Dist: pytest>=7; extra == 'test'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# cimdkit
|
|
27
|
+
|
|
28
|
+
OAuth **Client ID Metadata Documents** (CIMD) for Python authorization servers.
|
|
29
|
+
|
|
30
|
+
MCP clients such as Claude Code and VS Code no longer register with your OAuth server: they
|
|
31
|
+
send a URL as their `client_id`, and your server has to fetch that URL, validate the JSON it
|
|
32
|
+
finds there and remember it. Doing that safely is fiddly (SSRF, redirects, exact-match rules,
|
|
33
|
+
caching, the loopback port exception), and every server is writing it again from scratch.
|
|
34
|
+
cimdkit does it in one call, with no dependencies.
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install cimdkit
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Python 3.9+. Works with any web framework and any OAuth library (Authlib, FastMCP's OAuth
|
|
41
|
+
proxy, your own).
|
|
42
|
+
|
|
43
|
+
## Use it in your `/authorize` endpoint
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from cimdkit import ClientResolver, CIMDError, Policy, SERVER_METADATA, is_client_id_url
|
|
47
|
+
|
|
48
|
+
resolver = ClientResolver(Policy(
|
|
49
|
+
forbidden_client_ids=("https://auth.example.com",), # your own issuer
|
|
50
|
+
auth_methods=("none", "private_key_jwt"),
|
|
51
|
+
))
|
|
52
|
+
|
|
53
|
+
def authorize(params):
|
|
54
|
+
client_id = params["client_id"]
|
|
55
|
+
if not is_client_id_url(client_id):
|
|
56
|
+
return handle_preregistered_client(params) # your existing clients
|
|
57
|
+
try:
|
|
58
|
+
client = resolver.resolve(client_id) # fetch + validate + cache
|
|
59
|
+
redirect_uri = client.check_redirect_uri(params.get("redirect_uri"))
|
|
60
|
+
except CIMDError as err:
|
|
61
|
+
# Never redirect on these errors: show them to the user.
|
|
62
|
+
return render_error(err.to_oauth_error(), status=err.status_code)
|
|
63
|
+
return render_consent(name=client.display_name, host=client.host, logo=client.logo_uri,
|
|
64
|
+
redirect_uri=redirect_uri)
|
|
65
|
+
|
|
66
|
+
# And advertise support in /.well-known/oauth-authorization-server:
|
|
67
|
+
metadata.update(SERVER_METADATA) # {"client_id_metadata_document_supported": True}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
At the token endpoint, call `resolver.resolve(client_id)` again (it is served from the cache)
|
|
71
|
+
and use `client.token_endpoint_auth_method`: `"none"` means a public PKCE client, so reject any
|
|
72
|
+
client authentication it sends; `"private_key_jwt"` means verify its assertion against
|
|
73
|
+
`client.jwks` or `client.jwks_uri`. On a `TransientFetchError` answer 503; on any other
|
|
74
|
+
`CIMDError` answer `invalid_client`, and if `err.revoke_tokens` is true, revoke the client's
|
|
75
|
+
refresh tokens (the document now names a different client).
|
|
76
|
+
|
|
77
|
+
## What it checks
|
|
78
|
+
|
|
79
|
+
**The URL** (`check_client_id_url`): https, a path other than `/`, no userinfo, no `.`/`..`
|
|
80
|
+
segments (including `%2e`), no fragment, a valid port. Compared as an exact string, never
|
|
81
|
+
normalized.
|
|
82
|
+
|
|
83
|
+
**The fetch** (`fetch_document`):
|
|
84
|
+
|
|
85
|
+
- Every address the host resolves to must be public. Loopback, private, link-local (including
|
|
86
|
+
the cloud metadata address `169.254.169.254`), CGNAT, multicast and reserved ranges are
|
|
87
|
+
refused, also when hidden as IPv4-mapped IPv6, NAT64 or 6to4.
|
|
88
|
+
- The connection goes to the exact address that was checked, with SNI and certificate checks
|
|
89
|
+
for the real host name, so DNS rebinding cannot swap the address between check and connect.
|
|
90
|
+
- Redirects are not followed. Only `200` is accepted; `5xx`, `408` and `429` are reported as
|
|
91
|
+
transient.
|
|
92
|
+
- `Accept: application/json`, at most 5 KB read, one overall deadline, `application/json` (or
|
|
93
|
+
`application/*+json`) content type, and a JSON object with no duplicate keys.
|
|
94
|
+
|
|
95
|
+
**The document** (`validate_document`):
|
|
96
|
+
|
|
97
|
+
- `client_id` must exactly equal the URL it was fetched from.
|
|
98
|
+
- No `client_secret`, no shared-secret auth methods (`client_secret_basic`, `_post`, `_jwt`),
|
|
99
|
+
no private key material in `jwks`, not both `jwks` and `jwks_uri`.
|
|
100
|
+
- With no auth method declared, the method is `none` (not RFC 7591's `client_secret_basic`).
|
|
101
|
+
With `token_endpoint_auth_methods_supported`, the client's first method that you support is
|
|
102
|
+
chosen.
|
|
103
|
+
- `grant_types` and `response_types` are intersected with what you support; an empty result
|
|
104
|
+
is an error.
|
|
105
|
+
- `redirect_uris`: absolute, no fragment, no `javascript:`/`data:`/`file:`, plain `http` only
|
|
106
|
+
on loopback. Private-use schemes for native apps (`com.example.app:/cb`) are allowed.
|
|
107
|
+
- Unknown properties are ignored, not rejected. Informative URLs (`logo_uri`, `client_uri`...)
|
|
108
|
+
that are not https are dropped with a warning instead of failing the whole sign-in.
|
|
109
|
+
|
|
110
|
+
**Redirect URIs** (`client.check_redirect_uri`): exact match, except that
|
|
111
|
+
`http://127.0.0.1`, `http://[::1]` and `http://localhost` redirects may use any port, as RFC
|
|
112
|
+
8252 and RFC 9700 require for native apps and CLIs that listen on a random port.
|
|
113
|
+
|
|
114
|
+
**Caching** (`ClientResolver`): valid documents are kept for as long as `Cache-Control`
|
|
115
|
+
(`s-maxage`, `max-age`, `Age`, `no-store`...) or `Expires` allow, clamped to
|
|
116
|
+
`[min_ttl, max_ttl]` (default 0 s to 24 h, 5 minutes when the response says nothing). Errors
|
|
117
|
+
and invalid documents are never cached. A transient failure keeps serving the last good
|
|
118
|
+
document; a terminal failure drops it.
|
|
119
|
+
|
|
120
|
+
**Your policy** (`Policy`): supported grant types, response types and auth methods, ids the
|
|
121
|
+
client_id must never equal (your issuer, trusted resource servers), `allowed_hosts`
|
|
122
|
+
(`"*.example.com"`) to accept only trusted client domains, same-origin redirects, and whether
|
|
123
|
+
`localhost` counts as loopback.
|
|
124
|
+
|
|
125
|
+
## Check your own client's document
|
|
126
|
+
|
|
127
|
+
If you publish a CIMD for your MCP client, check it the way servers will:
|
|
128
|
+
|
|
129
|
+
```console
|
|
130
|
+
$ cimdkit check https://app.example.com/oauth/client.json --redirect-uri http://127.0.0.1:53682/callback
|
|
131
|
+
OK https://app.example.com/oauth/client.json
|
|
132
|
+
name Example Agent (host app.example.com)
|
|
133
|
+
auth method none
|
|
134
|
+
grant types authorization_code, refresh_token
|
|
135
|
+
redirect_uri http://127.0.0.1/callback
|
|
136
|
+
cache 3600 s
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`--file client.json` checks a local file before you deploy it, and `--json` prints the result
|
|
140
|
+
for CI. Or build the document in Python and get an error if any server would reject it:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from cimdkit import make_document
|
|
144
|
+
|
|
145
|
+
doc = make_document("https://app.example.com/oauth/client.json",
|
|
146
|
+
client_name="Example Agent",
|
|
147
|
+
redirect_uris=["http://127.0.0.1/callback"],
|
|
148
|
+
logo_uri="https://app.example.com/logo.png")
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## For development
|
|
152
|
+
|
|
153
|
+
`ClientResolver(allow_loopback=True)` (or `cimdkit check --allow-loopback`) lets a server on
|
|
154
|
+
your machine fetch documents from `127.0.0.1`/`::1`. The spec forbids this in production.
|
|
155
|
+
|
|
156
|
+
## Limits
|
|
157
|
+
|
|
158
|
+
- It resolves and validates clients. It does not issue codes or tokens, verify
|
|
159
|
+
`private_key_jwt` assertions, or fetch `jwks_uri` and logos for you.
|
|
160
|
+
- The fetch connects directly and ignores `HTTPS_PROXY`. Pass your own `transport=` to
|
|
161
|
+
`ClientResolver`/`fetch_document` if your server must use a proxy.
|
|
162
|
+
- The cache is in memory, per process. Pass `fetch=` or wrap `resolve` to share one.
|
|
163
|
+
- It follows draft-ietf-oauth-client-id-metadata-document as of November 2026. The draft can
|
|
164
|
+
still change.
|
|
165
|
+
|
|
166
|
+
## Why this exists
|
|
167
|
+
|
|
168
|
+
MCP's authorization spec made CIMD the default way for clients to identify themselves
|
|
169
|
+
([SEP-991](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/991)), and
|
|
170
|
+
authorization servers in every language are being asked to support it:
|
|
171
|
+
|
|
172
|
+
- [modelcontextprotocol/python-sdk#1801](https://github.com/modelcontextprotocol/python-sdk/issues/1801): implement server-side CIMD support
|
|
173
|
+
- [authlib/authlib#900](https://github.com/authlib/authlib/issues/900): support Client ID Metadata Document?
|
|
174
|
+
- [ory/hydra#4061](https://github.com/ory/hydra/issues/4061), [supabase/auth#2850](https://github.com/supabase/auth/issues/2850), [spring-security#18375](https://github.com/spring-projects/spring-security/issues/18375), [LibreChat#16627](https://github.com/LibreChat-AI/LibreChat/issues/16627), [doorkeeper#1875](https://github.com/doorkeeper-gem/doorkeeper/issues/1875)
|
|
175
|
+
|
|
176
|
+
The first implementations keep hitting the same bugs, each one a test here:
|
|
177
|
+
unknown properties rejected ([keycloak#51236](https://github.com/keycloak/keycloak/issues/51236)),
|
|
178
|
+
loopback redirect ports
|
|
179
|
+
([claude-code#37747](https://github.com/anthropics/claude-code/issues/37747)), private-use
|
|
180
|
+
redirect schemes rejected ([fastmcp#6336](https://github.com/PrefectHQ/fastmcp/issues/6336)), a
|
|
181
|
+
malformed port causing a 500 ([fastmcp#5764](https://github.com/PrefectHQ/fastmcp/issues/5764)),
|
|
182
|
+
no way to limit CIMD to trusted domains
|
|
183
|
+
([fastmcp#6153](https://github.com/PrefectHQ/fastmcp/issues/6153)), and partial client_id URL
|
|
184
|
+
validation ([python-sdk#3768](https://github.com/modelcontextprotocol/python-sdk/issues/3768)).
|
|
185
|
+
|
|
186
|
+
## License
|
|
187
|
+
|
|
188
|
+
MIT
|
cimdkit-0.1.0/README.md
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# cimdkit
|
|
2
|
+
|
|
3
|
+
OAuth **Client ID Metadata Documents** (CIMD) for Python authorization servers.
|
|
4
|
+
|
|
5
|
+
MCP clients such as Claude Code and VS Code no longer register with your OAuth server: they
|
|
6
|
+
send a URL as their `client_id`, and your server has to fetch that URL, validate the JSON it
|
|
7
|
+
finds there and remember it. Doing that safely is fiddly (SSRF, redirects, exact-match rules,
|
|
8
|
+
caching, the loopback port exception), and every server is writing it again from scratch.
|
|
9
|
+
cimdkit does it in one call, with no dependencies.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install cimdkit
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Python 3.9+. Works with any web framework and any OAuth library (Authlib, FastMCP's OAuth
|
|
16
|
+
proxy, your own).
|
|
17
|
+
|
|
18
|
+
## Use it in your `/authorize` endpoint
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from cimdkit import ClientResolver, CIMDError, Policy, SERVER_METADATA, is_client_id_url
|
|
22
|
+
|
|
23
|
+
resolver = ClientResolver(Policy(
|
|
24
|
+
forbidden_client_ids=("https://auth.example.com",), # your own issuer
|
|
25
|
+
auth_methods=("none", "private_key_jwt"),
|
|
26
|
+
))
|
|
27
|
+
|
|
28
|
+
def authorize(params):
|
|
29
|
+
client_id = params["client_id"]
|
|
30
|
+
if not is_client_id_url(client_id):
|
|
31
|
+
return handle_preregistered_client(params) # your existing clients
|
|
32
|
+
try:
|
|
33
|
+
client = resolver.resolve(client_id) # fetch + validate + cache
|
|
34
|
+
redirect_uri = client.check_redirect_uri(params.get("redirect_uri"))
|
|
35
|
+
except CIMDError as err:
|
|
36
|
+
# Never redirect on these errors: show them to the user.
|
|
37
|
+
return render_error(err.to_oauth_error(), status=err.status_code)
|
|
38
|
+
return render_consent(name=client.display_name, host=client.host, logo=client.logo_uri,
|
|
39
|
+
redirect_uri=redirect_uri)
|
|
40
|
+
|
|
41
|
+
# And advertise support in /.well-known/oauth-authorization-server:
|
|
42
|
+
metadata.update(SERVER_METADATA) # {"client_id_metadata_document_supported": True}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
At the token endpoint, call `resolver.resolve(client_id)` again (it is served from the cache)
|
|
46
|
+
and use `client.token_endpoint_auth_method`: `"none"` means a public PKCE client, so reject any
|
|
47
|
+
client authentication it sends; `"private_key_jwt"` means verify its assertion against
|
|
48
|
+
`client.jwks` or `client.jwks_uri`. On a `TransientFetchError` answer 503; on any other
|
|
49
|
+
`CIMDError` answer `invalid_client`, and if `err.revoke_tokens` is true, revoke the client's
|
|
50
|
+
refresh tokens (the document now names a different client).
|
|
51
|
+
|
|
52
|
+
## What it checks
|
|
53
|
+
|
|
54
|
+
**The URL** (`check_client_id_url`): https, a path other than `/`, no userinfo, no `.`/`..`
|
|
55
|
+
segments (including `%2e`), no fragment, a valid port. Compared as an exact string, never
|
|
56
|
+
normalized.
|
|
57
|
+
|
|
58
|
+
**The fetch** (`fetch_document`):
|
|
59
|
+
|
|
60
|
+
- Every address the host resolves to must be public. Loopback, private, link-local (including
|
|
61
|
+
the cloud metadata address `169.254.169.254`), CGNAT, multicast and reserved ranges are
|
|
62
|
+
refused, also when hidden as IPv4-mapped IPv6, NAT64 or 6to4.
|
|
63
|
+
- The connection goes to the exact address that was checked, with SNI and certificate checks
|
|
64
|
+
for the real host name, so DNS rebinding cannot swap the address between check and connect.
|
|
65
|
+
- Redirects are not followed. Only `200` is accepted; `5xx`, `408` and `429` are reported as
|
|
66
|
+
transient.
|
|
67
|
+
- `Accept: application/json`, at most 5 KB read, one overall deadline, `application/json` (or
|
|
68
|
+
`application/*+json`) content type, and a JSON object with no duplicate keys.
|
|
69
|
+
|
|
70
|
+
**The document** (`validate_document`):
|
|
71
|
+
|
|
72
|
+
- `client_id` must exactly equal the URL it was fetched from.
|
|
73
|
+
- No `client_secret`, no shared-secret auth methods (`client_secret_basic`, `_post`, `_jwt`),
|
|
74
|
+
no private key material in `jwks`, not both `jwks` and `jwks_uri`.
|
|
75
|
+
- With no auth method declared, the method is `none` (not RFC 7591's `client_secret_basic`).
|
|
76
|
+
With `token_endpoint_auth_methods_supported`, the client's first method that you support is
|
|
77
|
+
chosen.
|
|
78
|
+
- `grant_types` and `response_types` are intersected with what you support; an empty result
|
|
79
|
+
is an error.
|
|
80
|
+
- `redirect_uris`: absolute, no fragment, no `javascript:`/`data:`/`file:`, plain `http` only
|
|
81
|
+
on loopback. Private-use schemes for native apps (`com.example.app:/cb`) are allowed.
|
|
82
|
+
- Unknown properties are ignored, not rejected. Informative URLs (`logo_uri`, `client_uri`...)
|
|
83
|
+
that are not https are dropped with a warning instead of failing the whole sign-in.
|
|
84
|
+
|
|
85
|
+
**Redirect URIs** (`client.check_redirect_uri`): exact match, except that
|
|
86
|
+
`http://127.0.0.1`, `http://[::1]` and `http://localhost` redirects may use any port, as RFC
|
|
87
|
+
8252 and RFC 9700 require for native apps and CLIs that listen on a random port.
|
|
88
|
+
|
|
89
|
+
**Caching** (`ClientResolver`): valid documents are kept for as long as `Cache-Control`
|
|
90
|
+
(`s-maxage`, `max-age`, `Age`, `no-store`...) or `Expires` allow, clamped to
|
|
91
|
+
`[min_ttl, max_ttl]` (default 0 s to 24 h, 5 minutes when the response says nothing). Errors
|
|
92
|
+
and invalid documents are never cached. A transient failure keeps serving the last good
|
|
93
|
+
document; a terminal failure drops it.
|
|
94
|
+
|
|
95
|
+
**Your policy** (`Policy`): supported grant types, response types and auth methods, ids the
|
|
96
|
+
client_id must never equal (your issuer, trusted resource servers), `allowed_hosts`
|
|
97
|
+
(`"*.example.com"`) to accept only trusted client domains, same-origin redirects, and whether
|
|
98
|
+
`localhost` counts as loopback.
|
|
99
|
+
|
|
100
|
+
## Check your own client's document
|
|
101
|
+
|
|
102
|
+
If you publish a CIMD for your MCP client, check it the way servers will:
|
|
103
|
+
|
|
104
|
+
```console
|
|
105
|
+
$ cimdkit check https://app.example.com/oauth/client.json --redirect-uri http://127.0.0.1:53682/callback
|
|
106
|
+
OK https://app.example.com/oauth/client.json
|
|
107
|
+
name Example Agent (host app.example.com)
|
|
108
|
+
auth method none
|
|
109
|
+
grant types authorization_code, refresh_token
|
|
110
|
+
redirect_uri http://127.0.0.1/callback
|
|
111
|
+
cache 3600 s
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`--file client.json` checks a local file before you deploy it, and `--json` prints the result
|
|
115
|
+
for CI. Or build the document in Python and get an error if any server would reject it:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
from cimdkit import make_document
|
|
119
|
+
|
|
120
|
+
doc = make_document("https://app.example.com/oauth/client.json",
|
|
121
|
+
client_name="Example Agent",
|
|
122
|
+
redirect_uris=["http://127.0.0.1/callback"],
|
|
123
|
+
logo_uri="https://app.example.com/logo.png")
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## For development
|
|
127
|
+
|
|
128
|
+
`ClientResolver(allow_loopback=True)` (or `cimdkit check --allow-loopback`) lets a server on
|
|
129
|
+
your machine fetch documents from `127.0.0.1`/`::1`. The spec forbids this in production.
|
|
130
|
+
|
|
131
|
+
## Limits
|
|
132
|
+
|
|
133
|
+
- It resolves and validates clients. It does not issue codes or tokens, verify
|
|
134
|
+
`private_key_jwt` assertions, or fetch `jwks_uri` and logos for you.
|
|
135
|
+
- The fetch connects directly and ignores `HTTPS_PROXY`. Pass your own `transport=` to
|
|
136
|
+
`ClientResolver`/`fetch_document` if your server must use a proxy.
|
|
137
|
+
- The cache is in memory, per process. Pass `fetch=` or wrap `resolve` to share one.
|
|
138
|
+
- It follows draft-ietf-oauth-client-id-metadata-document as of November 2026. The draft can
|
|
139
|
+
still change.
|
|
140
|
+
|
|
141
|
+
## Why this exists
|
|
142
|
+
|
|
143
|
+
MCP's authorization spec made CIMD the default way for clients to identify themselves
|
|
144
|
+
([SEP-991](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/991)), and
|
|
145
|
+
authorization servers in every language are being asked to support it:
|
|
146
|
+
|
|
147
|
+
- [modelcontextprotocol/python-sdk#1801](https://github.com/modelcontextprotocol/python-sdk/issues/1801): implement server-side CIMD support
|
|
148
|
+
- [authlib/authlib#900](https://github.com/authlib/authlib/issues/900): support Client ID Metadata Document?
|
|
149
|
+
- [ory/hydra#4061](https://github.com/ory/hydra/issues/4061), [supabase/auth#2850](https://github.com/supabase/auth/issues/2850), [spring-security#18375](https://github.com/spring-projects/spring-security/issues/18375), [LibreChat#16627](https://github.com/LibreChat-AI/LibreChat/issues/16627), [doorkeeper#1875](https://github.com/doorkeeper-gem/doorkeeper/issues/1875)
|
|
150
|
+
|
|
151
|
+
The first implementations keep hitting the same bugs, each one a test here:
|
|
152
|
+
unknown properties rejected ([keycloak#51236](https://github.com/keycloak/keycloak/issues/51236)),
|
|
153
|
+
loopback redirect ports
|
|
154
|
+
([claude-code#37747](https://github.com/anthropics/claude-code/issues/37747)), private-use
|
|
155
|
+
redirect schemes rejected ([fastmcp#6336](https://github.com/PrefectHQ/fastmcp/issues/6336)), a
|
|
156
|
+
malformed port causing a 500 ([fastmcp#5764](https://github.com/PrefectHQ/fastmcp/issues/5764)),
|
|
157
|
+
no way to limit CIMD to trusted domains
|
|
158
|
+
([fastmcp#6153](https://github.com/PrefectHQ/fastmcp/issues/6153)), and partial client_id URL
|
|
159
|
+
validation ([python-sdk#3768](https://github.com/modelcontextprotocol/python-sdk/issues/3768)).
|
|
160
|
+
|
|
161
|
+
## License
|
|
162
|
+
|
|
163
|
+
MIT
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cimdkit"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "OAuth Client ID Metadata Documents (CIMD) for authorization servers: safe fetch, spec validation, caching and redirect_uri matching for MCP clients"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
authors = [{ name = "Sri Krishna Lingamaneni" }]
|
|
14
|
+
keywords = [
|
|
15
|
+
"oauth", "oauth2", "cimd", "client id metadata document", "client_id", "mcp",
|
|
16
|
+
"model context protocol", "authorization server", "dynamic client registration", "ssrf",
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 4 - Beta",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
24
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
25
|
+
"Topic :: Security",
|
|
26
|
+
"Topic :: Software Development :: Libraries",
|
|
27
|
+
]
|
|
28
|
+
dependencies = []
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
test = ["pytest>=7"]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/srikrishnaL/kitforge/tree/main/packages/cimdkit"
|
|
35
|
+
Source = "https://github.com/srikrishnaL/kitforge"
|
|
36
|
+
Issues = "https://github.com/srikrishnaL/kitforge/issues"
|
|
37
|
+
Changelog = "https://github.com/srikrishnaL/kitforge/blob/main/packages/cimdkit/CHANGELOG.md"
|
|
38
|
+
|
|
39
|
+
[project.scripts]
|
|
40
|
+
cimdkit = "cimdkit.cli:main"
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.wheel]
|
|
43
|
+
packages = ["src/cimdkit"]
|
|
44
|
+
|
|
45
|
+
[tool.pytest.ini_options]
|
|
46
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""cimdkit: OAuth Client ID Metadata Documents (CIMD) for authorization servers.
|
|
2
|
+
|
|
3
|
+
MCP clients identify themselves with a URL ``client_id`` instead of
|
|
4
|
+
registering. cimdkit fetches that URL safely, validates the document against
|
|
5
|
+
draft-ietf-oauth-client-id-metadata-document, caches it by its HTTP headers and
|
|
6
|
+
matches redirect URIs, including the loopback any-port rule.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .document import ClientMetadata, Policy, make_document, validate_document
|
|
10
|
+
from .errors import (
|
|
11
|
+
BlockedAddress,
|
|
12
|
+
CIMDError,
|
|
13
|
+
ClientIdMismatch,
|
|
14
|
+
FetchError,
|
|
15
|
+
InvalidClientId,
|
|
16
|
+
InvalidDocument,
|
|
17
|
+
InvalidRedirectUri,
|
|
18
|
+
TransientFetchError,
|
|
19
|
+
)
|
|
20
|
+
from .fetch import DEFAULT_MAX_BYTES, FetchResult, Response, fetch_document, is_public_address
|
|
21
|
+
from .resolver import ClientResolver, cache_ttl
|
|
22
|
+
from .urls import check_client_id_url, is_client_id_url, redirect_uri_matches
|
|
23
|
+
|
|
24
|
+
__version__ = "0.1.0"
|
|
25
|
+
|
|
26
|
+
#: Add these to your RFC 8414 authorization server metadata.
|
|
27
|
+
SERVER_METADATA = {"client_id_metadata_document_supported": True}
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"BlockedAddress",
|
|
31
|
+
"CIMDError",
|
|
32
|
+
"ClientIdMismatch",
|
|
33
|
+
"ClientMetadata",
|
|
34
|
+
"ClientResolver",
|
|
35
|
+
"DEFAULT_MAX_BYTES",
|
|
36
|
+
"FetchError",
|
|
37
|
+
"FetchResult",
|
|
38
|
+
"InvalidClientId",
|
|
39
|
+
"InvalidDocument",
|
|
40
|
+
"InvalidRedirectUri",
|
|
41
|
+
"Policy",
|
|
42
|
+
"Response",
|
|
43
|
+
"SERVER_METADATA",
|
|
44
|
+
"TransientFetchError",
|
|
45
|
+
"cache_ttl",
|
|
46
|
+
"check_client_id_url",
|
|
47
|
+
"fetch_document",
|
|
48
|
+
"is_client_id_url",
|
|
49
|
+
"is_public_address",
|
|
50
|
+
"make_document",
|
|
51
|
+
"redirect_uri_matches",
|
|
52
|
+
"validate_document",
|
|
53
|
+
]
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""``cimdkit check``: test a Client ID Metadata Document the way a server will."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .document import Policy, validate_document
|
|
13
|
+
from .errors import CIMDError
|
|
14
|
+
from .fetch import DEFAULT_MAX_BYTES, fetch_document
|
|
15
|
+
from .resolver import cache_ttl
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _parser() -> argparse.ArgumentParser:
|
|
19
|
+
p = argparse.ArgumentParser(
|
|
20
|
+
prog="cimdkit",
|
|
21
|
+
description="Check an OAuth Client ID Metadata Document (CIMD) as an authorization "
|
|
22
|
+
"server would: fetch rules, spec rules and redirect URIs.")
|
|
23
|
+
p.add_argument("--version", action="version", version=f"cimdkit {__version__}")
|
|
24
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
25
|
+
c = sub.add_parser("check", help="fetch (or read) and validate a metadata document")
|
|
26
|
+
c.add_argument("client_id", help="the Client Identifier URL")
|
|
27
|
+
c.add_argument("--file", help="validate this local JSON file instead of fetching the URL")
|
|
28
|
+
c.add_argument("--redirect-uri", action="append", default=[],
|
|
29
|
+
help="also check that this redirect_uri would be accepted (repeatable)")
|
|
30
|
+
c.add_argument("--auth-method", action="append",
|
|
31
|
+
help="auth methods the server supports (default: none, private_key_jwt)")
|
|
32
|
+
c.add_argument("--allow-loopback", action="store_true",
|
|
33
|
+
help="allow fetching from 127.0.0.1 / ::1 (development only)")
|
|
34
|
+
c.add_argument("--max-bytes", type=int, default=DEFAULT_MAX_BYTES)
|
|
35
|
+
c.add_argument("--timeout", type=float, default=5.0)
|
|
36
|
+
c.add_argument("--json", action="store_true", help="print the result as JSON")
|
|
37
|
+
return p
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _check(args: argparse.Namespace) -> int:
|
|
41
|
+
policy = Policy(auth_methods=tuple(args.auth_method)) if args.auth_method else Policy()
|
|
42
|
+
headers = {}
|
|
43
|
+
try:
|
|
44
|
+
if args.file:
|
|
45
|
+
with open(args.file, encoding="utf-8") as fh:
|
|
46
|
+
doc = json.load(fh)
|
|
47
|
+
else:
|
|
48
|
+
result = fetch_document(args.client_id, timeout=args.timeout,
|
|
49
|
+
max_bytes=args.max_bytes,
|
|
50
|
+
allow_loopback=args.allow_loopback)
|
|
51
|
+
doc, headers = result.document, dict(result.headers)
|
|
52
|
+
meta = validate_document(doc, args.client_id, policy)
|
|
53
|
+
bad = [u for u in args.redirect_uri if meta.match_redirect_uri(u) is None]
|
|
54
|
+
except CIMDError as exc:
|
|
55
|
+
kind = "transient" if exc.transient else "invalid_client"
|
|
56
|
+
if args.json:
|
|
57
|
+
print(json.dumps({"ok": False, "kind": kind, "error": str(exc)}))
|
|
58
|
+
else:
|
|
59
|
+
print(f"FAIL ({kind}): {exc}", file=sys.stderr)
|
|
60
|
+
return 1
|
|
61
|
+
except (OSError, ValueError) as exc:
|
|
62
|
+
print(f"FAIL: {exc}", file=sys.stderr)
|
|
63
|
+
return 1
|
|
64
|
+
|
|
65
|
+
ttl = cache_ttl(headers, now=time.time()) if headers else None
|
|
66
|
+
if args.json:
|
|
67
|
+
print(json.dumps({
|
|
68
|
+
"ok": not bad, "client_id": meta.client_id, "client_name": meta.client_name,
|
|
69
|
+
"host": meta.host, "redirect_uris": list(meta.redirect_uris),
|
|
70
|
+
"grant_types": list(meta.grant_types), "response_types": list(meta.response_types),
|
|
71
|
+
"token_endpoint_auth_method": meta.token_endpoint_auth_method,
|
|
72
|
+
"cache_seconds": ttl, "warnings": list(meta.warnings),
|
|
73
|
+
"rejected_redirect_uris": bad,
|
|
74
|
+
}, indent=2))
|
|
75
|
+
else:
|
|
76
|
+
print(f"{'OK' if not bad else 'FAIL'} {meta.client_id}")
|
|
77
|
+
print(f" name {meta.display_name} (host {meta.host})")
|
|
78
|
+
print(f" auth method {meta.token_endpoint_auth_method}")
|
|
79
|
+
print(f" grant types {', '.join(meta.grant_types)}")
|
|
80
|
+
for uri in meta.redirect_uris:
|
|
81
|
+
print(f" redirect_uri {uri}")
|
|
82
|
+
if ttl is not None:
|
|
83
|
+
print(f" cache {int(ttl)} s")
|
|
84
|
+
for w in meta.warnings:
|
|
85
|
+
print(f" warning {w}")
|
|
86
|
+
for uri in bad:
|
|
87
|
+
print(f" rejected {uri} is not a registered redirect_uri")
|
|
88
|
+
return 1 if bad else 0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
92
|
+
args = _parser().parse_args(argv)
|
|
93
|
+
if args.command == "check":
|
|
94
|
+
return _check(args)
|
|
95
|
+
return 2 # pragma: no cover
|