Support Client ID Metadata Documents (CIMD) as a registration mode - #191
Support Client ID Metadata Documents (CIMD) as a registration mode#191aterga wants to merge 30 commits into
Conversation
Both directories steer servers to CIMD over DCR: Anthropic recommends it for directory listings, and ChatGPT prioritises it. Each selects CIMD when the AS metadata advertises `client_id_metadata_document_supported: true` alongside `none` in `token_endpoint_auth_methods_supported` — so the flag must never be advertised ahead of the implementation, or every Claude connection fails with `invalid_client`. With CIMD the `client_id` IS an https URL, and the RFC 7591-shaped JSON at that URL is the client's registration. Nothing is stored per client, so a directory client that connects thousands of times no longer mints a DCR registration each time. The document is fetched under the discovery module's SSRF guard (https only, public addresses only, pinned against rebinding, redirect hops re-checked), now exposed as `imcp2_core::public_fetch::fetch_public_document` — strict where the crawl is opportunistic: a body over the cap (8 KiB), a transfer cut off mid-body, or an answer from a redirect target is an error, never a shorter document. 5 s timeout, at most 8 fetches in flight (an excess request is told to retry, not queued), and a bounded cache honouring the origin's `max-age` clamped to 1 min–24 h, 10 min by default. Failures are never cached. Validation follows the draft and Anthropic's reference server: the document's `client_id` must equal the URL exactly; it may carry no secret; it must be able to authenticate as a public client (ChatGPT's document prefers `private_key_jwt` but lists `none`, which is what it uses here); and of its `redirect_uris` only loopback ones and those same-origin with the document URL are kept, so a self-asserted document cannot point the code at another party. The requested redirect then gets EXACTLY the checks a DCR registration gets — a match against those URIs (loopback port-agnostically) AND the hosted-redirect allow-list — and the allow-list is checked BEFORE any fetch, so a redirect this server would refuse anyway never costs an outbound request. A fetch failure is `temporarily_unavailable` (retry), an invalid document `invalid_client`; neither reflects the caller-supplied URL to the browser. `OAUTH_CIMD_DISABLED=1` withdraws the advertisement and the mechanism at deploy time without a rebuild, because hosted Claude's own document URL is not published and could not be verified here; clients re-read the metadata within minutes and fall back to DCR. Tests use ChatGPT's and Claude Code's real documents (as served 2026-09-03) as fixtures, and a process-global stand-in for the web so the authorize path is exercised end to end without network: allow-list before fetch, caching, cross-origin refusal, port-agnostic loopback, kill switch. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
68d7426 to
a5cf0ec
Compare
The as-metadata check's detail line now ends in `CIMD=on|off`, read from `client_id_metadata_document_supported`, alongside the issuer and PKCE it already reports. CIMD is the registration mode both directories prefer, and `OAUTH_CIMD_DISABLED` can withdraw it at deploy time, so the dashboard is where an operator confirms which mode the production instance is actually offering — and where a regression that dropped the flag would show. Reported, not required: the switch being off is a state to see, not an outage. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
The guard test probed a private 10/8 address that is not one of the example values the internal-identifier scan strips before matching, so the scan flagged it. Use the canonical example address the scan allows and that discover.rs's own guard tests use. Same test, same refusal, no suppression marker needed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
99cebc5 to
6d6124a
Compare
|
CI
The flake has a mechanism worth fixing separately: the test guards its first Re-running the failed job once. Generated by Claude Code |
There was a problem hiding this comment.
🟡 Changes recommended
Redirect handling, DNS timeout coverage, and cache-control behavior undermine the promised strict fetch guarantees.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds Client ID Metadata Documents as a stateless OAuth registration mode alongside DCR.
Changes:
- Implements CIMD detection, validation, caching, authorization, and kill switch.
- Adds an SSRF-guarded public document fetcher.
- Updates monitoring, tests, and directory documentation.
File summaries
| File | Description |
|---|---|
src/auth.rs |
Implements CIMD authorization and caching. |
crates/imcp2-core/src/public_fetch.rs |
Adds guarded document fetching. |
crates/imcp2-core/src/discover.rs |
Exposes shared SSRF helpers internally. |
crates/imcp2-core/src/lib.rs |
Exports the public-fetch module. |
monitoring/mcp-status/checks.js |
Reports CIMD status. |
monitoring/mcp-status/checks.test.js |
Tests CIMD status reporting. |
README.md |
Documents CIMD behavior. |
docs/openai-directory-submission.md |
Records OpenAI CIMD readiness. |
docs/anthropic-directory-submission.md |
Records Anthropic CIMD readiness. |
Review details
Suppressed comments (2)
crates/imcp2-core/src/public_fetch.rs:53
- This policy follows same-host and public-IP redirects even though this strict fetch promises that any redirect target is rejected. The later check compares only origins, so
https://host/client.json -> https://host/otheris accepted and parsed as the original client's document. Disable redirects here; the existing non-success check will then reject the 3xx response.
.redirect(ssrf_redirect_policy())
crates/imcp2-core/src/public_fetch.rs:81
- This public API accepts any
usize, somax_bytes + 1overflows forusize::MAX(panic in checked builds, wrap to zero otherwise), potentially returning an empty document as a successful fetch. Use saturating addition or reject that input explicitly.
let body = match read_capped_inner(resp, max_bytes + 1).await {
- Files reviewed: 9/9 changed files
- Comments generated: 2
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Four findings from review, each a real gap between what the code promised and what it did: Redirects. `public_fetch` followed same-host and public-IP hops under the crawl's redirect guard and then compared origins, so a same-origin redirect to another path put a different document behind the client_id URL. Now no redirect is followed at all: a 3xx is a non-success answer and is refused, which is what the module doc had claimed. Deadline. The caller's timeout started after `resolve_public_url`, leaving DNS resolution unbounded — in the CIMD path, a slow resolver could hold one of the eight in-flight permits past the five seconds the authorize budget allows. One `tokio::time::timeout` now covers resolution, connect, response and body. imcp2-core gains tokio's `time` feature for it. Cache floor. `no-store`, `no-cache` and `max-age=0` were clamped up to a minute and the document reused meanwhile, defeating an origin's explicit instruction and keeping a withdrawn redirect authorized. The floor is gone: a zero lifetime means the document is not cached, and a positive `max-age` is honoured as given up to the 24 h ceiling. The floor's DoS rationale did not hold — an invalid document is never cached either, so a stranger could always force a fetch per request; the in-flight bound is what contains that. Overflow. `max_bytes + 1` wrapped for `usize::MAX`; it saturates now. Tests: a zero timeout expires during resolution of a public name and is reported as the deadline, not the guard; an uncapped read is accepted; a `no-store` document authorizes once and is refetched, not reused; the TTL test pins "no floor, ceiling kept, zero means don't cache". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
CI caught `deadline_covers_resolution` racing: on a runner whose resolver answers before tokio's timer tick, the fetch got past DNS and reqwest's own per-request `.timeout(ZERO)` failed it with a request error, not the deadline's. Two timeouts over one operation is the flaw. The client now sets none of its own; the outer `tokio::time::timeout` is the single deadline, dropping the future on expiry aborts the connection, and the caller sees the same error wherever the time ran out. The test asserts exactly that and is deterministic for it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
There was a problem hiding this comment.
🔵 Needs a closer look
Metadata validation, HTTP caching, decoding, and fetch admission contain unresolved correctness and reliability issues.
Review details
Suppressed comments (5)
Previously missed (5) — in code that hasn't changed since the last review.
crates/imcp2-core/src/public_fetch.rs:88
- The cache hint ignores the response's current age. For a CDN response with
Cache-Control: max-age=86400andAge: 86399,client_metadata_forstarts a fresh 24-hour TTL, so a withdrawn redirect can remain authorized almost a day beyond the origin's freshness lifetime. Return or compute the remaining freshness lifetime using the HTTPAge/Datesemantics rather than forwarding the raw max-age value as a new TTL.
crates/imcp2-core/src/public_fetch.rs:88 HeaderMap::getobserves only one Cache-Control field, but repeated Cache-Control fields are semantically combined. A response containing separatemax-age=86400andno-storefields can therefore be cached for a day depending on field order, violating the explicit no-store instruction. Combine all field values before parsing directives.
crates/imcp2-core/src/public_fetch.rs:92- This strict fetch delegates decoding to
read_capped_inner, which usesString::from_utf8_lossy. Invalid UTF-8 bytes inside a JSON string are therefore replaced with U+FFFD and the resulting document can passserde_jsonvalidation, even though CIMD JSON must be UTF-8. Preserve the existing fail-soft discovery behavior, but make this strict path reject decoding errors rather than normalizing them.
src/auth.rs:1263 - The global fail-fast semaphore can be monopolized with very little unauthenticated traffic: eight slow distinct URLs occupy every permit for up to five seconds, failures are not cached, and subsequent legitimate cold/expired CIMD requests all receive 503. Concurrent misses for one popular client also issue duplicate fetches and can consume all eight slots. Add per-key single-flight coalescing and admission control that one source/origin cannot exhaust before enabling this on the public authorize endpoint.
src/auth.rs:1271 - The fetched media type is discarded here, so a 200 response with a missing or
text/htmlContent-Type is accepted whenever its body parses as the expected JSON. CIMD metadata documents are required to be served asapplication/json; validate the case-insensitive media-type essence (while allowing parameters) and classify a mismatch as an invalid document before parsing it.
- Files reviewed: 10/10 changed files
- Comments generated: 0 new
- Review effort level: Balanced
There was a problem hiding this comment.
🔵 Needs a closer look
Content-type validation, HTTP cache freshness, and cold-cache request coordination remain incomplete.
Review details
Suppressed comments (4)
Previously missed (4) — in code that hasn't changed since the last review.
crates/imcp2-core/src/public_fetch.rs:92
- This does not fully honor HTTP freshness semantics:
HeaderMap::getreads only one of potentially several legalCache-Controlfield lines, and the returnedmax-ageis reused from receipt without subtracting the response'sAge. For example, a CDN response withmax-age=86400, Age: 86399is cached locally for another day, and a secondCache-Control: no-storeline can be missed. Combine all Cache-Control values and reduce the lifetime byAgebefore exposing it to callers.
src/auth.rs:1263 - Cold-cache requests for the same
client_idare not coalesced: all eight can consume permits fetching the identical document, while the ninth legitimate authorization is immediately rejected. This creates a thundering herd for popular directory clients and also lets a very low request rate monopolize the process-wide pool with slow URLs. Add per-key single-flight coordination with a cache recheck so one fetch serves concurrent requests; retain a separate global bound for distinct URLs.
src/auth.rs:1269 - The fetched response's
Content-Typeis recorded but never validated before accepting the metadata. The CIMD requirements call forapplication/json; as written, a document served as HTML or plain text is accepted whenever its bytes happen to parse as JSON. Validate the media type (allowing normal parameters such ascharset) and classify a mismatch asCimdError::Invalid.
crates/imcp2-core/src/public_fetch.rs:74 - No test exercises a 3xx response, so the redirect behavior that was corrected during the prior review is not protected against regression; the current deadline test never receives an HTTP response. Add a deterministic fetch test (or extract a response-policy seam) proving that same-origin and cross-origin redirects are returned as errors without requesting their targets.
- Files reviewed: 10/10 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Six findings, each a gap between what the code claimed and what it did: Freshness. `Cache-Control` was read from one header line and its `max-age` reused as a fresh lifetime. HTTP combines all lines (a `no-store` on the second counts) and freshness is `max-age` less the response's `Age`, so a CDN answer one second from expiry gave us a new day. `public_fetch` now reports the remaining lifetime from the combined fields, `Age` subtracted. Decoding. The body went through the crawl's lossy UTF-8 read, so a byte that was not UTF-8 became U+FFFD and the document still parsed. The strict path now reads bytes (`read_capped_bytes`, which the lossy read is built on) and refuses invalid UTF-8; the document parsed is the one served. Media type. A 200 with any `Content-Type` was parsed as JSON. A metadata document must be served as `application/json`; anything else, by essence (parameters and case aside), is now `invalid_client` before parsing. Thundering herd. Concurrent misses for one document each fetched it and each spent a permit. A per-`client_id` single-flight lock now lets the first fetch and the rest read the cache after it. Monopolisation. Eight slow distinct URLs on one host could hold every permit. A per-host cap of two now bounds any one host; the global bound of eight stays. Neither queues: an excess request is told to retry. Redirects were untested. `accept` is split from the sending so the acceptance rules run against synthetic responses with no network: every 3xx is refused as "not followed", non-2xx refused, the cap exact, non-UTF-8 refused, `Age` and multi-line `Cache-Control` honoured. imcp2-core gains `http` as a dev-dependency for them (Cargo.lock: one edge). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
There was a problem hiding this comment.
🟡 Changes recommended
The deployment enables a currently failing integration by default, and failed or uncacheable fetches are not correctly coalesced.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 10/11 changed files
- Comments generated: 2
- Review effort level: Balanced
Align the Client ID Metadata Document support with the scoping in PR #143 and the third review round. Trust policy: a URL client_id is fetched only when its origin is a vetted vendor's — a host on or under a domain of the hosted-redirect allow-list, on the default https port. Anything else is refused before any request goes out and, like a hosted redirect off the allow-list, pointed at the allow-listing contact (403 invalid_client, or the not-approved page for a browser). The one vendor list decides both where a code may land and whose document this server will GET. Opt-in: CIMD is advertised and URL client_ids accepted only where the deployment sets OAUTH_CIMD_ENABLED=1. The deploy template takes the variable from the GitHub Environment, so a routine deploy never switches the directory clients over by itself; unsetting it is the rollback. Negative cache: a failure that is about the URL itself (404, a redirect, not JSON, about another URL, too large, not UTF-8) is remembered for a minute so a repeat costs no fetch. A transient one (deadline, connection, 5xx, 429) is not, and a per-request failure (a redirect the document does not list) never is, so a probe cannot lock out a real client. The fetcher's errors are typed to make that split. Single-flight shares the outcome: concurrent misses for one document share the one fetch's result — failure and uncacheable document included — instead of re-fetching serially behind it, and the flight entry is retired only by the flight that made it. A document may list no more redirect_uris than a DCR registration. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
There was a problem hiding this comment.
🟡 Changes recommended
SSRF proxy handling, transient DNS classification, cache freshness, redirect bounds, and process-wide concurrency limits need correction.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 14/15 changed files
- Comments generated: 5
- Review effort level: Balanced
- A resolver failure is a failure of the moment, not of the URL: the SSRF guard now reports it apart from its refusals (ResolveError), the fetcher maps it to Unreachable, and the client-metadata cache no longer remembers a DNS outage as "no document there" for a minute. - The guarded fetch takes no proxy from the environment: a proxy would resolve the host itself and the address pin would bind nothing. - A max-age given more than once is honoured at its most restrictive value, so a duplicate can never extend freshness. - A document's redirect_uris are bounded in length as well as count, exactly as a DCR registration's are, so a document admits no redirect DCR would refuse. - The cache, single-flight map and in-flight bounds are one per process, shared by every store the binary mounts, so the documented limits hold per process rather than multiplying with the mounts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
There was a problem hiding this comment.
🔵 Needs a closer look
The security-sensitive OAuth, SSRF, caching, and concurrency changes require final human review and live vendor validation.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
crates/imcp2-core/src/public_fetch.rs:60
- The public field documentation no longer matches the value returned by
freshness: it also usess-maxage/Expires, subtracts apparent age fromDate, and returns zero forprivateorVary: *. Documenting onlymax-age/Agecan mislead downstream users of this newly exported API about whenNoneandSome(0)occur.
- Files reviewed: 15/16 changed files
- Comments generated: 0 new
- Review effort level: Balanced
The twentieth review round: the public field's doc still named only max-age and Age. It now describes the value as computed — s-maxage or max-age from every Cache-Control line, else Expires less Date, minus the current age — and says when Some(0) and None occur. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
There was a problem hiding this comment.
🟡 Changes recommended
URL preprocessing and malformed Age handling can violate the documented validation and cache-safety behavior.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
src/auth.rs:966
Url::parsealso silently strips leading/trailing C0 controls and spaces. Consequently a raw identifier such as" https://chatgpt.com/oauth/client.json "reaches the CIMD path and is fetched as the normalized URL, despite the contract here requiring the raw identifier itself to be the HTTPS URL. Extend the raw precheck (and the shape regression test) to reject those trimmed bytes too.
if client_id.contains(['\t', '\n', '\r']) || raw_authority_has_userinfo(client_id) {
return None;
- Files reviewed: 15/16 changed files
- Comments generated: 1
- Review effort level: Balanced
…ld trim The twenty-first review round, two findings: - Every `Age` line counts and the greatest wins, and one that is not even ASCII is the greatest age like any other unparseable one — only the first line was read, and a non-ASCII value counted as zero. - The WHATWG parser also trims leading and trailing C0 controls and spaces from a URL, so a raw client_id beginning or ending with one is refused, as tab/newline/CR and an empty userinfo already were. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
There was a problem hiding this comment.
🔵 Needs a closer look
The IPv6 SSRF classifier still accepts reserved addresses outside the globally allocated unicast range.
Review details
Suppressed comments (1)
crates/imcp2-core/src/discover.rs:1384
- The IPv6 guard is still default-allow: any address not in these individual exclusions is treated as public. For example,
4000::1is outside the currently allocated global-unicast2000::/3, yet this function returnstrue, so a vetted hostname could route the new unauthenticated fetch to internally routed reserved space. Require2000::/3for native IPv6 addresses (the mapped IPv4 path already returns above) and add a reserved-address regression case.
|| (seg[0] & 0xffc0) == 0xfec0 // fec0::/10 site-local (deprecated, RFC 3879)
|| (seg[0] == 0x0100 && seg[1] == 0 && seg[2] == 0 && seg[3] == 0) // 100::/64 discard-only (RFC 6666)
|| (seg[0] == 0x3fff && (seg[1] & 0xf000) == 0) // 3fff::/20 documentation (RFC 9637)
|| seg[0] == 0x5f00 // 5f00::/16 SRv6 SIDs, not globally reachable (RFC 9602)
- Files reviewed: 15/16 changed files
- Comments generated: 0 new
- Review effort level: Balanced
The twenty-second review round: the IPv6 classifier was default-allow — any address not on its list of exclusions was public — so an address in space IANA has not allocated for global unicast (4000::1, say) passed. Global unicast is allocated only from 2000::/3, so a native address outside it is now refused without being named, which also covers the loopback, discard, NAT64, SRv6, unique-local, link-local, site-local and multicast ranges the list used to enumerate; within 2000::/3 the 2001::/23 default-deny and the documentation and 6to4 carve-outs remain. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
There was a problem hiding this comment.
🟡 Changes recommended
The fetcher can accept partial representations and can over-cache when a valid non-ASCII Cache-Control line is discarded.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
crates/imcp2-core/src/public_fetch.rs:224
filter_mapsilently discards anyCache-Controlfield line thatHeaderValue::to_str()cannot decode. HTTP quoted strings may legally containobs-text, so a response can haveCache-Control: max-age=86400plus a valid byte-valued line such asfoo="<obs-text>", max-age=0; the restrictive line is dropped and the document is reused for a day. Treat an undecodable line as zero freshness rather than ignoring it.
- Files reviewed: 15/16 changed files
- Comments generated: 1
- Review effort level: Balanced
… as no reuse The twenty-third review round, two findings: - Only a `200 OK` is the document. Any other 2xx was accepted before — a `206 Partial Content` fragment that happens to parse as JSON would have been validated as the complete metadata document, against the strict reader's completeness guarantee — and is now refused like a 3xx or 4xx. - A `Cache-Control` or `Vary` line the header cannot decode (a quoted argument may carry obs-text) is read as forbidding reuse rather than skipped, or an undecodable `max-age=0` beside a decodable `max-age=86400` would be dropped and the day honoured. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
There was a problem hiding this comment.
🟡 Changes recommended
The default cache lifetime currently ignores an already-aged response, allowing stale client metadata to be reused.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 15/16 changed files
- Comments generated: 1
- Review effort level: Balanced
The twenty-fourth review round: where the origin sent no freshness information, the ten-minute default was granted in full, so a document some cache along the way had already held for a day (Age: 86400, or an old Date) got ten fresh minutes here. The fetched document now reports its current age alongside the remaining freshness, and the default lifetime is that default less the age; an origin's own value is already net of it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
The twenty-fifth review round: the WHATWG parser reads a backslash as a slash in an https URL — `https:\\host\path` parses as `https://host/path`, and one in the authority ends it before an `@` the raw scan expects there — so a raw identifier with a backslash was not the URL that was parsed and fetched. Any backslash is refused on the raw string now, alongside tab/newline/CR, trimmed controls and an empty userinfo. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
The twenty-sixth review round. A document fetch answered 421 Misdirected Request or 425 Too Early was classified as a failure of the URL, so the client was refused as unknown and the refusal remembered for the negative TTL, though both statuses are defined as ones the client may retry: 421 is about the connection the request arrived on, 425 about the moment. Both join 408 and 429 as failures of the moment, told to retry and never remembered. The endpoint's retry response — 503 temporarily_unavailable to a programmatic caller, the sign-in error page to a browser — was covered only through the verdict it maps. It is now exercised directly, for an unreachable origin and for a 425 answer: the status and error code, that neither body reflects the client_id URL or the cause, and that nothing is remembered so the next request fetches again. The test fixture gains an `answer(url, status)` for any non-200 status; `not_found` is built on it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
Summary
Both directories steer servers to Client ID Metadata Documents over DCR. Anthropic's connector auth doc recommends CIMD for directory listings ("DCR causes Claude to register a new client on every fresh connection"); OpenAI's Apps SDK auth doc says ChatGPT prioritises it. Each selects CIMD when our AS metadata advertises
client_id_metadata_document_supported: truealongside"none"intoken_endpoint_auth_methods_supported. We already had the second; this PR adds the first and the implementation behind it. Advertising the flag alone would have been an outage: Claude would sendclient_id=https://claude.ai/oauth/…,validate_clientwould find nothing in the DCR store, and every Claude connection would fail withinvalid_client.With CIMD the
client_idis an https URL, and the RFC 7591-shaped JSON at that URL is the client's registration. Nothing is stored per client, so a directory client connecting thousands of times mints no registrations. Both vendors' live documents are the parsing test's fixtures:client_idredirect_urishttps://chatgpt.com/oauth/client.json["https://chatgpt.com/connector_platform_oauth_redirect"]private_key_jwt, listsnonehttps://claude.ai/oauth/claude-code-client-metadata["http://localhost/callback", "http://127.0.0.1/callback"]noneChatGPT's document declares exactly the redirect #189 pinned
Exact, which is a nice confirmation of that call.Design basis: the scoping in PR #143
This follows the design PR #143 lays out (
docs/scoping-cimd.mdthere; unmerged, so referenced rather than linked), which I had not read when the PR was first opened. Where it stands against that scoping:client_idis fetched only when its origin is a vetted vendor's: a host on or under a domain ofDEFAULT_ALLOWED_REDIRECTS(plusOAUTH_ALLOWED_REDIRECT_PREFIXESentries), on the default https port (cimd_origin_trusted). One source of truth for "who is a vetted vendor", as §8.2 recommends. An explicit:8443is refused before any lookup, as §3.1 asks. The new outbound-fetch surface on the unauthenticated/oauth/authorizeis therefore a finite set of vetted hosts, not any URL.client_idoff the policy gets the same treatment as a hosted redirect off the allow-list:403 invalid_clientnaming the contact, or the "not approved" page for a browser. Nothing is fetched, nothing about the URL is reflected.2000::/3, default-denies the IETF protocol-assignment block2001::/23within it admitting only the IANA registry's six reachable exceptions by name, and carves out documentation, 6to4 and3fff::/20; the IPv4 side also refuses the deprecated 6to4 relay block192.88.99.0/24bar its reachable192.88.99.2), exposed asimcp2_core::public_fetch, with the strict reader §3.3 asks for (only a200 OKis the document — a 206 fragment or any other 2xx is refused like a 3xx — and an over-cap, cut-off, or non-UTF-8 body is an error, never a truncated document),Accept: application/jsonand a required JSON media type, redirects disabled entirely (the option §3.3 recommends), and no proxy from the environment (through a proxy the address pin would bind nothing). Tighter than proposed where the real documents allow it: 8 KiB cap (they are under 1 KB), one 5 s deadline including DNS (Claude gives our authorize endpoint 10 s).client_id≠ URL as a plain string match, too large, overMAX_REDIRECT_URISorMAX_REDIRECT_URI_LEN, a flow this server does not run, no redirect a DCR registration could have registered) are negative-cached for 60 s; the redirect membership and allow-list checks run per request against the positively cached document and never produce a negative entry, so a bad-redirect probe cannot lock out a real client. Transient failures (a resolver that did not answer, deadline, connection, 5xx, the 4xx a client may retry — 408, 421, 425, 429 — an exhausted rate budget) are not cached; the SSRF guard reports a resolver failure apart from its refusals (ResolveError) so the two cannot be confused.CimdState::shared), shared by every store the binary mounts (/mcp,/mcp-beta), so the limits hold as documented rather than multiplying with the mounts; hosts are keyed by one spelling (lower-case, no trailing dot) so no spelling buys a second per-host quota; aclient_idis bounded at 2 KiB before it becomes a cache key, so the cache's memory is bounded too; and a request dropped mid-fetch gives back its host slot and permit, while the flight it was in stays for its waiters (the last one out retires it), so cancelled connections neither grow the map nor split one document's requests over two fetches. Concurrent misses for one document share one fetch. Logging is bounded the same way: the per-request diagnostics on the unauthenticated path are debug-level, and the invalid/unavailable outcomes are logged at warn only where a fetch actually happened.client_idhost only.no-store/max-age=0and can keep a withdrawn redirect authorized; review round 1 flagged exactly that. There is a 24 h ceiling and a 10 min default (less the age the response already has); the in-flight and rate caps and the negative cache are what bound the fetch rate, not a floor.ETagrevalidation is not implemented; the documents are tiny and the cache is in-memory (§8.3: a miss just re-fetches).How it works
cimd_client_id: an https URL (the scheme in any case) of at most 2 KiB with a host, a path beyond/, no fragment or userinfo — the last two checked on the raw string as well, since the WHATWG parser erases an empty@, strips tab/newline/CR, trims leading or trailing controls and spaces, and reads a backslash as a slash, none of which a raw identifier may carry. It is taken as given — the string its document must repeat byte for byte, and the cache key — with only the host normalised for the trust policy and the per-host quota, sohttps://ChatGPT.com/…or an explicit:443is a client like any other provided its document says the same. Anything else is an ordinary DCR id; that path is unchanged.cimd_origin_trusted, before anything else (above).redirect_urimust pass the hosted-redirect allow-list before the document is asked for, so even a vetted host is not fetched on behalf of a redirect that could never be used.fetch_public_document(above), once a host slot and a permit are held and a rate token taken. Freshness follows HTTP for the SHARED cache this is:Vary: *means no reuse at all; everyCache-Controlline is combined (a line that cannot be decoded counts as forbidding reuse) and split into directives only at commas outside a quoted-string,privateforbids reuse likeno-store,s-maxagetakes precedence overmax-age, a directive given more than once is honoured at its most restrictive value, one given without a valid number is stale rather than the default lifetime,Expires(relative toDate) decides whereCache-Controlgrants no freshness, and the response's current age — the larger ofAge(every line counted, the greatest winning, an unparseable one counting as the greatest) and the time sinceDate— is subtracted, and reported alongside so the caller's own default lifetime is net of it too. Failures are typed (FetchError::{Refused, Unreachable, Answered{status}, TooLarge, NotUtf8}) so the caller can make the §3.4 split.parse_client_metadata, per the draft and Anthropic's reference server:client_idequals the URL exactly; no client secret; can authenticate as a public client (none— absent meansnone, a non-string is malformed — ornonelisted intoken_endpoint_auth_methods_supported, which is ChatGPT's case); can run this server's one flow (grant_typesabsent or includingauthorization_code,response_typesabsent or includingcode, as DCR requires of a registration);redirect_uriswithin what a DCR registration may send (16 entries of at most 2 KiB each); and of those only the ones a DCR registration could have registered (redirect_uri_permitted: loopback, or https on an allow-listed host and pinned path, never with query or fragment) that are loopback or same-origin with the document URL are kept, so a self-asserted document cannot point the code at another party nor slip in a redirect DCR would refuse.redirect_allowedover the document's URIs (loopback port-agnostically, per RFC 8252 §7.3 — Claude Code needslocalhostas well as127.0.0.1) and the hosted-redirect allow-list.FlightGuard, and for a flight whose fetcher was cancelled before publishing the last holder out retires it, so a waiter takes over the fetch and nothing is left behind.temporarily_unavailable(retry, not "re-add the connector"); an invalid document isinvalid_client; an off-policy origin isinvalid_clientwith the contact. None reflects the caller-supplied URL to the browser; the cause is logged where the fetch happened.client_idwith the one bound into the grant, and a URL binds fine.Opt-in, and rollout
CIMD is off unless the deployment sets
OAUTH_CIMD_ENABLED=1: unset, the metadata does not advertise it and a URLclient_idis an unknown client, so a deploy of this PR changes no behaviour by itself. The variable is wired through the checked-in deployment path —deploy/native/imcp2.service→deploy.sh→deploy-native.yml, which takesvars.OAUTH_CIMD_ENABLEDfrom the GitHub Environment (seedeploy/native/README.md). Set it to1on the staging Environment and deploy, connect from Claude web and ChatGPT while watching forclient metadata document unavailable/client metadata document is invalidat warn in the logs (emitted once per fetch, never per request), then production. To roll back, unset the variable and redeploy (workflow_dispatchwith the same ref is enough; no rebuild): the value is rendered into the systemd unit at deploy time and read once at start-up, so changing the variable alone changes nothing on the host. Once the process restarts without it, Claude's discovery cache (~5 minutes) has clients back on DCR within minutes. The status dashboard'sas-metadatacheck reportsCIMD=on|off.What this PR could not verify: hosted Claude's own document URL is not published (the doc names only Claude Code's, and guesses at it returned 403), so its shape could not be checked the way the other two were; Anthropic's reference server enforces the same rules this PR does, so their client should pass them, but "should" is not "verified". (An earlier revision of this description reported ChatGPT's document as 404: that was this sandbox's egress, not the document, which is served fine elsewhere.)
What this does not do
There is still no consent screen:
/oauth/authorizehands the browser straight to Internet Identity, for CIMD clients exactly as for DCR ones. Phase 2 of #143 (branding keyed on the verified domain, coordinated with II) and Phase 3 (opening CIMD beyond the trust policy) are not here. Nor does it serve a stale document when its origin starts failing (stale-if-error). The discovery crawl's ownsite_clientstill takes a proxy from the environment as reqwest does by default; that is pre-existing and left for a follow-up.Related issues
Follows #189. Design per #143 (unmerged scoping). Rebased onto #190, which checked in
rustfmt.tomland made the formatting check part of CI. Both submission docs are updated (docs/anthropic-directory-submission.mdhad CIMD down as a follow-up "if usage grows").Changes
crates/imcp2-core/src/public_fetch.rs(new) —fetch_public_documentwith typedFetchError; the strict SSRF-guarded, proxy-free GET, split into sending andacceptso the acceptance rules are testable on synthetic responses;PublicDocumentcarries the remaining freshness and the response's current age;freshness(Vary: *, combinedCache-Controlwith undecodable lines read as no reuse,Expiresfallback) andcurrent_age(everyAgeline, conservatively, andDate),cache_directives(quoted-string-aware splitting) andcache_max_age(shared-cache semantics:private,s-maxage, most-restrictive duplicates, malformed values stale, directives matched by name).discover.rsgainsread_capped_bytes(the lossyread_capped_inneris now built on it), a typedResolveErrorforresolve_public_url(the crawl keeps its string errors viaFrom), makes bothpub(crate), and makes the classifiers default-deny (ipv6_is_global: nothing native outside2000::/3,2001::/23denied withietf_protocol_assignment_is_globalfor the registry's exceptions, documentation and 6to4 carved out;ipv4_is_global: the 6to4 relay block bar its reachable exception);lib.rsexports the module (additive public API on the published crate — no version bump here, that's yours to schedule);Cargo.tomladds tokio'stimefeature,httpdate(already in the lockfile through hyper) as a dependency, andhttpas a dev-dependency (two edges inCargo.lock, no new crate).src/auth.rs— the CIMD section: constants,cimd_enabled_by_env/cimd_enabled_by(the opt-in),ClientMetadata,cimd_client_id(reusingraw_authority_has_userinfo),cimd_origin_trusted/allow_listed_domain/vetted_domain/host_key(the trust policy),parse_client_metadata,is_json_media_type,cimd_ttl(the origin's remaining freshness, or the default less the response's age),CimdState(the process-wide cache, single-flight map, in-flight bounds and rate buckets;retire_flight),TokenBucket/Rates,HostSlotandFlightGuard(the guards that give a slot back and retire an unpublished flight, however the request ends),fetch_and_validate_client_metadata,classify_fetch_error(5xx and the retryable 4xx — 408, 421, 425, 429 — are the moment; every other answer is the URL),fetch_client_metadata_documentwith a#[cfg(test)]fixture registry (answering with a document or any status, aged, failing, or hanging).AuthStoregainscimd: Arc<CimdState>andcimd_enabled;validate_clientreturns aClientCheckverdict (Allowed/Refused/MetadataUnavailable/UntrustedClientOrigin);client_metadata_for/fetch_and_cache_client_metadata/remember_client_metadatado single-flight, bounds, fetch, validate, cache, keyed by the identifier as given, with warn-level logging only where a fetch happened;/oauth/authorizemapsMetadataUnavailableto a retry andUntrustedClientOriginto the not-approved page or its JSON; the metadata advertises the flag percimd_enabled.deploy/native/imcp2.service,deploy/native/deploy.sh,.github/workflows/deploy-native.yml,deploy/native/README.md—OAUTH_CIMD_ENABLEDwired from the GitHub Environment variable to the unit, with the rollback (unset and redeploy) spelled out.monitoring/mcp-status/checks.js— theas-metadatadetail line reportsCIMD=on|off(reported, not required); its test fixture and assertion updated.cimd_client_id_shape(including non-canonical spellings and an upper-case scheme accepted, the exact length cap, and what the parser would alter or trim — an empty userinfo, tab/newline/CR, edge controls, backslashes — refused),cimd_client_id_is_taken_as_given,client_metadata_parsing(both vendors' real documents, every refusal, theredirect_uriscount and length bounds, a non-string auth method, the grant and response types, a loopback entry with a fragment and an unpinned own-origin path dropped),cimd_fetch_error_classification(5xx, 408, 421, 425 and 429 the moment; redirects and the other 4xx the URL),cimd_host_key_is_one_spelling_per_host,cimd_opt_in_values,cimd_origin_trust_policy(real identifiers and subdomains trusted; a stranger, a look-alike, a vetted name under a stranger, and a non-default port refused with no fetch; CIMD off makes a URL id an unknown client),cimd_cache_ttl_is_bounded(including the default net of the response's age),cimd_media_type,cimd_rate_bucket,cimd_fetch_rate_is_bounded(a vendor's share, then the process's budget, refused before any fetch, holding nothing),cimd_client_authorization(the authorize path end to end without network: allow-list before fetch, caching,no-storenot cached, a day-old answer with no cache hint not cached, cross-origin refusal, wrong media type, port-agnostic loopback, transient failure retried and not remembered, invalid and 404 remembered, a per-request failure not poisoning the positive cache),cimd_fetches_are_coalesced_and_bounded_per_host(one fetch for three concurrent misses, one shared failure for three concurrent misses, a third document on one host refused with no rate token spent and every slot released),cimd_flight_retirement_rules(published → retired at once however many hold it; unpublished → kept for the waiter, retired by the last holder; a newer flight never touched),cimd_cancelled_fetch_leaves_nothing_behind(a lone fetcher aborted mid-fetch: no flight entry, host slot or permit left behind, and the next request succeeds),cimd_cancelled_fetcher_hands_over_to_a_waiter(a fetcher aborted with a waiter in the flight: the flight survives, the waiter fetches once, a newcomer fetches nothing, the map is empty afterwards),cimd_state_is_shared_by_every_store,as_metadata_advertises_cimd_only_where_enabled,authorize_points_an_unvetted_cimd_origin_at_the_contact,authorize_tells_a_cimd_client_to_retry_when_its_document_is_unavailable(the retry response at the endpoint itself, for an unreachable origin and for a 425 answer:503 temporarily_unavailableto a programmatic caller, the sign-in error page to a browser, neverinvalid_client, neither body reflecting the URL or the cause, and nothing remembered so the next request fetches again);public_fetch: guard refusals (loopback, private, link-local, site-local, unique-local, discard-only, unallocated IPv6 space, the IETF protocol-assignment block, documentation, SRv6, the 6to4 relay block, metadata, IPv4-mapped), an unresolvable host asUnreachable, the single deadline, refusal of redirects, 4xx, 5xx and every non-200 2xx as typed errors, exact cap, UTF-8,Age(including overflowing, non-numeric, non-ASCII, and several lines) and the current age on its own, staleDate,Ageversus apparent age either way round, clock skew,Expireswith and withoutDate, past and invalidExpires,Cache-Controlprecedence overExpires,Vary: *, multi-lineCache-Control, an undecodableCache-ControlorVaryline, duplicatemax-age, malformedmax-age,private,s-maxage, arguedno-cache, commas and escapes inside quoted arguments, an unterminated quoted-string;discover.rs's classifier test refuses the special-purpose ranges, unassigned2001::/23space and everything outside2000::/3, while keeping the registry's reachable exceptions global. The pre-existing LRU-stamp test is updated for the verdict type.README.md,docs/anthropic-directory-submission.md,docs/openai-directory-submission.md— CIMD documented as supported: trust policy, opt-in and rollback, same-origin rule, public-client rule, media-type rule, bounds (in flight and in rate), negative cache.Testing
cargo build --locked --workspace --all-targetscargo test --locked --workspace --all-targets— 297 tests, 0 failurescargo fmt --all -- --check— clean under therustfmt.tomlCheck in rustfmt.toml and enforce formatting in CI #190 checked incargo clippy --locked --workspace --all-targets— the 9 warnings are the pre-existing ones inimcp2-core(calls.rs,discover.rs,tools.rs,management.rs); none inauth.rsorpublic_fetch.rs, and this change adds nonenpm test --prefix monitoring/mcp-status— 71 tests, 0 failures.github/scripts/scan-internal-identifiers.sh origin/main...HEAD— clean, commit messages includedNegative controls, each restored afterwards: removing the allow-list-before-fetch check flips the rogue-redirect case from
RefusedtoMetadataUnavailable("must not be fetched"), proving the test observes whether a fetch happened; removing the metadata flag fails the metadata test; putting 421/425 back among the URL failures fails both the classification test and the endpoint test (the 425 client gets403, not503). The two vendor documents in the fixtures are byte-faithful to whatchatgpt.comandclaude.aiserved on 2026-09-03.Review rounds (Copilot): round 1 found same-origin redirects accepted, DNS outside the deadline, a cache floor overriding
no-store, and ausize::MAXoverflow; round 2 foundAgeignored and only oneCache-Controlline read, lossy UTF-8 decoding, no media-type check, no single-flight, one host able to take every permit, and redirect handling untested; round 3 found CIMD defaulting on with nothing in the deploy path to turn it off, and single-flight sharing only cache hits rather than outcomes; round 4 found a resolver outage classified as a URL refusal (and so negative-cached), the environment's proxy bypassing the address pin, the first of severalmax-agevalues winning,redirect_urisbounded in count but not length, and the in-flight limits being per store rather than per process; round 5 (suppressed comments, no threads) foundprivateands-maxageignored by what is a shared cache, a non-stringtoken_endpoint_auth_methodread as absent, and HTTP 408 treated as a permanent failure; round 6 found theclient_idunbounded in length before becoming a cache key, a trailing-dot host buying a second per-host quota, and the README narrowing the public-client rule; round 7 foundgrant_types/response_typesignored, so a document declaring only another flow was accepted; round 8 found a request cancelled mid-fetch leaving its single-flight entry behind; round 9 found the shared IPv6 classifier not refusing the deprecated site-localfec0::/10; round 10 found the discard-only100::/64likewise, a loopback redirect with a fragment retained (and matched fragment-free), no rate cap behind the concurrency cap, and a cancelled fetcher's waiters and newcomers landing on two flights; round 11 found the benchmarking and ORCHID IPv6 ranges likewise, freshness ignoring the apparent age aDateheader implies, and rate tokens spent on requests refused for congestion; round 12 found the documentation and SRv6 IPv6 ranges likewise, and a published flight joinable until its last holder left, prolonging ano-storeoutcome; round 13 (suppressed, no thread) found the runbook implying that unsetting the variable alone rolls CIMD back, when a redeploy is needed; round 14 found unassigned2001::/23space still classified public (fixed by default-denying the block), a malformedmax-agefalling through to the default lifetime, and a gap between publishing an outcome and retiring its flight; round 15 foundCache-Controlsplit at commas inside quoted-strings, the canonical-form requirement refusingclient_idspellings the draft allows, and2001:1::3(DNS-SD anycast, reachable) wrongly refused; round 16 found an upper-caseHTTPS://scheme refused before parsing, a test doc comment describing the removed canonical-form contract, andExpiresignored whereCache-Controlgrants no freshness; round 17 found the deprecated 6to4 relay block192.88.99.0/24classified public,Vary: *responses cached, and a rawclient_idthe parser would silently alter (empty userinfo, tab/newline/CR) accepted; round 18 (suppressed, no threads) found an overflowingAgeread as zero, and per-request warn logs on the unauthenticated path that a flood could make unbounded; rounds 19 and 20 (suppressed, no threads) found three doc comments still describing earlier contracts; round 21 found only the firstAgeline read (a non-ASCII one counting as zero), and aclient_idwith leading or trailing controls or spaces accepted though the parser trims them; round 22 (suppressed, no thread) found the IPv6 classifier still default-allow outside its named exclusions (fixed by refusing everything outside2000::/3); round 23 found a206 Partial Contentaccepted as the document, and an undecodableCache-Controlline silently dropped; round 24 found the default cache lifetime granted in full to an answer that was already a day old; round 25 (summary only, no comment) found aclient_idwith backslashes, which the parser reads as slashes, accepted; round 26 (summary only, no comment) found421 Misdirected Requestand425 Too Early— both defined as answers the client may retry — classified as failures of the URL and so negative-cached, and the endpoint's retry response covered only through the verdict it maps rather than directly. All sixty-two fixed; see the commits and the threads. One earliertestfailure was a racy deadline test of mine (two competing timeouts), fixed by making the outer deadline the only one; another was a transport blip in the pre-existing livesvault.techtest, not this PR's (comment on the PR).Not tested here: a live authorization from Claude or ChatGPT against a deployed build — this needs the deploy, and see "Opt-in, and rollout" above.
Checklist
🤖 Generated with Claude Code
https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj