Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
a5cf0ec
Support Client ID Metadata Documents (CIMD) as a registration mode
aterga Sep 3, 2026
c776135
Report CIMD advertisement on the status dashboard
aterga Sep 3, 2026
6d6124a
Use the scanner's canonical private address in the SSRF-guard test
aterga Sep 3, 2026
d954433
Tighten the CIMD fetch and cache as review found
aterga Sep 3, 2026
51aed76
Make the fetch deadline the only timeout
aterga Sep 3, 2026
bc39d88
Harden the CIMD fetch path as the second review round found
aterga Sep 3, 2026
87f3ea7
Gate CIMD on the vendor trust policy and make it opt-in
claude Sep 3, 2026
49322bc
Close the fourth review round's gaps in the CIMD fetch path
claude Sep 3, 2026
6b294d1
Read Cache-Control as the shared cache this is, and tighten two edges
claude Sep 3, 2026
c851781
Bound the CIMD client_id and key per-host slots by one host spelling
claude Sep 3, 2026
bedecb8
Require a metadata document to name the authorization-code flow
claude Sep 3, 2026
efe8443
Retire a CIMD flight when the request fetching for it is dropped
claude Sep 3, 2026
bfad6cf
Refuse deprecated IPv6 site-local addresses in the SSRF guard
claude Sep 3, 2026
95c5ebc
Bound the CIMD fetch rate and keep one flight through a cancelled fet…
claude Sep 3, 2026
419b9df
Count a response's apparent age, spend rate tokens only on a fetch
claude Sep 3, 2026
76bedff
Retire a published CIMD flight at once; refuse two more IPv6 ranges
claude Sep 3, 2026
2a3cf1b
Say that the CIMD rollback needs a redeploy, not just the variable
claude Sep 3, 2026
23b5f67
Default-deny 2001::/23, treat a malformed max-age as stale, retire be…
claude Sep 3, 2026
efc71c4
Parse Cache-Control quoted-strings, take the client_id as given, admi…
claude Sep 3, 2026
9a91292
Honour Expires, accept an upper-case scheme, fix a test's contract
claude Sep 3, 2026
dbbf817
Refuse the 6to4 relay block, Vary: *, and a client_id the parser woul…
claude Sep 3, 2026
c1e2611
Saturate an unparseable Age, and log CIMD failures only where a fetch…
claude Sep 3, 2026
8a06f94
Bring two CIMD doc comments up to date with the negative cache
claude Sep 3, 2026
cfddaa5
Describe PublicDocument::cache_max_age as freshness computes it
claude Sep 3, 2026
f5cc269
Fold every Age line conservatively; refuse a client_id the parser wou…
claude Sep 3, 2026
bafdcaf
Default-deny native IPv6 outside 2000::/3 in the SSRF guard
claude Sep 3, 2026
bed9cf6
Accept only 200 OK as the document; read an undecodable Cache-Control…
claude Sep 3, 2026
445afa7
Apply a response's age to the default CIMD cache lifetime too
claude Sep 3, 2026
64007a9
Refuse a CIMD client_id containing a backslash
claude Sep 3, 2026
bca1f99
Treat 421 and 425 as failures of the moment, and test the retry response
claude Sep 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,28 @@ its AS issuer is `<PUBLIC_URL>/mcp` and everything OAuth lives under it:
honoured (intersected with `authorization_code`). A **hosted** `redirect_uri` is
rejected unless its host is on the allow-list (see the Companion-control note
below); loopback redirects are always accepted.
- **Client ID Metadata Documents** — the MCP authorization spec's preferred
registration, advertised as `client_id_metadata_document_supported: true`. A
client may skip `/register` and use the https URL of its metadata document as
its `client_id`; `/mcp/oauth/authorize` fetches that document under the same
SSRF guard as app discovery (https only, public addresses only, pinned, no
redirects, 8 KiB cap, 5 s including DNS), requires its `client_id` to equal the URL, and checks the requested
redirect against the document's `redirect_uris` exactly as it would a DCR
registration's — hosted-redirect allow-list included, and checked before any
fetch, so a document can neither admit a redirect a DCR client couldn't
register nor make the server fetch a URL for a redirect it would refuse. A
hosted redirect must also be same-origin with the document URL (loopback
excepted), so a self-asserted document cannot point the code at another
party. Only public clients (`token_endpoint_auth_method: none`) are accepted.
Documents must be served as `application/json`, and are cached (bounded; the
origin's remaining freshness — `max-age` less `Age` — honoured up to 24 h,
10 min when it sends none, not at all on `no-store`), so a directory client
connecting thousands of times mints no registrations. Concurrent requests for
one document share a single fetch, and at most eight fetches are in flight at
once, two per host, so one slow host cannot hold up the rest. Claude and ChatGPT both select CIMD over DCR when it is
advertised; `OAUTH_CIMD_DISABLED=1` withdraws the advertisement and the
mechanism without a rebuild (clients re-read the metadata within minutes and
fall back to DCR).

- `GET /mcp/oauth/authorize` — validates the client + redirect, requires PKCE, sets
the binding cookie, then redirects to II's handshake (with `registration_key`)
Expand Down
7 changes: 6 additions & 1 deletion crates/imcp2-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ rmcp = { workspace = true, features = ["server", "macros"] }
ic-agent = { workspace = true }
candid = { workspace = true }
candid_parser = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "net"] }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "net", "time"] }
serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
Expand Down Expand Up @@ -48,3 +48,8 @@ wat = { version = "1", optional = true }
# `Identities::seed_app_identity` — are gated on `cfg(test)` as well, so they
# exist only in this crate's own test binary, never in a library build.
e2e = ["dep:pocket-ic", "dep:wat", "rmcp/client"]

[dev-dependencies]
# Synthetic responses for pinning `public_fetch`'s acceptance rules without a
# network (`reqwest::Response: From<http::Response<_>>`).
http = { workspace = true }
26 changes: 21 additions & 5 deletions crates/imcp2-core/src/discover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1380,7 +1380,7 @@ fn ipv6_is_global(ip: &Ipv6Addr) -> bool {
/// Validate a user-supplied discovery URL against SSRF and return the parsed URL
/// plus the socket addresses to PIN the client to. https only; every resolved
/// address must be global. Async DNS (no blocking of the executor).
async fn resolve_public_url(raw: &str) -> Result<(url::Url, Vec<SocketAddr>), String> {
pub(crate) async fn resolve_public_url(raw: &str) -> Result<(url::Url, Vec<SocketAddr>), String> {
let url = url::Url::parse(raw).map_err(|e| format!("invalid discovery URL {raw}: {e}"))?;
if url.scheme() != "https" {
return Err(format!(
Expand Down Expand Up @@ -1536,10 +1536,26 @@ enum Overflow {
/// The shared read. `Err((partial, error))` carries what had arrived before the
/// transfer failed, so the fail-soft caller can keep it and the strict one can
/// report the failure.
async fn read_capped_inner(
mut resp: reqwest::Response,
pub(crate) async fn read_capped_inner(
resp: reqwest::Response,
max: usize,
) -> Result<String, (String, String)> {
// Lossy by design for the crawl: a stray byte must not cost a whole bundle.
// A caller that treats the body as a statement (the CIMD fetch) reads the
// bytes and decodes strictly instead.
match read_capped_bytes(resp, max).await {
Ok(buf) => Ok(String::from_utf8_lossy(&buf).into_owned()),
Err((buf, e)) => Err((String::from_utf8_lossy(&buf).into_owned(), e)),
}
}

/// The shared read in bytes: up to `max` of the body, stopping — and so dropping
/// the response and its connection — at the cap. `Err((partial, error))` carries
/// what had arrived before the transfer failed.
pub(crate) async fn read_capped_bytes(
mut resp: reqwest::Response,
max: usize,
) -> Result<Vec<u8>, (Vec<u8>, String)> {
let mut buf: Vec<u8> = Vec::new();
loop {
if buf.len() >= max {
Expand All @@ -1554,10 +1570,10 @@ async fn read_capped_inner(
}
}
Ok(None) => break,
Err(e) => return Err((String::from_utf8_lossy(&buf).into_owned(), e.to_string())),
Err(e) => return Err((buf, e.to_string())),
}
}
Ok(String::from_utf8_lossy(&buf).into_owned())
Ok(buf)
}

/// GET `url` and return up to `max` bytes of its body, distinguishing "this app
Expand Down
1 change: 1 addition & 0 deletions crates/imcp2-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@

pub mod identities;
pub mod iiconnect;
pub mod public_fetch;
pub mod skills;
pub mod tools;

Expand Down
Loading
Loading