diff --git a/.github/workflows/deploy-native.yml b/.github/workflows/deploy-native.yml index 06980db..c13fa0f 100644 --- a/.github/workflows/deploy-native.yml +++ b/.github/workflows/deploy-native.yml @@ -249,10 +249,11 @@ jobs: # Unset -> endpoint 404s. An optional secret that neither caller # passes arrives here as the empty string. OPENAI_APPS_CHALLENGE_TOKEN: ${{ secrets.OPENAI_APPS_CHALLENGE_TOKEN }} - # Client ID Metadata Documents: advertised only where this GitHub - # Environment defines the OAUTH_CIMD_ENABLED variable as `1` (this job - # runs in that environment, so its variables resolve here). Unset - # arrives as the empty string, which the server reads as off. + # Client ID Metadata Documents: on, unless this GitHub Environment + # defines the OAUTH_CIMD_ENABLED variable as `0` (this job runs in that + # environment, so its variables resolve here). Unset arrives as the + # empty string, which the server reads as on. A roll-out kill switch + # only, to be removed. OAUTH_CIMD_ENABLED: ${{ vars.OAUTH_CIMD_ENABLED }} run: deploy/native/deploy.sh diff --git a/README.md b/README.md index 792eb12..525c150 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ async fn main() -> anyhow::Result<()> { clients: SharedClients::load(&state_dir), state_dir, require_resource: true, // strict RFC 8707 (reject a missing `resource`) + cimd_enabled: true, // Client ID Metadata Documents (URL client_ids); false turns them off }); server.spawn_session_reaper(); let app = axum::Router::new() @@ -599,6 +600,10 @@ when hosting: falsey value (`0`/`false`/`no`/`off`) only if you must serve a client too old to send `resource`; that reopens the confused-deputy path for such clients, so prefer updating the client. +- **`OAUTH_CIMD_ENABLED`** — Client ID Metadata Documents, **on by default**. A + falsey value (`0`/`false`/`no`/`off`) switches them off, in which case Claude + and ChatGPT register through DCR instead. A kill switch for the roll-out only, + to be removed once CIMD has run in production for a while. A `Dockerfile` is included (works on Render / Fly / Cloud Run / Koyeb). The reference deployment (`deploy/native/`, see its README) instead runs the binary @@ -716,11 +721,12 @@ its AS issuer is `/mcp` and everything OAuth lives under it: once per process (however many instances the binary mounts), four per host, so one slow host cannot hold up the rest. The fetch connects directly, never through a proxy from the environment, so the address pin always binds. Claude and ChatGPT both select CIMD over DCR when it is - advertised — which it is only where `OAUTH_CIMD_ENABLED=1` is set (the deploy - template takes it from the GitHub Environment's variable of that name, so a - deploy never enables it by itself; to roll back, unset it and redeploy — the - value is read once at start-up, so the variable alone changes nothing — and - clients re-read the metadata within minutes and fall back to DCR). Only a + advertised — which it is by default: `McpConfig::cimd_enabled` for an embedding + host, and the `imcp2` binary has it on unless `OAUTH_CIMD_ENABLED` is falsey + (a roll-out kill switch, to be removed; the deploy template takes it from the + GitHub Environment's variable of that name and the value is read once at + start-up, so switching means setting it and redeploying — clients then re-read + the metadata within minutes and fall back to DCR). Only a document on a vetted vendor origin is fetched at all — a host on or under an allow-listed domain, default port (the trust policy of PR #143); any other URL `client_id` is refused before any request and pointed at the allow-listing diff --git a/deploy/native/README.md b/deploy/native/README.md index bd2c7b1..3087309 100644 --- a/deploy/native/README.md +++ b/deploy/native/README.md @@ -313,13 +313,13 @@ addresses, since the ship job runs inside the VPN. | `DEPLOY_KNOWN_HOSTS` | `PROD_DEPLOY_KNOWN_HOSTS` | *(optional)* output of `ssh-keyscan `; pin it to avoid trust-on-first-use | | `OPENAI_APPS_CHALLENGE_TOKEN` | *(same name)* | *(optional)* OpenAI Apps domain-verification token, served at `/.well-known/openai-apps-challenge` (404 while unset). Submission-specific rather than host-specific, so one repository-level secret feeds both environments | -One setting is a GitHub Environment **variable** rather than a secret (**Settings → -Environments → *staging* / *production* → Variables**); `deploy.sh` renders it into -the unit like the secrets above: +One optional setting is a GitHub Environment **variable** rather than a secret +(**Settings → Environments → *staging* / *production* → Variables**); `deploy.sh` +renders it into the unit like the secrets above: | Variable | Value | |---|---| -| `OAUTH_CIMD_ENABLED` | `1` to advertise Client ID Metadata Documents — the registration mode Claude and ChatGPT prefer over DCR — on that environment; unset or empty is off. Off by default so a routine deploy never switches the directory clients over by itself: enable it on staging first, then production. The value is rendered into the unit at deploy time and read once at start-up, so to roll back, unset (or clear) the variable **and redeploy** — `workflow_dispatch` with the same ref is enough, no rebuild; changing the variable alone changes nothing on the host | +| `OAUTH_CIMD_ENABLED` | Client ID Metadata Documents — the registration mode Claude and ChatGPT prefer over DCR — are **on** unless this is a falsey value (`0`/`false`/`no`/`off`); unset or empty is on. A roll-out kill switch, to be removed once CIMD has run in production for a while. The value is rendered into the unit at deploy time and read once at start-up, so to switch CIMD off, set the variable to `0` **and redeploy** — `workflow_dispatch` with the same ref is enough, no rebuild; changing the variable alone changes nothing on the host | > **Set these as repository-level secrets** (**Settings → Secrets and variables → > Actions**). The callers pass them into the reusable workflow, and a job that calls diff --git a/deploy/native/deploy.sh b/deploy/native/deploy.sh index 56e91cd..610f107 100755 --- a/deploy/native/deploy.sh +++ b/deploy/native/deploy.sh @@ -80,9 +80,9 @@ tar -C "$repo_root" -cf - monitoring | $SSH "tar -C $REMOTE_DIR -xf -" echo ">> rendering + installing units and Caddyfile, then (re)starting services" # MCP_SERVE_BETA is set (to "1") only for the staging deployment, so /mcp-beta # is exposed there and not in production; it defaults to empty (off) otherwise. -# OAUTH_CIMD_ENABLED ("1" to advertise Client ID Metadata Documents) comes from -# the GitHub Environment's variable of that name and defaults to empty (off), -# so enabling CIMD is a per-environment decision, never a side effect of a deploy. +# OAUTH_CIMD_ENABLED comes from the GitHub Environment's variable of that name: +# Client ID Metadata Documents are on unless it is a falsey value (0/false/no/off); +# empty (unset) is on. A roll-out kill switch only, to be removed. unit_mcp="$(sed -e "s#__PUBLIC_URL__#https://$DOMAIN#g" -e "s#__MCP_SERVE_BETA__#${MCP_SERVE_BETA:-}#g" -e "s#__OPENAI_APPS_CHALLENGE_TOKEN__#${OPENAI_APPS_CHALLENGE_TOKEN:-}#g" -e "s#__OAUTH_CIMD_ENABLED__#${OAUTH_CIMD_ENABLED:-}#g" "$here/imcp2.service")" # SERVE_STATUS likewise is set (to "1") only for the staging deployment. Staging # keeps the Caddyfile's marked /status/ block (minus the marker lines) so the diff --git a/deploy/native/imcp2.service b/deploy/native/imcp2.service index 9c838b9..754698f 100644 --- a/deploy/native/imcp2.service +++ b/deploy/native/imcp2.service @@ -32,13 +32,13 @@ Environment=MCP_SERVE_METRICS=1 # deploy inputs). Environment=OPENAI_APPS_CHALLENGE_TOKEN=__OPENAI_APPS_CHALLENGE_TOKEN__ # Client ID Metadata Documents (CIMD), the registration mode Claude and ChatGPT -# prefer over DCR: OFF unless this is `1`. deploy.sh substitutes -# __OAUTH_CIMD_ENABLED__ from the OAUTH_CIMD_ENABLED variable of the GitHub -# Environment being deployed (empty when unset), so a routine deploy never -# switches the directory clients over by itself: enable it on staging first, -# then production, each deliberately. Rendered at deploy time and read once at -# start-up, so to roll back, unset the variable AND redeploy (the same ref will -# do — no rebuild); changing the variable alone changes nothing on the host. +# prefer over DCR: ON unless this is a falsey value (0/false/no/off). deploy.sh +# substitutes __OAUTH_CIMD_ENABLED__ from the OAUTH_CIMD_ENABLED variable of the +# GitHub Environment being deployed (empty when unset, which is on). A roll-out +# kill switch only, to be removed once CIMD has run in production for a while. +# Rendered at deploy time and read once at start-up, so to switch CIMD off, set +# the variable to 0 AND redeploy (the same ref will do — no rebuild); changing +# the variable alone changes nothing on the host. Environment=OAUTH_CIMD_ENABLED=__OAUTH_CIMD_ENABLED__ # imcp2 creates its operational files (today: the dynamic-client-registration # store, so OAuth clients that cached their client_id keep working across diff --git a/docs/anthropic-directory-submission.md b/docs/anthropic-directory-submission.md index 95b6d5e..a199422 100644 --- a/docs/anthropic-directory-submission.md +++ b/docs/anthropic-directory-submission.md @@ -59,7 +59,7 @@ submission — and match a live scan of a deployed instance of that build | HTTPS remote server, Streamable HTTP transport | ✅ `rmcp` streamable-HTTP, stateless, JSON responses ([`src/lib.rs`](../src/lib.rs)) | | OAuth 2.0, authorization-code + PKCE **S256**, advertised in metadata | ✅ `code_challenge_methods_supported: ["S256"]` in the live RFC 8414 document | | Dynamic Client Registration (RFC 7591) — the out-of-the-box `oauth_dcr` mode | ✅ live probe: `POST /mcp/oauth/register` with the claude.ai callback → `201` | -| Client ID Metadata Documents — the `oauth_cimd` mode Anthropic recommends over DCR for directory listings | ✅ implemented, trust-policy-gated per the scoping in PR #143; advertised as `client_id_metadata_document_supported: true` alongside `"none"` in `token_endpoint_auth_methods_supported` — the two flags Claude requires to select CIMD — only where the deployment sets `OAUTH_CIMD_ENABLED=1` (off by default; enable per environment). Claude Code's live document (`https://claude.ai/oauth/claude-code-client-metadata`) is a fixture of the parsing test ([`src/auth.rs`](../src/auth.rs), `cimd_client_id` / `parse_client_metadata`) | +| Client ID Metadata Documents — the `oauth_cimd` mode Anthropic recommends over DCR for directory listings | ✅ implemented, trust-policy-gated per the scoping in PR #143; advertised as `client_id_metadata_document_supported: true` alongside `"none"` in `token_endpoint_auth_methods_supported` — the two flags Claude requires to select CIMD — by default (`McpConfig::cimd_enabled`; the `imcp2` binary has it on unless `OAUTH_CIMD_ENABLED=0`). Claude Code's live document (`https://claude.ai/oauth/claude-code-client-metadata`) is a fixture of the parsing test ([`src/auth.rs`](../src/auth.rs), `cimd_client_id` / `parse_client_metadata`) | | Claude's hosted callback `https://claude.ai/api/mcp/auth_callback` accepted | ✅ seeded in the redirect allow-list ([`src/auth.rs`](../src/auth.rs), `DEFAULT_ALLOWED_REDIRECTS`) | | Claude Code loopback redirects (RFC 8252) | ✅ loopback redirects are exempt from the hosted allow-list | | Discovery documents (RFC 8414 + RFC 9728, path-scoped + root fallback) | ✅ all four live, `WWW-Authenticate` on the 401 points at the resource metadata | @@ -81,8 +81,8 @@ Claude registers a new client on each fresh connection (the registration store is a bounded LRU of 10,000, which tolerates that churn); Anthropic recommends **CIMD** (Client ID Metadata Documents) for high-traffic directory listings, and the server implements it (PR #143's trust-policy-gated design) and -advertises it where `OAUTH_CIMD_ENABLED=1` is set, so there Claude selects CIMD -and registers nothing. +advertises it by default (`McpConfig::cimd_enabled`; the `imcp2` binary has it +on unless `OAUTH_CIMD_ENABLED=0`), so Claude selects CIMD and registers nothing. ## Blockers to resolve before submitting diff --git a/docs/openai-directory-submission.md b/docs/openai-directory-submission.md index 5e4393c..a6edebb 100644 --- a/docs/openai-directory-submission.md +++ b/docs/openai-directory-submission.md @@ -64,7 +64,7 @@ add details not published in the docs. | Requirement | Status | |---|---| | OAuth 2.1 authorization-code + PKCE **S256**, per the MCP authorization spec | ✅ live; `code_challenge_methods_supported: ["S256"]` | -| Client registration: CIMD preferred; DCR (`registration_endpoint`) and predefined clients also accepted | ✅ both. CIMD implemented (trust-policy-gated per PR #143), advertised as `client_id_metadata_document_supported: true` where the deployment sets `OAUTH_CIMD_ENABLED=1` (off by default) — ChatGPT's live document (`https://chatgpt.com/oauth/client.json`) is a fixture of the parsing test ([`src/auth.rs`](../src/auth.rs)); it prefers `private_key_jwt` but lists `none`, which is what it uses against this AS — and RFC 7591 DCR live and verified | +| Client registration: CIMD preferred; DCR (`registration_endpoint`) and predefined clients also accepted | ✅ both. CIMD implemented (trust-policy-gated per PR #143), advertised as `client_id_metadata_document_supported: true` by default (`McpConfig::cimd_enabled`; the `imcp2` binary has it on unless `OAUTH_CIMD_ENABLED=0`) — ChatGPT's live document (`https://chatgpt.com/oauth/client.json`) is a fixture of the parsing test ([`src/auth.rs`](../src/auth.rs)); it prefers `private_key_jwt` but lists `none`, which is what it uses against this AS — and RFC 7591 DCR live and verified | | Discovery documents (RFC 8414 AS metadata + RFC 9728 protected-resource) | ✅ all live, path-scoped + root fallback | | Both of ChatGPT's callbacks accepted — `https://chatgpt.com/connector_platform_oauth_redirect` (the form it sends us) and `https://chatgpt.com/connector/oauth/{callback_id}` | ✅ the redirect allow-list pins both paths for `chatgpt.com` ([`src/auth.rs`](../src/auth.rs), `DEFAULT_ALLOWED_REDIRECTS`) | | No machine-to-machine grants (client credentials etc. unsupported by ChatGPT) | ✅ user-consent authorization-code flow only | diff --git a/monitoring/mcp-status/checks.js b/monitoring/mcp-status/checks.js index bf39b28..d2a3054 100644 --- a/monitoring/mcp-status/checks.js +++ b/monitoring/mcp-status/checks.js @@ -415,7 +415,7 @@ export const checkMcpEndpoints = async ( id: "as-metadata", label: "OAuth Authorization Server Metadata", description: - "Verifies the RFC 8414 metadata advertising the authorize/token/registration endpoints and PKCE support that clients need to log in, and reports whether Client ID Metadata Documents are advertised (the registration mode Claude and ChatGPT prefer over DCR; on only where the server runs with OAUTH_CIMD_ENABLED=1).", + "Verifies the RFC 8414 metadata advertising the authorize/token/registration endpoints and PKCE support that clients need to log in, and reports whether Client ID Metadata Documents are advertised (the registration mode Claude and ChatGPT prefer over DCR; on by default, off only where the server runs with OAUTH_CIMD_ENABLED set to a falsey value).", target: `GET ${url}`, expected: "200 JSON with issuer + authorize/token/register endpoints", status: pass ? "pass" : "fail", diff --git a/src/auth.rs b/src/auth.rs index 817798e..5a033e3 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -785,16 +785,14 @@ fn loopback_match(registered: &str, requested: &str) -> bool { // ones; were one added, the only attested fact is the HOST of the `client_id` // URL — never the self-asserted `client_name` or `logo_uri`. // -// OPT-IN per deployment: `OAUTH_CIMD_ENABLED=1` advertises the mechanism and -// accepts URL `client_id`s; unset, the metadata does not advertise it and a URL -// `client_id` is an unknown client. Claude and ChatGPT both switch to CIMD the -// moment an AS advertises it, so a routine deploy must never switch them over -// by itself (the deploy template takes the variable from the GitHub -// Environment). The rollback, should a vendor's document turn out to be shaped -// in a way this implementation refuses, is to unset the variable AND redeploy: -// it is rendered into the unit at deploy time and read here once at start-up -// ([`cimd_enabled_by_env`]), so changing it alone changes nothing on the host. -// Once the process restarts without it, the clients re-read the metadata within +// Per deployment ([`crate::McpConfig::cimd_enabled`]): on, the metadata +// advertises the mechanism and URL `client_id`s are accepted; off, a URL +// `client_id` is an unknown client. Claude and ChatGPT both select CIMD the +// moment an AS advertises it, so it is normally on: an embedding host sets the +// field, and the `imcp2` binary has it on unless `OAUTH_CIMD_ENABLED`, a +// roll-out kill switch slated for removal, says off. The rollback, should a +// vendor's document turn out to be shaped in a way this implementation refuses, +// is to switch it off and restart; the clients re-read the metadata within // minutes and fall back to DCR. /// Byte cap on a `client_id` URL before it is treated as CIMD at all: the URL @@ -844,29 +842,6 @@ const CIMD_CACHE_MAX_TTL: Duration = Duration::from_secs(24 * 60 * 60); /// out a real client; nor is a transient (a deadline, a 5xx), which is retried. const CIMD_NEGATIVE_TTL: Duration = Duration::from_secs(60); -/// Whether this process is deployed with CIMD on: `OAUTH_CIMD_ENABLED` set to -/// an on-value ([`cimd_enabled_by`]). Read once (the env is process-static), -/// like the allow-list's `OAUTH_ALLOWED_REDIRECT_PREFIXES`; each [`AuthStore`] -/// takes its own copy at construction, which tests set directly. -fn cimd_enabled_by_env() -> bool { - static ENABLED: std::sync::OnceLock = std::sync::OnceLock::new(); - *ENABLED.get_or_init(|| { - let enabled = cimd_enabled_by(std::env::var("OAUTH_CIMD_ENABLED").ok().as_deref()); - if enabled { - tracing::info!("OAUTH_CIMD_ENABLED is set: Client ID Metadata Documents are on"); - } - enabled - }) -} - -/// The opt-in's reading of `OAUTH_CIMD_ENABLED`: `1`, `true`, `yes` and `on` -/// (any case) switch CIMD on; unset, empty, or anything else leaves it off. -fn cimd_enabled_by(value: Option<&str>) -> bool { - value.is_some_and(|v| { - matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on") - }) -} - /// A validated Client ID Metadata Document: what this server needs from it. #[derive(Clone, Debug, PartialEq, Eq)] struct ClientMetadata { @@ -1476,8 +1451,8 @@ pub struct AuthStore { /// bounds — the PROCESS's ([`CimdState::shared`]), so every mounted instance /// draws on the same bounds; a test may give a store its own. cimd: Arc, - /// Whether URL `client_id`s are accepted and CIMD advertised: the process's - /// `OAUTH_CIMD_ENABLED` ([`cimd_enabled_by_env`]), or what a test set. + /// Whether URL `client_id`s are accepted and CIMD advertised + /// ([`crate::McpConfig::cimd_enabled`]). cimd_enabled: bool, } @@ -1587,6 +1562,7 @@ impl AuthStore { public_url: String, mcp_path: String, require_resource: bool, + cimd_enabled: bool, ) -> Self { Self { clients: clients.0, @@ -1598,12 +1574,12 @@ impl AuthStore { mcp_path, require_resource, cimd: CimdState::shared(), - cimd_enabled: cimd_enabled_by_env(), + cimd_enabled, } } - /// This store with CIMD switched on or off, whatever the environment says, - /// and with CIMD state of its own, so tests do not see each other's flights. + /// This store with CIMD switched on or off, and with CIMD state of its own, + /// so tests do not see each other's flights. #[cfg(test)] fn with_cimd(mut self, enabled: bool) -> Self { self.cimd_enabled = enabled; @@ -3015,9 +2991,8 @@ pub async fn authorization_server_metadata(State(store): State) -> Re // identify itself with the https URL of its Client ID Metadata Document // instead of registering (see `cimd_client_id`). Claude and ChatGPT both // select CIMD over DCR when this is advertised alongside `none` above — - // which is why it is advertised only where the deployment opts in with - // `OAUTH_CIMD_ENABLED`, and a redeploy without that withdraws it (no - // rebuild; the value is read once at start-up). + // which is why it is advertised only where the deployment opts in + // (`McpConfig::cimd_enabled`), and switching that off withdraws it. "client_id_metadata_document_supported": store.cimd_enabled, // RFC 9207: we emit `iss` on every authorization response, so we MUST // advertise it here (a client that sees this flag rejects any response @@ -3628,19 +3603,6 @@ mod tests { assert!(invalid(FetchError::NotUtf8("bytes".into()))); } - /// The deploy-time opt-in's reading of its variable: off unless it says on. - #[test] - fn cimd_opt_in_values() { - use super::cimd_enabled_by; - let off = [None, Some(""), Some(" "), Some("0"), Some("false"), Some("no"), Some("off")]; - for value in off.into_iter().chain([Some("enabled"), Some("2")]) { - assert!(!cimd_enabled_by(value), "{value:?} should leave CIMD off"); - } - for value in [Some("1"), Some("true"), Some("Yes"), Some("ON"), Some(" 1 ")] { - assert!(cimd_enabled_by(value), "{value:?} should turn CIMD on"); - } - } - /// PR #143's trust policy: a document is fetched only from a vetted vendor /// origin — a host on or under an allow-listed domain, on the default port. /// Any other URL `client_id` is refused before any fetch and told where to @@ -4365,13 +4327,13 @@ mod tests { } fn test_store_cfg(require_resource: bool) -> super::AuthStore { - // As deployed with `OAUTH_CIMD_ENABLED=1`, and with CIMD state of its own so - // tests do not see each other's flights; the off case sets this itself. + // As a deployment with CIMD on, and with CIMD state of its own so tests do + // not see each other's flights; the off case sets this itself. new_store(require_resource).with_cimd(true) } - /// A store exactly as [`super::AuthStore::new`] builds it: CIMD per the - /// environment (off under `cargo test`), CIMD state shared process-wide. + /// A store exactly as [`super::AuthStore::new`] builds it with CIMD off, CIMD + /// state shared process-wide. fn new_store(require_resource: bool) -> super::AuthStore { use candid::Principal; use imcp2_core::identities::{Identities, IiInstance}; @@ -4395,6 +4357,7 @@ mod tests { "https://mcp.test".into(), "/mcp".into(), require_resource, + false, ) } @@ -4639,6 +4602,7 @@ mod tests { "https://mcp.test".into(), mcp_path.into(), false, + false, ) }; // Both instances, so the allow-list covers each mount. diff --git a/src/e2e_handshake.rs b/src/e2e_handshake.rs index 846981d..d90113c 100644 --- a/src/e2e_handshake.rs +++ b/src/e2e_handshake.rs @@ -340,6 +340,7 @@ async fn registration_delegation_end_to_end() { state_dir: state_dir.clone(), // The handshake under test carries no `resource`; keep it lenient. require_resource: false, + cimd_enabled: false, }); let app = axum::Router::new() .nest_service(server.mcp_path(), server.mcp_router()) diff --git a/src/lib.rs b/src/lib.rs index 901a600..48bb3d7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -42,6 +42,7 @@ //! clients: SharedClients::load(&state_dir), //! state_dir, //! require_resource: true, // strict RFC 8707 (reject a missing `resource`) +//! cimd_enabled: true, // Client ID Metadata Documents (URL client_ids); false turns them off //! }); //! server.spawn_session_reaper(); //! let app = axum::Router::new() @@ -149,6 +150,14 @@ pub struct McpConfig { /// of turning away any client predating RFC 8707. When `false`, a missing /// `resource` is tolerated (a present one must still match). pub require_resource: bool, + /// Client ID Metadata Documents: when `true`, the AS metadata advertises + /// `client_id_metadata_document_supported` and a URL `client_id` on a vetted + /// vendor origin is accepted by fetching its document; when `false`, a URL + /// `client_id` is an unknown client. Claude and ChatGPT select CIMD the + /// moment it is advertised, so a deployment normally sets `true`. Set by the + /// embedding application; the `imcp2` binary has it on unless + /// `$OAUTH_CIMD_ENABLED` switches it off. + pub cimd_enabled: bool, } /// One MCP server instance: the shared state behind [`Self::mcp_router`] and @@ -187,6 +196,7 @@ impl McpServer { public_url.clone(), mcp_path.clone(), config.require_resource, + config.cimd_enabled, ); Self { agent: config.agent, @@ -618,6 +628,7 @@ mod lib_tests { clients: SharedClients::load(&dir), state_dir: dir, require_resource: true, + cimd_enabled: false, }) } @@ -644,6 +655,7 @@ mod lib_tests { clients: SharedClients::load(std::env::temp_dir().join("dir-a")), state_dir: std::env::temp_dir().join("dir-b"), require_resource: true, + cimd_enabled: false, }); } diff --git a/src/main.rs b/src/main.rs index 916fc2c..d2418bc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -79,6 +79,24 @@ fn serve_beta() -> bool { .unwrap_or(false) } +/// Whether to advertise and accept Client ID Metadata Documents, the +/// registration mode Claude and ChatGPT prefer over DCR. **On by default**; a +/// falsey `$OAUTH_CIMD_ENABLED` (`0`/`false`/`no`/`off`) switches it off. Read +/// once and handed to every instance, so switching means setting it and +/// redeploying. +/// +/// TODO: remove the variable (pass `true` to every instance) once CIMD has run +/// in production for a while; it exists only as a kill switch for the roll-out. +fn cimd_enabled() -> bool { + cimd_enabled_by(std::env::var("OAUTH_CIMD_ENABLED").ok().as_deref()) +} + +fn cimd_enabled_by(value: Option<&str>) -> bool { + !value.is_some_and(|v| { + matches!(v.trim().to_ascii_lowercase().as_str(), "0" | "false" | "no" | "off") + }) +} + /// Whether to serve the Prometheus exposition at `/metrics`. Off unless /// `$MCP_SERVE_METRICS` is truthy (`1`/`true`/`yes`/`on`). /// @@ -291,6 +309,12 @@ async fn main() -> anyhow::Result<()> { // the operational directory. let clients = SharedClients::load(&state_dir); + // Client ID Metadata Documents: on for every instance unless the deploy says no. + let cimd_on = cimd_enabled(); + if !cimd_on { + tracing::warn!("OAUTH_CIMD_ENABLED is off: Client ID Metadata Documents are disabled"); + } + // Production Internet Identity at `/mcp`: always served, and the origin's // default instance (it answers the plain-root discovery probes). A // self-contained McpServer whose sessions/tokens never cross instances. @@ -302,6 +326,7 @@ async fn main() -> anyhow::Result<()> { clients: clients.clone(), state_dir: state_dir.clone(), require_resource: require_resource(), + cimd_enabled: cimd_on, }); prod.spawn_session_reaper(); @@ -316,6 +341,7 @@ async fn main() -> anyhow::Result<()> { clients, state_dir, require_resource: require_resource(), + cimd_enabled: cimd_on, }); beta.spawn_session_reaper(); Some(beta) @@ -543,10 +569,24 @@ mod tests { landing_redirects_router, metrics_router, openai_apps_challenge_router, serve_metrics, site_metadata_router, }; + use axum::http::{Request, StatusCode}; use http_body_util::BodyExt; use tower::ServiceExt; + /// `OAUTH_CIMD_ENABLED`'s reading: on unless it says off. + #[test] + fn cimd_opt_out_values() { + use super::cimd_enabled_by; + let on = [None, Some(""), Some(" "), Some("1"), Some("true"), Some("yes"), Some("on")]; + for value in on.into_iter().chain([Some("disabled"), Some("2")]) { + assert!(cimd_enabled_by(value), "{value:?} should leave CIMD on"); + } + for value in [Some("0"), Some("false"), Some("No"), Some("OFF"), Some(" 0 ")] { + assert!(!cimd_enabled_by(value), "{value:?} should switch CIMD off"); + } + } + /// The exposition must be **off** unless asked for, and the ask must be /// explicit. This is the security-relevant half of the gate: the native host /// has a proxy answering `/metrics` with a 404, but the bundled `Dockerfile` on diff --git a/src/metrics.rs b/src/metrics.rs index 782a7b0..1980217 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -329,6 +329,7 @@ mod tests { clients: crate::SharedClients::load(std::env::temp_dir()), state_dir: std::env::temp_dir(), require_resource: true, + cimd_enabled: false, }) } diff --git a/tests/routers.rs b/tests/routers.rs index a5af63c..d6236cc 100644 --- a/tests/routers.rs +++ b/tests/routers.rs @@ -44,6 +44,7 @@ fn server(instance: imcp2::IiInstance, mcp_path: &str) -> imcp2::McpServer { // Lenient: these router contract tests drive flows that don't carry a // `resource`; strict RFC 8707 is covered by the auth unit tests. require_resource: false, + cimd_enabled: false, }) }