Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 5 additions & 4 deletions .github/workflows/deploy-native.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 11 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -716,11 +721,12 @@ its AS issuer is `<PUBLIC_URL>/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
Expand Down
8 changes: 4 additions & 4 deletions deploy/native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <host>`; 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
Expand Down
6 changes: 3 additions & 3 deletions deploy/native/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 7 additions & 7 deletions deploy/native/imcp2.service
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions docs/anthropic-directory-submission.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/openai-directory-submission.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion monitoring/mcp-status/checks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading