Merge pull request #2326 from modelcontextprotocol/v2/fix/2305-cimd-loopback-http
fix: exempt loopback hosts from the CIMD metadata URL HTTPS requirement
This commit is contained in:
commit
336e045210
7 changed files with 267 additions and 25 deletions
|
|
@ -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(
|
||||
<ClientSettingsForm
|
||||
settings={{
|
||||
...EMPTY_CLIENT_SETTINGS,
|
||||
cimdEnabled: true,
|
||||
clientMetadataUrl: "",
|
||||
}}
|
||||
expandedSections={["cimd"]}
|
||||
onExpandedSectionsChange={vi.fn()}
|
||||
onSettingsChange={vi.fn()}
|
||||
emaIdpLoginState="none"
|
||||
/>,
|
||||
);
|
||||
|
||||
// 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(
|
||||
<ClientSettingsForm
|
||||
settings={{
|
||||
...EMPTY_CLIENT_SETTINGS,
|
||||
cimdEnabled: true,
|
||||
clientMetadataUrl: "http://localhost:8092/client-metadata.json",
|
||||
}}
|
||||
expandedSections={["cimd"]}
|
||||
onExpandedSectionsChange={vi.fn()}
|
||||
onSettingsChange={vi.fn()}
|
||||
emaIdpLoginState="none"
|
||||
revealErrors
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ClientSettingsForm
|
||||
|
|
|
|||
|
|
@ -307,15 +307,16 @@ export function ClientSettingsForm({
|
|||
{settings.cimdEnabled && (
|
||||
<>
|
||||
<HintText>
|
||||
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"}
|
||||
</HintText>
|
||||
<ClearableTextInput
|
||||
label="Client ID metadata document URL"
|
||||
description="Public HTTPS URL of your OAuth client metadata JSON document."
|
||||
description="HTTPS URL of your OAuth client metadata JSON document (http:// is accepted only on localhost, 127.0.0.1 or [::1])."
|
||||
value={settings.clientMetadataUrl}
|
||||
onChange={(e) =>
|
||||
patch({ clientMetadataUrl: e.currentTarget.value })
|
||||
|
|
|
|||
|
|
@ -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,70 @@ 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("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.
|
||||
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", () => {
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
|
|
|||
|
|
@ -58,9 +58,63 @@ 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 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 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);
|
||||
}
|
||||
|
||||
/** Field-level error when a CIMD metadata URL has no path segment. */
|
||||
export const CIMD_METADATA_URL_PATH_ERROR =
|
||||
|
|
@ -81,7 +135,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 === "") {
|
||||
|
|
|
|||
|
|
@ -560,16 +560,40 @@ 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
|
||||
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) the config and
|
||||
form validator had no loopback exemption** — the *runtime* already tolerated an `http://` URL as an
|
||||
already-stored `client_id`, which is precisely the asymmetry that issue is about; there was simply no
|
||||
way to get such a value past validation and into `client.json`. 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
|
||||
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.
|
||||
would succeed. That workaround is no longer needed — if you find it in a script or an older note,
|
||||
delete it.
|
||||
|
||||
⚠️ **Use the canonical spelling of the host.** `http://127.1/…` and `http://2130706433/…` are accepted
|
||||
by the validator (and by the SDK) because both canonicalize to `127.0.0.1` — but a CIMD `client_id`
|
||||
is compared as a **string** by the authorization server against the URL it dereferenced, so an
|
||||
exotic spelling can fail that comparison on a server that normalizes differently than the one here.
|
||||
Paste the origin the fixture announced rather than an equivalent you typed yourself.
|
||||
|
||||
⚠️ **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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
@ -510,14 +510,24 @@ 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
|
||||
# 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
|
||||
```
|
||||
|
||||
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**.
|
||||
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 `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`.
|
||||
|
||||
⚠️ **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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue