From a1cad93ed9b4c5ac7e017166306a4912da9395e2 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 11 Sep 2026 02:31:47 -0400 Subject: [PATCH 1/5] fix: exempt loopback hosts from the CIMD metadata URL HTTPS requirement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getCimdClientMetadataUrlError` rejected every non-HTTPS CIMD client metadata URL with no loopback exemption, and the same check runs against `client.json` on disk through `refineCimdMetadataUrl`, so it could not be worked around by hand-editing the file either. Every test server in this repo speaks plain HTTP, so CIMD could not be driven against any local fixture: #2242 shipped verified by tests alone, and reproducing it by hand meant standing up a self-signed HTTPS listener plus `NODE_TLS_REJECT_UNAUTHORIZED=0` in the test server's environment. Accept plain `http:` on the three literals the SDK itself exempts from its token-endpoint TLS assertion — `localhost`, `127.0.0.1` and `[::1]`. That is option 1 in #2305 and matches what the runtime already tolerated: `core/auth/cimd.ts` notes that an already-stored `client_id` may be an `http://` URL used by local dev/test metadata servers. Anything outside those three, `localhost.` and `tenant.app.localhost` included, is still rejected — matching the SDK literal for literal keeps the two allow-lists from disagreeing about one URL. The exemption is a literal set rather than `isLoopbackHost` because `core/client/config-parse.ts` is browser-safe and `core/node/hostUrl.ts` imports `node:net`. The field error now names the exemption instead of saying only "must use HTTPS", and the form's hint and description say the same, so a developer pointing the field at a local fixture is told which spellings work. Closes #2305 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GfHQ8vMm5NBgzbUJ6UwxmB Signed-off-by: cliffhall --- .../ClientSettingsForm/ClientSettingsForm.tsx | 7 ++- .../web/src/test/core/client/config.test.ts | 57 +++++++++++++++++++ .../mcp/oauth-cimd-fixture.test.ts | 32 +++++++++-- core/client/config-parse.ts | 45 ++++++++++++++- docs/test-servers.md | 25 +++++--- 5 files changed, 146 insertions(+), 20 deletions(-) diff --git a/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.tsx b/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.tsx index 8014aa08..4a440c8a 100644 --- a/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.tsx +++ b/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.tsx @@ -307,15 +307,16 @@ export function ClientSettingsForm({ {settings.cimdEnabled && ( <> - The metadata document must be served over HTTPS and list - this redirect URI:{" "} + The metadata document must be served over HTTPS — or over + plain HTTP from localhost, 127.0.0.1 or [::1], for a local + fixture — and list this redirect URI:{" "} {typeof window !== "undefined" ? `${window.location.origin}/oauth/callback` : "http://localhost:6274/oauth/callback"} patch({ clientMetadataUrl: e.currentTarget.value }) diff --git a/clients/web/src/test/core/client/config.test.ts b/clients/web/src/test/core/client/config.test.ts index bb2b7712..3b6e2663 100644 --- a/clients/web/src/test/core/client/config.test.ts +++ b/clients/web/src/test/core/client/config.test.ts @@ -206,6 +206,19 @@ describe("client config", () => { ).toThrow(/HTTPS/); }); + it("parseClientConfig accepts a loopback http CIMD URL", () => { + for (const clientMetadataUrl of [ + "http://localhost:8090/client-metadata.json", + "http://127.0.0.1:8090/client-metadata.json", + "http://[::1]:8090/client-metadata.json", + ]) { + const config = parseClientConfig({ + cimd: { enabled: true, clientMetadataUrl }, + }); + expect(config.cimd?.clientMetadataUrl).toBe(clientMetadataUrl); + } + }); + it("parseClientConfig rejects CIMD URL without path", () => { expect(() => parseClientConfig({ @@ -299,6 +312,50 @@ describe("client config", () => { CIMD_METADATA_URL_PATH_ERROR, ); }); + + it("accepts plain http on the three exempt loopback literals", () => { + expect( + getCimdClientMetadataUrlError( + "http://localhost:8090/client-metadata.json", + ), + ).toBeUndefined(); + expect( + getCimdClientMetadataUrlError( + "http://127.0.0.1:8090/client-metadata.json", + ), + ).toBeUndefined(); + expect( + getCimdClientMetadataUrlError("http://[::1]:8090/client-metadata.json"), + ).toBeUndefined(); + }); + + it("still requires a path on an exempt loopback host", () => { + expect(getCimdClientMetadataUrlError("http://localhost:8090")).toBe( + CIMD_METADATA_URL_PATH_ERROR, + ); + }); + + it("does not widen the exemption beyond the three literals", () => { + // `localhost.` and `*.localhost` resolve to loopback on every resolver, + // but they are outside the SDK's allow-list — so they are outside ours + // too, deliberately, rather than the two disagreeing about one URL. + for (const value of [ + "http://tenant.app.localhost:3300/client-metadata.json", + "http://127.0.0.2:8090/client-metadata.json", + "http://localhost.example.com/client-metadata.json", + ]) { + expect(getCimdClientMetadataUrlError(value)).toBe( + CIMD_METADATA_URL_HTTPS_ERROR, + ); + } + // The root-anchored spelling never reaches the protocol check at all: + // `isAbsoluteHttpUrl` rejects its trailing empty label first, so it is + // flagged as unparseable rather than as non-HTTPS. Pinned so a later + // change to either check has to decide about this case on purpose. + expect( + getCimdClientMetadataUrlError("http://localhost./client-metadata.json"), + ).toBe(CIMD_METADATA_URL_INVALID_ERROR); + }); }); it("formatClientConfigLoadError summarizes Zod validation failures", () => { diff --git a/clients/web/src/test/integration/mcp/oauth-cimd-fixture.test.ts b/clients/web/src/test/integration/mcp/oauth-cimd-fixture.test.ts index 759d1d2a..96f30192 100644 --- a/clients/web/src/test/integration/mcp/oauth-cimd-fixture.test.ts +++ b/clients/web/src/test/integration/mcp/oauth-cimd-fixture.test.ts @@ -10,6 +10,10 @@ import { loadConfig, resolveConfig, } from "@modelcontextprotocol/inspector-test-server"; +import { + getCimdClientMetadataUrlError, + parseClientConfig, +} from "@inspector/core/client/config-parse.js"; /** * Live coverage of `test-servers/configs/oauth-cimd-http.json` — the fixture @@ -34,11 +38,13 @@ import { * - the document's `client_id` equals the URL it was fetched from, which is * what makes it a legal CIMD client id rather than an arbitrary blob. * - * The document is served over plain HTTP here. That is deliberate and is *not* - * a usable `clientMetadataUrl` for the Inspector, which requires HTTPS with no - * loopback exemption (#2305) — driving the UI needs a self-signed HTTPS - * listener, as `docs/test-servers.md` describes. This endpoint exists for the - * authorization-server side of the flow and for exactly these assertions. + * The document is served over plain HTTP here, on `localhost`. Since #2305 that + * *is* a usable `clientMetadataUrl`: the Inspector's HTTPS requirement now + * exempts the same three loopback literals the SDK exempts for token endpoints, + * so the fixture can be driven from the web client with no self-signed HTTPS + * listener. The last case below pins that, because it is the property the + * fixture's whole reason for existing rests on — a re-tightened validator would + * put the manual repro back out of reach without failing anything else here. */ describe("CIMD showcase fixture (#2242)", () => { let server: TestServerHttp | null = null; @@ -121,6 +127,22 @@ describe("CIMD showcase fixture (#2242)", () => { expect(doc.token_endpoint_auth_method).toBe("none"); }); + it("serves the document at a URL the Inspector accepts as a clientMetadataUrl", async () => { + const started = await startShowcase(); + const documentUrl = `${originOf(started)}/client-metadata.json`; + + // The #2305 property: no self-signed HTTPS listener, no + // NODE_TLS_REJECT_UNAUTHORIZED=0 — the served URL is legal config as-is, + // both inline in the settings form and in `client.json` on disk. + expect(new URL(documentUrl).protocol).toBe("http:"); + expect(getCimdClientMetadataUrlError(documentUrl)).toBeUndefined(); + expect( + parseClientConfig({ + cimd: { enabled: true, clientMetadataUrl: documentUrl }, + }).cimd?.clientMetadataUrl, + ).toBe(documentUrl); + }); + it("preserves a query-bearing document URL in the client_id it publishes", async () => { const started = await startShowcase(); const documentUrl = `${originOf(started)}/client-metadata.json?profile=a`; diff --git a/core/client/config-parse.ts b/core/client/config-parse.ts index 82c66f1f..fe783664 100644 --- a/core/client/config-parse.ts +++ b/core/client/config-parse.ts @@ -58,9 +58,48 @@ const HttpUrlStringSchema = z export const CIMD_METADATA_URL_INVALID_ERROR = "Must be a valid URL, like https://example.com/oauth/client.json"; -/** Field-level error when a CIMD metadata URL is not HTTPS. */ +/** + * Field-level error when a CIMD metadata URL is neither HTTPS nor served from + * one of the exempt loopback literals. The message names the exemption rather + * than saying only "must use HTTPS", so a developer pointing the field at a + * local fixture is told which spellings work instead of concluding that nothing + * local can (the same phrasing lesson as the token-endpoint notice in + * `core/auth/oauthUx.ts`). + */ export const CIMD_METADATA_URL_HTTPS_ERROR = - "CIMD client metadata URL must use HTTPS"; + "CIMD client metadata URL must use HTTPS, except on localhost, 127.0.0.1 or [::1]"; + +/** + * Hosts for which a plain `http:` CIMD metadata URL is accepted. + * + * SEP-991 expects HTTPS in production for a real reason: a CIMD `client_id` is + * a URL the authorization server dereferences, and over plain HTTP the document + * it gets back is attacker-modifiable in transit. Loopback is the documented + * exception to exactly that reasoning — there is no network segment to sit on — + * which is why the SDK exempts these same three literals from its token-endpoint + * TLS assertion, and why `core/auth/cimd.ts` already notes that an + * already-stored `client_id` may be an `http://` URL "used by local dev/test + * metadata servers". Without this, the runtime tolerated a value the config + * validation would not let anyone enter, and CIMD could not be driven against + * any fixture in this repo (#2305). + * + * ⚠️ This is the SDK's *three-literal* list, deliberately, and not the broader + * `isLoopbackHost` in `core/node/hostUrl.ts` — which imports `node:net` and so + * cannot be reached from this browser-safe module. Matching the SDK literal for + * literal also keeps the two allow-lists from disagreeing about the same URL. + * `::1` arrives from `URL.hostname` bracketed, which is the form stored here. + */ +const CIMD_HTTP_EXEMPT_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]); + +/** + * True when a plain-`http:` CIMD metadata URL on this host is acceptable. Takes + * the bare host (`URL.hostname`), never a `host:port`. Not exported: the only + * caller is the validator below, and the exemption is exercised through it + * rather than through a second public surface that could drift from it. + */ +function isCimdHttpExemptHost(hostname: string): boolean { + return CIMD_HTTP_EXEMPT_HOSTS.has(hostname); +} /** Field-level error when a CIMD metadata URL has no path segment. */ export const CIMD_METADATA_URL_PATH_ERROR = @@ -81,7 +120,7 @@ export function getCimdClientMetadataUrlError( } try { const url = new URL(trimmed); - if (url.protocol !== "https:") { + if (url.protocol !== "https:" && !isCimdHttpExemptHost(url.hostname)) { return CIMD_METADATA_URL_HTTPS_ERROR; } if (url.pathname === "/" || url.pathname === "") { diff --git a/docs/test-servers.md b/docs/test-servers.md index 55afa07a..806cace2 100644 --- a/docs/test-servers.md +++ b/docs/test-servers.md @@ -560,16 +560,23 @@ from **Client settings**, not from a server's own OAuth settings. A `clientMetad catalog entry's `oauth` block is silently ignored — and with `supportDCR: true` the connection then succeeds *via DCR*, which looks like CIMD working until you read the client id. -⚠️ **The Inspector requires that URL to be HTTPS, and there is no loopback exemption** +**Use this server's own document as the `clientMetadataUrl`** — `/client-metadata.json` on the origin +the server announced on startup (`http://127.0.0.1:8092/client-metadata.json` when it got its +configured port; read the announced URL, since this fixture walks upward on `EADDRINUSE` like every +other one here). The Inspector requires HTTPS *except* on the three +loopback literals `localhost`, `127.0.0.1` and `[::1]` (`getCimdClientMetadataUrlError` in `core/client/config-parse.ts`, applied to `client.json` on disk as -well as to the settings form). So this server's own `http://` document is **not** usable as a -`clientMetadataUrl`: it exists for the authorization-server side of the flow and for tests that drive -the AS directly. To drive the Inspector end to end you need the document served over HTTPS — -`https://127.0.0.1:8443/client-metadata.json` from a throwaway self-signed listener works, with -`NODE_TLS_REJECT_UNAUTHORIZED=0` in the *test server's* environment so its own fetch of that document -succeeds. That asymmetry is tracked in -[#2305](https://github.com/modelcontextprotocol/inspector/issues/2305); it is the same over-narrow -allow-list shape as the token-endpoint exemption above. +well as to the settings form), which is the same exemption the SDK applies to token endpoints and what +the runtime already tolerated for an already-stored `client_id`. Give the URL the host the server is +actually listening on; a host outside those three — `localhost.` and `tenant.app.localhost` included, +even though both resolve to loopback — is still rejected, deliberately, so this allow-list and the +SDK's cannot disagree about one URL. + +⚠️ **Before [#2305](https://github.com/modelcontextprotocol/inspector/issues/2305) there was no +loopback exemption at all**, so driving this flow meant standing up a throwaway self-signed HTTPS +listener to hold the document and setting `NODE_TLS_REJECT_UNAUTHORIZED=0` in the *test server's* +environment so its own fetch of that document would succeed. That workaround is no longer needed — if +you find it in a script or an older note, delete it. With that in place: set the metadata URL in Client settings, connect, and open **Connection Info**. It should read `Client registration — Client ID Metadata (CIMD)` with the **client id equal to the From 7b9090c024b42ac375089796778d8c007cdfcee7 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 11 Sep 2026 02:57:59 -0400 Subject: [PATCH 2/5] docs: drop the now-stale "tests only" CIMD loopback claim from the auth smoke guide `specification/v2_auth_smoke_testing.md` said `ensureCimdClientRegistration` allows `http://127.0.0.1` metadata URLs "in tests only". That was accurate while the exemption lived only in the runtime's already-stored-`client_id` path; after this PR a loopback `http://` URL is a valid persisted `client.json` setting and a valid entry in the settings form, so the sentence contradicted the workflow the same guide asks a smoke tester to run (Copilot review round 1). The same paragraph's recipe was stale in two other ways it is cheaper to fix here than to leave next to a corrected claim: `cd test-servers && npm run build` names a manifest that does not exist, and `oauth-cimd-only.json` is not a config in this repo. Both now point at the shipped `oauth-cimd-http.json` fixture and the real build command. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GfHQ8vMm5NBgzbUJ6UwxmB Signed-off-by: cliffhall --- specification/v2_auth_smoke_testing.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/specification/v2_auth_smoke_testing.md b/specification/v2_auth_smoke_testing.md index b2621975..c3168e16 100644 --- a/specification/v2_auth_smoke_testing.md +++ b/specification/v2_auth_smoke_testing.md @@ -510,14 +510,16 @@ Same MCP URL (`stytch-as-demo.val.run/mcp` or `mcp.stytch.dev/mcp`), leave CIMD ### Local fallback (composable test server) -For offline CIMD regression — or a **strict** fail-with-CIMD-off / succeed-with-CIMD-on pair without Stytch’s DCR fallback — use the in-repo `TestServerHttp` with `supportCIMD: true` and **`supportDCR: false`** (see `inspectorClient-oauth-e2e.test.ts`). `ensureCimdClientRegistration` allows `http://127.0.0.1` metadata URLs in tests only. +For offline CIMD regression — or a **strict** fail-with-CIMD-off / succeed-with-CIMD-on pair without Stytch’s DCR fallback — use the shipped `oauth-cimd-http.json` fixture, which sets `supportCIMD: true` and **`supportDCR: false`** and hosts the client metadata document itself at `/client-metadata.json`. ```bash -cd test-servers && npm run build -node build/server-composable.js --config path/to/oauth-cimd-only.json +cd clients/web && npm run test-servers:build +node test-servers/build/server-composable.js --config test-servers/configs/oauth-cimd-http.json ``` -Example composable OAuth flags: `"supportCIMD": true`, `"supportDCR": false`. With CIMD off in Client Settings, connect + authenticate should **fail**; with CIMD on and a valid local metadata URL, it should **succeed**. +Since [#2305](https://github.com/modelcontextprotocol/inspector/issues/2305) a loopback `http://` metadata URL is a **valid persisted `client.json` setting and a valid entry in the settings form**, not a test-only affordance — `getCimdClientMetadataUrlError` exempts `localhost`, `127.0.0.1` and `[::1]`, the same three literals the SDK exempts for token endpoints. So point `clientMetadataUrl` straight at the URL the fixture announced (`http://127.0.0.1:8092/client-metadata.json` on its configured port) with no HTTPS listener. Before that change the exemption existed only in `ensureCimdClientRegistration`, for an already-stored `client_id`, and driving this by hand needed a self-signed HTTPS listener plus `NODE_TLS_REJECT_UNAUTHORIZED=0`. + +With CIMD off in Client Settings, connect + authenticate should **fail**; with CIMD on and that local metadata URL, it should **succeed**. `docs/test-servers.md` has the full walkthrough, including the `redirect_uris` port trap. --- From a21fd0c161bc68a43a8f11d8591e85b6984c14ee Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 11 Sep 2026 03:12:07 -0400 Subject: [PATCH 3/5] docs: state the exemption's canonicalization contract, and fix the smoke recipe's cwd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 2. The exemption compares the WHATWG-canonicalized `URL.hostname`, so it covers alternate spellings of three loopback *addresses* rather than three input strings: `127.1`, `2130706433`, `0x7f.0.0.1`, `[0:0:0:0:0:0:0:1]` and a root-anchored `127.0.0.1.` all pass. The behavior is right — the security argument is about the address the request reaches, and all of these are the loopback interface — but the comment described it as a literal-input match, which it is not. It now states the contract, and six regression cases pin the aliases alongside the existing near-miss cases. This is also still an exact match for the SDK: `assertSecureTokenEndpoint` tests `url.hostname` against the same three values after the same `new URL` canonicalization, so an alias accepted here is one the SDK accepts too. Separately, the smoke guide's fixture recipe left the shell in `clients/web` after the build line, so the repo-root-relative paths on the next line resolved under `clients/web` and the copy-pasted setup failed before the server started. The build now runs in a subshell, and the block notes that the server announces its URL on stderr and walks past a taken port. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GfHQ8vMm5NBgzbUJ6UwxmB Signed-off-by: cliffhall --- .../web/src/test/core/client/config.test.ts | 22 ++++++++++++- core/client/config-parse.ts | 31 ++++++++++++++----- specification/v2_auth_smoke_testing.md | 6 +++- 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/clients/web/src/test/core/client/config.test.ts b/clients/web/src/test/core/client/config.test.ts index 3b6e2663..a3d94239 100644 --- a/clients/web/src/test/core/client/config.test.ts +++ b/clients/web/src/test/core/client/config.test.ts @@ -335,7 +335,27 @@ describe("client config", () => { ); }); - it("does not widen the exemption beyond the three literals", () => { + it("accepts alternate spellings that canonicalize to the same addresses", () => { + // The check runs against the WHATWG-canonicalized `URL.hostname`, so the + // exemption covers these three *addresses*, not three input strings. Each + // of these reaches the loopback interface, which is what the security + // argument is about — and the SDK canonicalizes identically, so an alias + // accepted here is one it accepts for a token endpoint too. + for (const value of [ + "http://127.1:8090/client-metadata.json", + "http://2130706433:8090/client-metadata.json", + "http://0x7f.0.0.1:8090/client-metadata.json", + "http://[0:0:0:0:0:0:0:1]:8090/client-metadata.json", + // WHATWG strips a root-anchored dot from an IP literal but not from a + // name, which is why this passes where `localhost.` below does not. + "http://127.0.0.1.:8090/client-metadata.json", + "http://LOCALHOST:8090/client-metadata.json", + ]) { + expect(getCimdClientMetadataUrlError(value)).toBeUndefined(); + } + }); + + it("does not widen the exemption beyond those three addresses", () => { // `localhost.` and `*.localhost` resolve to loopback on every resolver, // but they are outside the SDK's allow-list — so they are outside ours // too, deliberately, rather than the two disagreeing about one URL. diff --git a/core/client/config-parse.ts b/core/client/config-parse.ts index fe783664..48f762d4 100644 --- a/core/client/config-parse.ts +++ b/core/client/config-parse.ts @@ -83,19 +83,34 @@ export const CIMD_METADATA_URL_HTTPS_ERROR = * validation would not let anyone enter, and CIMD could not be driven against * any fixture in this repo (#2305). * - * ⚠️ This is the SDK's *three-literal* list, deliberately, and not the broader - * `isLoopbackHost` in `core/node/hostUrl.ts` — which imports `node:net` and so - * cannot be reached from this browser-safe module. Matching the SDK literal for - * literal also keeps the two allow-lists from disagreeing about the same URL. - * `::1` arrives from `URL.hostname` bracketed, which is the form stored here. + * ⚠️ This is the SDK's own list, matched member for member — not the broader + * `isLoopbackHost` in `core/node/hostUrl.ts`, which imports `node:net` and so + * cannot be reached from this browser-safe module. The SDK's + * `assertSecureTokenEndpoint` tests `url.hostname` against exactly + * `localhost` / `127.0.0.1` / `[::1]` (plus a `::1` arm `URL.hostname` never + * produces, since it brackets IPv6 literals). Keeping the two identical is what + * stops them disagreeing about one URL. + * + * ⚠️ **The comparison is against the WHATWG-canonicalized `URL.hostname`, so it + * covers alternate spellings of these three *addresses*, not three input + * strings.** `http://127.1/…`, `http://2130706433/…`, `http://0x7f.0.0.1/…` and + * `http://[0:0:0:0:0:0:0:1]/…` all canonicalize into the set and are accepted; + * so is a root-anchored `http://127.0.0.1./…`, which WHATWG strips the dot from + * for IP literals but *not* for `localhost.`. That is deliberate on both counts: + * the security argument is about the address the request actually reaches, and + * every one of these is the loopback interface. It is also exactly what the SDK + * does — it canonicalizes through `new URL` the same way — so an alias accepted + * here is an alias the SDK accepts too. */ const CIMD_HTTP_EXEMPT_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]); /** * True when a plain-`http:` CIMD metadata URL on this host is acceptable. Takes - * the bare host (`URL.hostname`), never a `host:port`. Not exported: the only - * caller is the validator below, and the exemption is exercised through it - * rather than through a second public surface that could drift from it. + * the already-canonicalized `URL.hostname`, never a raw authority or a + * `host:port` — passing an unparsed string would compare the wrong thing and + * silently reject every alias above. Not exported: the only caller is the + * validator below, and the exemption is exercised through it rather than + * through a second public surface that could drift from it. */ function isCimdHttpExemptHost(hostname: string): boolean { return CIMD_HTTP_EXEMPT_HOSTS.has(hostname); diff --git a/specification/v2_auth_smoke_testing.md b/specification/v2_auth_smoke_testing.md index c3168e16..340e1b84 100644 --- a/specification/v2_auth_smoke_testing.md +++ b/specification/v2_auth_smoke_testing.md @@ -513,10 +513,14 @@ Same MCP URL (`stytch-as-demo.val.run/mcp` or `mcp.stytch.dev/mcp`), leave CIMD For offline CIMD regression — or a **strict** fail-with-CIMD-off / succeed-with-CIMD-on pair without Stytch’s DCR fallback — use the shipped `oauth-cimd-http.json` fixture, which sets `supportCIMD: true` and **`supportDCR: false`** and hosts the client metadata document itself at `/client-metadata.json`. ```bash -cd clients/web && npm run test-servers:build +# The build script lives in clients/web; both paths below are repo-root-relative, +# so come back up before starting the server. +(cd clients/web && npm run test-servers:build) node test-servers/build/server-composable.js --config test-servers/configs/oauth-cimd-http.json ``` +The server announces its URL on **stderr**, and walks to the next free port on `EADDRINUSE` — read the announced URL rather than assuming 8092. + Since [#2305](https://github.com/modelcontextprotocol/inspector/issues/2305) a loopback `http://` metadata URL is a **valid persisted `client.json` setting and a valid entry in the settings form**, not a test-only affordance — `getCimdClientMetadataUrlError` exempts `localhost`, `127.0.0.1` and `[::1]`, the same three literals the SDK exempts for token endpoints. So point `clientMetadataUrl` straight at the URL the fixture announced (`http://127.0.0.1:8092/client-metadata.json` on its configured port) with no HTTPS listener. Before that change the exemption existed only in `ensureCimdClientRegistration`, for an already-stored `client_id`, and driving this by hand needed a self-signed HTTPS listener plus `NODE_TLS_REJECT_UNAUTHORIZED=0`. With CIMD off in Client Settings, connect + authenticate should **fail**; with CIMD on and that local metadata URL, it should **succeed**. `docs/test-servers.md` has the full walkthrough, including the `redirect_uris` port trap. From d7f90144c661d1260a642e9762d2e30af3ca1a90 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 11 Sep 2026 03:32:02 -0400 Subject: [PATCH 4/5] docs: make the local CIMD smoke deterministic, and fix two HTTPS-only claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 4, four findings, all correct. The CIMD-off / CIMD-on pair the smoke guide documents is not a control without clearing OAuth state first: tokens and the registered client persist in oauth.json independently of the install-wide CIMD toggle, and the Inspector reuses valid stored tokens before prompting — so a leftover grant from an earlier CIMD-on run makes the CIMD-off phase connect and the negative control silently proves nothing. Both the spec's pair and the walkthrough in docs/test-servers.md now say to clear state (or isolate MCP_STORAGE_DIR) first. The composable server announces its MCP endpoint, not the metadata document — `server-composable.ts` prints a URL ending in `/mcp`. The guide called the announced URL a metadata URL; it now says to take the origin from that line and append `/client-metadata.json`. Two general statements elsewhere in the same file still defined CIMD as always HTTPS (the glossary row, and the Stytch section's opening), so a reader following them would have rejected the loopback fixture the same file now documents. Both note the exemption; the Stytch examples stay HTTPS, which they are. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GfHQ8vMm5NBgzbUJ6UwxmB Signed-off-by: cliffhall --- docs/test-servers.md | 8 ++++++++ specification/v2_auth_smoke_testing.md | 12 ++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/test-servers.md b/docs/test-servers.md index 806cace2..50fa455d 100644 --- a/docs/test-servers.md +++ b/docs/test-servers.md @@ -578,6 +578,14 @@ listener to hold the document and setting `NODE_TLS_REJECT_UNAUTHORIZED=0` in th environment so its own fetch of that document would succeed. That workaround is no longer needed — if you find it in a script or an older note, delete it. +⚠️ **Clear OAuth state before the run if you have connected to this fixture before.** Tokens and the +registered client persist in `~/.mcp-inspector/storage/oauth.json` independently of the install-wide +CIMD toggle, and the Inspector reuses valid stored tokens before prompting — so a leftover grant can +carry a run that the registration path never actually completed, which is the same +"it connected, therefore CIMD worked" trap `supportDCR: false` exists to close. Use **Clear OAuth +state and disconnect** (Server Settings → Authorization), or point `MCP_STORAGE_DIR` at a throwaway +directory, which additionally survives a restarted fixture having forgotten a client it once issued. + With that in place: set the metadata URL in Client settings, connect, and open **Connection Info**. It should read `Client registration — Client ID Metadata (CIMD)` with the **client id equal to the metadata URL**, which is what CIMD means and what distinguishes it from a DCR-issued diff --git a/specification/v2_auth_smoke_testing.md b/specification/v2_auth_smoke_testing.md index 340e1b84..810eb538 100644 --- a/specification/v2_auth_smoke_testing.md +++ b/specification/v2_auth_smoke_testing.md @@ -40,7 +40,7 @@ Web uses `http://localhost:6274/oauth/callback` on the main app server — not t | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Static / preregistered client** | You supply `oauthClientId` and optionally `oauthClientSecret` in Server Settings. Inspector skips DCR and uses your credentials. | | **DCR** | Dynamic Client Registration (RFC 7591). Inspector registers at the AS `registration_endpoint` on first connect; no client id in config. | -| **CIMD** | Client ID Metadata Document (SEP-991). Inspector uses an HTTPS metadata URL as `client_id` when the AS advertises `client_id_metadata_document_supported`. | +| **CIMD** | Client ID Metadata Document (SEP-991). Inspector uses an HTTPS metadata URL as `client_id` when the AS advertises `client_id_metadata_document_supported` — or a plain `http://` one on `localhost` / `127.0.0.1` / `[::1]`, the loopback exemption added in [#2305](https://github.com/modelcontextprotocol/inspector/issues/2305) for local fixtures. | | **Client credentials grant** | OAuth 2.0 machine-to-machine `grant_type=client_credentials`. **Not** what we mean by “static client credentials” here. Inspector does not implement this grant yet ([#1225](https://github.com/modelcontextprotocol/inspector/issues/1225)). | ## Prerequisites @@ -395,7 +395,7 @@ The full `redirect_uris` list also includes other localhost ports and MCPJam app #### Why MCPJam’s document works with Inspector -CIMD treats the metadata **HTTPS URL** as the OAuth `client_id`. On authorize, Stytch (or any CIMD-capable AS) fetches that JSON and checks: +CIMD treats the metadata **HTTPS URL** as the OAuth `client_id` (loopback `http://` is the one exemption — see [§ local fallback](#local-fallback-composable-test-server); everything in this Stytch section is HTTPS). On authorize, Stytch (or any CIMD-capable AS) fetches that JSON and checks: 1. The document’s `client_id` field matches the URL. 2. The `redirect_uri` on the request appears **exactly** in `redirect_uris`. @@ -521,9 +521,13 @@ node test-servers/build/server-composable.js --config test-servers/configs/oauth The server announces its URL on **stderr**, and walks to the next free port on `EADDRINUSE` — read the announced URL rather than assuming 8092. -Since [#2305](https://github.com/modelcontextprotocol/inspector/issues/2305) a loopback `http://` metadata URL is a **valid persisted `client.json` setting and a valid entry in the settings form**, not a test-only affordance — `getCimdClientMetadataUrlError` exempts `localhost`, `127.0.0.1` and `[::1]`, the same three literals the SDK exempts for token endpoints. So point `clientMetadataUrl` straight at the URL the fixture announced (`http://127.0.0.1:8092/client-metadata.json` on its configured port) with no HTTPS listener. Before that change the exemption existed only in `ensureCimdClientRegistration`, for an already-stored `client_id`, and driving this by hand needed a self-signed HTTPS listener plus `NODE_TLS_REJECT_UNAUTHORIZED=0`. +Since [#2305](https://github.com/modelcontextprotocol/inspector/issues/2305) a loopback `http://` metadata URL is a **valid persisted `client.json` setting and a valid entry in the settings form**, not a test-only affordance — `getCimdClientMetadataUrlError` exempts `localhost`, `127.0.0.1` and `[::1]`, the same three literals the SDK exempts for token endpoints. So `clientMetadataUrl` can point straight at the document this fixture serves, with no HTTPS listener. Before that change the exemption existed only in `ensureCimdClientRegistration`, for an already-stored `client_id`, and driving this by hand needed a self-signed HTTPS listener plus `NODE_TLS_REJECT_UNAUTHORIZED=0`. -With CIMD off in Client Settings, connect + authenticate should **fail**; with CIMD on and that local metadata URL, it should **succeed**. `docs/test-servers.md` has the full walkthrough, including the `redirect_uris` port trap. +⚠️ **The server announces its MCP endpoint, not the metadata URL** — the line on stderr ends in `/mcp`. Take the **origin** (host and port) from it and append `/client-metadata.json`: an announced `http://127.0.0.1:8092/mcp` means `clientMetadataUrl` is `http://127.0.0.1:8092/client-metadata.json`. Don't assume 8092; the fixture walks past a taken port. + +⚠️ **Clear OAuth state before each phase, or neither outcome below is deterministic.** Tokens and the registered client persist in `~/.mcp-inspector/storage/oauth.json` independently of the install-wide CIMD toggle, and the Inspector reuses valid stored tokens before prompting — so a leftover grant from an earlier CIMD-on run makes the CIMD-off phase *connect*, and the negative control silently proves nothing. Use **Clear OAuth state and disconnect** (Server Settings → Authorization) before the off phase and again between the off and on phases. Pointing `MCP_STORAGE_DIR` at a throwaway directory for the whole run is the stronger version, and also survives a restarted fixture having forgotten a client it once issued. + +With that state cleared: with CIMD off in Client Settings, connect + authenticate should **fail**; with CIMD on and that local metadata URL, it should **succeed**. `docs/test-servers.md` has the full walkthrough, including the `redirect_uris` port trap. --- From 8baa1216fd387c99539674ef51a610e2e74abce9 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 11 Sep 2026 03:48:54 -0400 Subject: [PATCH 5/5] test: pin the CIMD form copy; narrow the "no exemption" claim in the docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 5. Two of three findings taken; the third rests on a false premise about the SDK and is declined with evidence in the PR thread. The hint and the input description are part of this fix's user-facing contract — an HTTPS-only instruction there puts the local-fixture workflow back out of reach even while the validator stays permissive — and nothing asserted them. Two cases now do, plus one asserting a loopback http URL raises no error in the form. Each mutation (restoring either piece of old copy) fails exactly one of them. The docs said there was "no loopback exemption at all" before #2305, which contradicts the sentence above it: the runtime already tolerated an `http://` already-stored `client_id`, and that asymmetry is what the issue is about. The gap was in the config/form validator, and the wording now says so. Also added: a note to use the canonical spelling of the host. `127.1` and `2130706433` are accepted here and by the SDK, since both canonicalize to `127.0.0.1` — but a CIMD `client_id` is string-compared by the authorization server against the URL it dereferenced, so an exotic spelling is a real footgun independent of what either validator allows. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GfHQ8vMm5NBgzbUJ6UwxmB Signed-off-by: cliffhall --- .../ClientSettingsForm.test.tsx | 54 +++++++++++++++++++ docs/test-servers.md | 19 +++++-- 2 files changed, 68 insertions(+), 5 deletions(-) diff --git a/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.test.tsx b/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.test.tsx index 9f5c92ce..0c472f8a 100644 --- a/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.test.tsx +++ b/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.test.tsx @@ -312,6 +312,60 @@ describe("ClientSettingsForm EMA IdP session", () => { expect(screen.getByText(CIMD_METADATA_URL_HTTPS_ERROR)).toBeInTheDocument(); }); + it("tells the reader the loopback exemption exists, in both pieces of CIMD copy", () => { + renderWithMantine( + , + ); + + // The copy is part of this feature's contract, not decoration: the whole + // point of #2305 is that a local fixture URL is enterable, and an + // HTTPS-only instruction here puts that back out of reach for a reader + // even while the validator stays permissive. Both places that state the + // requirement are asserted, since either one alone would still mislead. + const hint = screen.getByText(/must be served over HTTPS/); + expect(hint).toHaveTextContent("localhost, 127.0.0.1 or [::1]"); + + expect( + screen.getByText(/HTTPS URL of your OAuth client metadata JSON document/), + ).toHaveTextContent( + "http:// is accepted only on localhost, 127.0.0.1 or [::1]", + ); + }); + + it("shows no CIMD URL error for a loopback http URL", () => { + renderWithMantine( + , + ); + + expect( + screen.queryByText(CIMD_METADATA_URL_HTTPS_ERROR), + ).not.toBeInTheDocument(); + expect( + screen.queryByText(CIMD_METADATA_URL_INVALID_ERROR), + ).not.toBeInTheDocument(); + }); + it("shows no CIMD URL error for a valid URL", () => { renderWithMantine(