fix(auth): accept RFC 8414 metadata served at the OIDC well-known path

The SDK's `discoverAuthorizationServerMetadata` picks its validation schema
from the well-known filename that resolved rather than from the document that
came back, so anything found at `/.well-known/openid-configuration` is
validated as an OpenID provider document — requiring `jwks_uri`,
`subject_types_supported` and `id_token_signing_alg_values_supported`, three
fields RFC 8414 does not define. RFC 8414 §5 permits that filename for general
OAuth metadata, so a conforming plain OAuth 2.0 authorization server is
rejected; and because the parse throws instead of continuing the candidate
loop, discovery aborts and the connection fails outright.

Filed upstream as modelcontextprotocol/typescript-sdk#2733.

`core/auth/oidcDiscoveryCompat.ts` works around it without fabricating a
field: on a failed RFC 8414 candidate it probes the OIDC candidates the SDK
would try next and, when one returns a document that satisfies
`OAuthMetadataSchema` but fails the OIDC schema, serves that body as the RFC
8414 response so the SDK picks the schema that describes it. A genuine OpenID
provider document is left to the SDK's own OIDC leg. Issuer validation runs
unchanged, since the substituted document is the one the server published.

It wraps `effectiveAuthFetch` — above the fetch tracker, the opposite of
`withOAuthEndpointOverrides` — so the Network tab still records the real 404
and the real probe rather than the substitution.

The candidate derivation mirrors the SDK's `buildDiscoveryUrls`, which a fetch
wrapper cannot call, and a test pins it against that export so the two cannot
drift.

Adds `oauth.asMetadataPath` to the composable test server (mirroring
`oauth.resourceMetadataPath`) and the `oauth-rfc8414-at-oidc-path-http.json`
showcase config that reproduces the failure.

Closes #2172

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-28 00:40:11 -04:00
commit 7ca753bc4c
13 changed files with 844 additions and 36 deletions

View file

@ -120,6 +120,25 @@ v2/main/
│ │ # metadata document, the one seam SDK v2 routes
│ │ # BOTH endpoints through (neither reaches the
│ │ # OAuthClientProvider) — #1906;
│ │ # oidcDiscoveryCompat.ts workaround for
│ │ # typescript-sdk#2733: the SDK picks the
│ │ # metadata schema from the well-known
│ │ # FILENAME, so a plain OAuth 2.0 AS
│ │ # publishing RFC 8414 metadata at
│ │ # /.well-known/openid-configuration (which
│ │ # RFC 8414 §5 permits) is parsed as an
│ │ # OpenID provider document and THROWS,
│ │ # aborting discovery rather than trying the
│ │ # next candidate. The wrapper fabricates
│ │ # NOTHING — on a failed RFC 8414 candidate
│ │ # it probes the OIDC candidates and, when
│ │ # one is RFC 8414 metadata that is not a
│ │ # valid OIDC document, serves that body as
│ │ # the RFC 8414 response so the SDK picks the
│ │ # right schema. Sits ABOVE the fetch tracker
│ │ # (unlike endpointOverrides, which sits
│ │ # below) so the Network tab records the real
│ │ # 404 and the real probe — #2172;
│ │ # secret-storage-info.ts browser-safe
│ │ # descriptor of WHERE a typed secret lands
│ │ # — kind/plaintext/durable plus the label,

View file

@ -159,6 +159,7 @@ Each config below is a ready-made server for exercising one feature by hand. Loa
| `rfc6570-templates-http.json` | Resources tab: RFC 6570 resource-template expansion | [#1919](https://github.com/modelcontextprotocol/inspector/issues/1919) |
| `advertised-extensions-http.json` | Tool registration gated on advertised extensions | [#1739](https://github.com/modelcontextprotocol/inspector/issues/1739) |
| `oauth-custom-resource-metadata-http.json` **(legacy era)** | OAuth discovery driven by the challenge's `resource_metadata` | [#2071](https://github.com/modelcontextprotocol/inspector/issues/2071) |
| `oauth-rfc8414-at-oidc-path-http.json` **(legacy era)** | Plain OAuth 2.0 AS metadata served at the OIDC well-known path | [#2172](https://github.com/modelcontextprotocol/inspector/issues/2172) |
| `logging-{legacy,modern}-http.json` | Logging, both eras | [#1629](https://github.com/modelcontextprotocol/inspector/issues/1629) |
| `subscriptions-{legacy,modern}-http.json` | Resource subscriptions, both eras | [#1630](https://github.com/modelcontextprotocol/inspector/issues/1630) |
| `subscriptions-never-acknowledged-http.json` | A `subscriptions/listen` answered with a bare result | [#2097](https://github.com/modelcontextprotocol/inspector/issues/2097) |
@ -438,6 +439,22 @@ The same server is worth running against `--cli` / `--tui`, which reach it by a
The value now rides the normalized `AuthChallenge` as a string — it has to be serializable, because the web client's challenge crosses the remote-backend boundary as JSON — and is converted to a `URL` at the OAuth boundary, where it is handed to `auth()` as `resourceMetadataUrl` and to the CIMD pre-registration probe, which runs *before* `auth()` and would otherwise do its own default-location discovery. A malformed value is ignored rather than surfaced, matching the SDK's own `WWW-Authenticate` parser: discovery falls back to the default locations instead of failing the whole authorization on a bad header. The callback leg needs nothing extra — SDK `auth()` persists the URL in its discovery state, so it survives both the web full-page redirect and the CLI/TUI loopback callback.
#### Plain OAuth 2.0 metadata at the OIDC well-known path
`oauth-rfc8414-at-oidc-path-http.json` is an OAuth-protected server (combined AS + resource, DCR enabled) whose RFC 8414 authorization-server metadata is served **only** from `/.well-known/openid-configuration`. It is a plain OAuth 2.0 authorization server — no ID tokens, no `jwks_uri`, no `sub` claims — and RFC 8414 §5 explicitly permits that filename for general OAuth metadata. `/.well-known/oauth-authorization-server` is deliberately left unserved. Plain streamable-HTTP — connect with the **default (legacy)** protocol era.
Add the server and click **Connect**: authorization must proceed normally. On the broken build it failed before the browser ever opened, with a `ZodError` naming three fields the server had no reason to publish:
```
"path":["jwks_uri"] … "path":["subject_types_supported"] … "path":["id_token_signing_alg_values_supported"]
```
The cause is upstream. `discoverAuthorizationServerMetadata` in `@modelcontextprotocol/client@2.0.0` picks its validation schema from the well-known **filename that resolved**, not from the document that came back — anything found at `openid-configuration` is parsed as OpenID Connect Discovery 1.0 provider metadata, which requires those three fields. And because the parse *throws* rather than continuing the candidate loop, discovery aborts outright instead of falling through ([#2172](https://github.com/modelcontextprotocol/inspector/issues/2172), filed upstream as [typescript-sdk#2733](https://github.com/modelcontextprotocol/typescript-sdk/issues/2733)).
`core/auth/oidcDiscoveryCompat.ts` works around it without fabricating anything. When the RFC 8414 candidate comes back 4xx, it fetches the OIDC candidates the SDK would try next; if one returns a document that satisfies `OAuthMetadataSchema` but *fails* the OIDC schema, that document is returned as the response to the RFC 8414 request — so the SDK validates it under the schema that actually describes it. A genuine OpenID provider document is left alone and takes the SDK's normal OIDC leg. Issuer validation is untouched, since the substituted document is the one the server published, `issuer` included.
Watch the Network tab to confirm the Inspector is not hiding anything: the real 404 on `/.well-known/oauth-authorization-server` and the real request to `/.well-known/openid-configuration` are both recorded, and the metadata shown in the Auth tab is exactly what the server sent — no invented `jwks_uri`. The wrapper sits above the fetch tracker for that reason.
#### Logging, both eras
`logging-legacy-http.json` and `logging-modern-http.json` both serve `logging: true` plus a `send_notification` tool that emits a `notifications/message` at a chosen level. The legacy one is a plain streamable-HTTP server; the modern one sets `transport.modern: true`.

View file

@ -0,0 +1,330 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { buildDiscoveryUrls } from "@modelcontextprotocol/client";
import {
oidcDiscoveryCandidates,
isRfc8414OnlyMetadata,
withRfc8414OidcCompat,
} from "@inspector/core/auth/oidcDiscoveryCompat.js";
/** Minimal RFC 8414 authorization-server metadata — no OIDC-only fields. */
const RFC8414_DOC = {
issuer: "https://as.example.com/tenant",
authorization_endpoint: "https://as.example.com/tenant/authorize",
token_endpoint: "https://as.example.com/tenant/token",
response_types_supported: ["code"],
};
/** The same document plus the three fields OpenID Connect Discovery requires. */
const OIDC_DOC = {
...RFC8414_DOC,
jwks_uri: "https://as.example.com/tenant/jwks",
subject_types_supported: ["public"],
id_token_signing_alg_values_supported: ["RS256"],
};
const RFC8414_URL =
"https://as.example.com/.well-known/oauth-authorization-server/tenant";
const OIDC_SUFFIXED =
"https://as.example.com/.well-known/openid-configuration/tenant";
const OIDC_APPENDED =
"https://as.example.com/tenant/.well-known/openid-configuration";
function json(body: unknown, init?: ResponseInit): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
...init,
});
}
function notFound(): Response {
return new Response("nope", { status: 404 });
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("oidcDiscoveryCandidates", () => {
// The derivation is a hand-written mirror of the SDK's `buildDiscoveryUrls`,
// which a fetch wrapper cannot call (it sees a request URL, not the
// authorization-server URL). Pinning it against the SDK's own export is what
// keeps the two from drifting when the SDK changes its candidate list.
it.each([
"https://as.example.com",
"https://as.example.com/",
"https://as.example.com/tenant",
"https://as.example.com/tenant/",
"https://misc.poodll.com/mod/minilesson/mcp.php",
])("matches the SDK's OIDC candidates for %s", (authServerUrl) => {
const sdkUrls = buildDiscoveryUrls(authServerUrl);
const rfc8414 = sdkUrls.find((entry) => entry.type === "oauth");
expect(rfc8414).toBeDefined();
const expected = sdkUrls
.filter((entry) => entry.type === "oidc")
.map((entry) => entry.url.href);
expect(oidcDiscoveryCandidates(rfc8414!.url.href)).toEqual(expected);
});
it("returns nothing for a URL that is not an RFC 8414 candidate", () => {
expect(oidcDiscoveryCandidates("https://as.example.com/tokens")).toEqual(
[],
);
});
it("returns nothing for an unparseable URL", () => {
expect(oidcDiscoveryCandidates("not a url")).toEqual([]);
});
});
describe("isRfc8414OnlyMetadata", () => {
it("accepts plain RFC 8414 metadata", () => {
expect(isRfc8414OnlyMetadata(RFC8414_DOC)).toBe(true);
});
it("rejects a document that is a valid OpenID provider document", () => {
expect(isRfc8414OnlyMetadata(OIDC_DOC)).toBe(false);
});
it("rejects a body that is not authorization-server metadata at all", () => {
expect(isRfc8414OnlyMetadata({ hello: "world" })).toBe(false);
});
});
describe("withRfc8414OidcCompat", () => {
it("passes a successful response through untouched", async () => {
const original = json(RFC8414_DOC);
const base = vi.fn<typeof fetch>().mockResolvedValue(original);
const wrapped = withRfc8414OidcCompat(base);
await expect(wrapped(RFC8414_URL)).resolves.toBe(original);
expect(base).toHaveBeenCalledTimes(1);
});
it("passes a status the SDK will not walk past through untouched", async () => {
const original = new Response("boom", { status: 500 });
const base = vi.fn<typeof fetch>().mockResolvedValue(original);
const wrapped = withRfc8414OidcCompat(base);
await expect(wrapped(RFC8414_URL)).resolves.toBe(original);
expect(base).toHaveBeenCalledTimes(1);
});
it("does not probe when the failed request is not a discovery request", async () => {
const original = notFound();
const base = vi.fn<typeof fetch>().mockResolvedValue(original);
const wrapped = withRfc8414OidcCompat(base);
await expect(wrapped("https://as.example.com/tokens")).resolves.toBe(
original,
);
expect(base).toHaveBeenCalledTimes(1);
});
it("serves an RFC 8414 document found on the appended OIDC path", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const base = vi.fn<typeof fetch>(async (input) => {
const url = String(input);
if (url === OIDC_APPENDED) return json(RFC8414_DOC);
return notFound();
});
const wrapped = withRfc8414OidcCompat(base);
const response = await wrapped(RFC8414_URL);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("application/json");
await expect(response.json()).resolves.toEqual(RFC8414_DOC);
// The failed original, then both OIDC candidates.
expect(base).toHaveBeenCalledTimes(3);
expect(warn).toHaveBeenCalledWith(expect.stringContaining(OIDC_APPENDED));
});
it("serves an RFC 8414 document found on the path-suffixed OIDC path", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
const base = vi.fn<typeof fetch>(async (input) => {
const url = String(input);
if (url === OIDC_SUFFIXED) return json(RFC8414_DOC);
return notFound();
});
const wrapped = withRfc8414OidcCompat(base);
await expect((await wrapped(RFC8414_URL)).json()).resolves.toEqual(
RFC8414_DOC,
);
// The failed original, then the first OIDC candidate — no need for a second.
expect(base).toHaveBeenCalledTimes(2);
});
it("leaves a genuine OpenID provider document to the SDK's own OIDC leg", async () => {
const original = notFound();
const base = vi.fn<typeof fetch>(async (input) => {
if (String(input) === OIDC_SUFFIXED) return json(OIDC_DOC);
return original;
});
const wrapped = withRfc8414OidcCompat(base);
await expect(wrapped(RFC8414_URL)).resolves.toBe(original);
// Stops at the document discovery would have used; the appended candidate
// is never probed.
expect(base).toHaveBeenCalledTimes(2);
});
it("skips a candidate that is not JSON and keeps looking", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
const base = vi.fn<typeof fetch>(async (input) => {
const url = String(input);
if (url === OIDC_SUFFIXED) {
return new Response("<html>login</html>", {
status: 200,
headers: { "content-type": "text/html" },
});
}
if (url === OIDC_APPENDED) return json(RFC8414_DOC);
return notFound();
});
const wrapped = withRfc8414OidcCompat(base);
await expect((await wrapped(RFC8414_URL)).json()).resolves.toEqual(
RFC8414_DOC,
);
});
it("skips a candidate whose JSON body will not parse", async () => {
const original = notFound();
const base = vi.fn<typeof fetch>(async (input) => {
if (String(input).includes("openid-configuration")) {
return new Response("{ not json", {
status: 200,
headers: { "content-type": "application/json" },
});
}
return original;
});
const wrapped = withRfc8414OidcCompat(base);
await expect(wrapped(RFC8414_URL)).resolves.toBe(original);
expect(base).toHaveBeenCalledTimes(3);
});
it("skips a candidate that itself fails", async () => {
const original = notFound();
const base = vi.fn<typeof fetch>().mockResolvedValue(original);
const wrapped = withRfc8414OidcCompat(base);
await expect(wrapped(RFC8414_URL)).resolves.toBe(original);
expect(base).toHaveBeenCalledTimes(3);
});
it("treats a probe that throws as a candidate to skip", async () => {
const original = notFound();
const base = vi.fn<typeof fetch>(async (input) => {
if (String(input).includes("openid-configuration")) {
throw new TypeError("Failed to fetch");
}
return original;
});
const wrapped = withRfc8414OidcCompat(base);
await expect(wrapped(RFC8414_URL)).resolves.toBe(original);
expect(base).toHaveBeenCalledTimes(3);
});
it("probes on a 502, which the SDK also walks past", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
const base = vi.fn<typeof fetch>(async (input) => {
if (String(input) === OIDC_APPENDED) return json(RFC8414_DOC);
return new Response("bad gateway", { status: 502 });
});
const wrapped = withRfc8414OidcCompat(base);
await expect((await wrapped(RFC8414_URL)).json()).resolves.toEqual(
RFC8414_DOC,
);
});
it("carries the discovery headers from the failed request onto the probe", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
const seen: Headers[] = [];
const base = vi.fn<typeof fetch>(async (input, init) => {
const url = String(input);
if (url.includes("openid-configuration")) {
seen.push(new Headers(init?.headers));
return json(RFC8414_DOC);
}
return notFound();
});
const wrapped = withRfc8414OidcCompat(base);
await wrapped(RFC8414_URL, {
headers: {
"MCP-Protocol-Version": "2025-11-25",
Accept: "application/json",
},
});
expect(seen[0]?.get("mcp-protocol-version")).toBe("2025-11-25");
expect(seen[0]?.get("accept")).toBe("application/json");
});
it("reads the headers off a Request input when there is no init", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
const seen: Headers[] = [];
const base = vi.fn<typeof fetch>(async (input, init) => {
const url = String(input instanceof Request ? input.url : input);
if (url.includes("openid-configuration")) {
seen.push(new Headers(init?.headers));
return json(RFC8414_DOC);
}
return notFound();
});
const wrapped = withRfc8414OidcCompat(base);
await wrapped(
new Request(RFC8414_URL, {
headers: { "MCP-Protocol-Version": "2025-11-25" },
}),
);
expect(seen[0]?.get("mcp-protocol-version")).toBe("2025-11-25");
});
it("accepts a URL instance as the request input", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
const base = vi.fn<typeof fetch>(async (input) => {
if (String(input) === OIDC_APPENDED) return json(RFC8414_DOC);
return notFound();
});
const wrapped = withRfc8414OidcCompat(base);
await expect((await wrapped(new URL(RFC8414_URL))).json()).resolves.toEqual(
RFC8414_DOC,
);
});
it("handles a root authorization server, which has a single OIDC candidate", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
const rootDoc = {
issuer: "https://as.example.com",
authorization_endpoint: "https://as.example.com/authorize",
token_endpoint: "https://as.example.com/token",
response_types_supported: ["code"],
};
const base = vi.fn<typeof fetch>(async (input) => {
if (
String(input) ===
"https://as.example.com/.well-known/openid-configuration"
) {
return json(rootDoc);
}
return notFound();
});
const wrapped = withRfc8414OidcCompat(base);
await expect(
(
await wrapped(
"https://as.example.com/.well-known/oauth-authorization-server",
)
).json(),
).resolves.toEqual(rootDoc);
expect(base).toHaveBeenCalledTimes(2);
});
});

View file

@ -0,0 +1,100 @@
/**
* RFC 8414 authorization-server metadata served at the OpenID Connect
* well-known path, end to end against a real OAuth test server (#2172).
*
* The unit tests prove the wrapper substitutes the right document. This file
* proves the two halves only a real server can: that the SDK genuinely rejects
* a conforming plain-OAuth document found at `openid-configuration`, and that
* the wrapper recovers discovery against the same server without adding a
* field the server never published.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/client";
import {
TestServerHttp,
getDefaultServerConfig,
createOAuthTestServerConfig,
waitForOAuthWellKnown,
} from "@modelcontextprotocol/inspector-test-server";
import { withRfc8414OidcCompat } from "@inspector/core/auth/oidcDiscoveryCompat.js";
const OIDC_PATH = "/.well-known/openid-configuration";
/** The three fields OpenID Connect Discovery requires and RFC 8414 does not. */
const OIDC_ONLY_FIELDS = [
"jwks_uri",
"subject_types_supported",
"id_token_signing_alg_values_supported",
] as const;
describe("RFC 8414 metadata at the OIDC well-known path (#2172)", () => {
let mcpServer: TestServerHttp | null = null;
let serverUrl = "";
beforeAll(async () => {
mcpServer = new TestServerHttp({
...getDefaultServerConfig(),
serverType: "streamable-http" as const,
...createOAuthTestServerConfig({
requireAuth: true,
asMetadataPath: OIDC_PATH,
}),
});
const port = await mcpServer.start();
serverUrl = `http://localhost:${port}`;
await waitForOAuthWellKnown(serverUrl, { metadataPath: OIDC_PATH });
}, 30_000);
afterAll(async () => {
await mcpServer?.stop();
mcpServer = null;
}, 30_000);
it("serves plain RFC 8414 metadata only from the OIDC path", async () => {
const oidc = await fetch(`${serverUrl}${OIDC_PATH}`);
expect(oidc.status).toBe(200);
const metadata = (await oidc.json()) as Record<string, unknown>;
expect(metadata.issuer).toBe(serverUrl);
for (const field of OIDC_ONLY_FIELDS) {
expect(metadata).not.toHaveProperty(field);
}
const rfc8414 = await fetch(
`${serverUrl}/.well-known/oauth-authorization-server`,
);
expect(rfc8414.status).toBe(404);
});
it("is rejected by unwrapped SDK discovery", async () => {
// The upstream defect this module exists for
// (modelcontextprotocol/typescript-sdk#2733). If this ever starts
// resolving, the SDK has been fixed and `oidcDiscoveryCompat` can go.
await expect(
discoverAuthorizationServerMetadata(serverUrl),
).rejects.toThrow(/jwks_uri/);
});
it("resolves through the compat wrapper without fabricating a field", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const metadata = await discoverAuthorizationServerMetadata(serverUrl, {
fetchFn: withRfc8414OidcCompat(fetch),
});
expect(metadata?.issuer).toBe(serverUrl);
expect(metadata?.authorization_endpoint).toBe(
`${serverUrl}/oauth/authorize`,
);
expect(metadata?.token_endpoint).toBe(`${serverUrl}/oauth/token`);
for (const field of OIDC_ONLY_FIELDS) {
expect(metadata).not.toHaveProperty(field);
}
expect(warn).toHaveBeenCalledWith(
expect.stringContaining(`${serverUrl}${OIDC_PATH}`),
);
} finally {
warn.mockRestore();
}
});
});

View file

@ -45,6 +45,11 @@ export {
withOAuthEndpointOverrides,
} from "./endpointOverrides.js";
export type { OAuthEndpointOverrides } from "./endpointOverrides.js";
export {
oidcDiscoveryCandidates,
isRfc8414OnlyMetadata,
withRfc8414OidcCompat,
} from "./oidcDiscoveryCompat.js";
// Storage
export type {

View file

@ -0,0 +1,254 @@
/**
* Compatibility shim for a plain OAuth 2.0 authorization server that publishes
* its RFC 8414 metadata at the OpenID Connect well-known path (#2172).
*
* ## The upstream defect
*
* Filed as modelcontextprotocol/typescript-sdk#2733.
*
* `discoverAuthorizationServerMetadata` in `@modelcontextprotocol/client@2.0.0`
* tries a fixed list of well-known URLs (`buildDiscoveryUrls`) and picks the
* validation schema from the **filename that resolved**, not from the document
* that came back:
*
* ```js
* const parsed = type === "oauth"
* ? OAuthMetadataSchema.parse(body)
* : OpenIdProviderDiscoveryMetadataSchema.parse(body);
* ```
*
* `type` is `"oidc"` for every `openid-configuration` candidate, so a document
* served there is required to carry `jwks_uri`, `subject_types_supported` and
* `id_token_signing_alg_values_supported` — three fields OpenID Connect
* Discovery 1.0 requires and RFC 8414 does not. A plain OAuth 2.0 authorization
* server — no ID tokens, no JWKS, no `sub` — that publishes at that path is
* therefore rejected with a `ZodError`, and because the parse **throws** rather
* than continuing the loop, discovery aborts outright: the connection fails and
* no later candidate is tried.
*
* RFC 8414 §5 explicitly anticipates this deployment. `openid-configuration` is
* a permitted location for general OAuth authorization server metadata, and the
* OIDC-only fields are not part of the RFC 8414 document. So the server in
* #2172 is conforming and the client is wrong.
*
* ## What this wrapper does
*
* It never fabricates a field. It changes *which candidate URL* the document is
* handed back on, so the SDK validates it under the schema that actually
* describes it:
*
* 1. It watches only for the RFC 8414 candidate,
* `/.well-known/oauth-authorization-server[<path>]`, and only when that
* request came back with a status the SDK treats as "try the next
* candidate" (4xx, or 502).
* 2. It then fetches the OIDC candidates the SDK would try next — derived from
* the request URL by the same rule `buildDiscoveryUrls` uses, which
* `oidcDiscoveryCandidates` mirrors and a test pins against the SDK's own
* exported function so the two cannot drift.
* 3. If one of them returns a document that satisfies `OAuthMetadataSchema` but
* **fails** `OpenIdProviderDiscoveryMetadataSchema` — i.e. it is RFC 8414
* metadata and is not an OpenID provider document — that body is returned as
* the response to the RFC 8414 request. The SDK parses it with
* `OAuthMetadataSchema`, which is the correct schema for it, and discovery
* succeeds.
*
* A document that *does* satisfy the OIDC schema is left alone: the original
* response is returned unchanged and the SDK proceeds to its own OIDC candidate
* as usual. That costs one duplicate request on a genuine OIDC server, which is
* the price of not changing behavior for the case that already works.
*
* ## What it deliberately does not do
*
* - **It does not weaken issuer validation.** The substituted document is the
* one the server actually published, `issuer` included, so the SDK's RFC 8414
* §3.3 issuer-echo check runs on it exactly as before. A document whose
* `issuer` does not match is still rejected.
* - **It does not invent `jwks_uri` (or any other field).** Back-filling the
* three OIDC-required fields would also make the parse succeed, but the
* Inspector would then be displaying, in the Auth and Network tabs, a
* metadata document the server never published — which is the one thing a
* debugging tool must not do.
* - **It does not hide the real traffic.** It is installed *above* the fetch
* tracker (`InspectorClient.effectiveAuthFetch`), so the Network tab records
* the genuine 404 on the RFC 8414 path and the genuine request to the OIDC
* path. The substitution is visible only to the SDK's parse.
*
* ## Removing this
*
* This is a workaround for an upstream bug, not a feature. When
* modelcontextprotocol/typescript-sdk#2733 lands — the SDK selecting the schema
* from the document rather than from the filename, or treating a schema failure
* as a candidate to skip rather than as fatal — delete this module and its
* wiring in `core/mcp/inspectorClient.ts`.
*/
import {
OAuthMetadataSchema,
OpenIdProviderDiscoveryMetadataSchema,
} from "@modelcontextprotocol/core";
/** The RFC 8414 well-known path, which every OAuth-typed candidate starts with. */
const RFC8414_WELL_KNOWN = "/.well-known/oauth-authorization-server";
/** The OpenID Connect Discovery well-known path. */
const OIDC_WELL_KNOWN = "/.well-known/openid-configuration";
/**
* The OIDC discovery candidates the SDK would try after the RFC 8414 candidate
* `rfc8414Url` failed, in the SDK's own order.
*
* Derived from the RFC 8414 URL rather than from the authorization-server URL
* because that is all a `fetch` wrapper sees. The two are equivalent: the SDK
* builds the RFC 8414 candidate as
* `${origin}/.well-known/oauth-authorization-server${path}` with the
* authorization server's (trailing-slash-stripped) pathname as `path`, so
* recovering `path` recovers everything `buildDiscoveryUrls` keyed off.
*
* Returns an empty array when the URL is not an RFC 8414 candidate at all.
*/
export function oidcDiscoveryCandidates(rfc8414Url: string): string[] {
let url: URL;
try {
url = new URL(rfc8414Url);
} catch {
return [];
}
if (!url.pathname.startsWith(RFC8414_WELL_KNOWN)) return [];
const path = url.pathname.slice(RFC8414_WELL_KNOWN.length);
// The authorization server had no path, so the SDK emitted a single OIDC
// candidate at the origin.
if (path === "") return [new URL(OIDC_WELL_KNOWN, url.origin).href];
// A path-suffixed RFC 8414 candidate always has two OIDC siblings: the
// path-suffixed form and the path-prefixed ("appended") form.
return [
new URL(`${OIDC_WELL_KNOWN}${path}`, url.origin).href,
new URL(`${path}${OIDC_WELL_KNOWN}`, url.origin).href,
];
}
/**
* Whether a status makes the SDK move on to the next discovery candidate.
*
* Mirrors `discoverAuthorizationServerMetadata`: a 4xx or a 502 continues the
* loop, anything else throws. Probing on a status the SDK will not walk past
* would be wasted work — the flow is already over.
*/
function continuesDiscovery(status: number): boolean {
return (status >= 400 && status < 500) || status === 502;
}
/**
* Whether a `content-type` names a whole JSON document.
*
* An exact media-type test rather than a substring search for "json", for the
* same reason `endpointOverrides.ts` uses one: a streaming media type would be
* read to completion and never resolve.
*/
function isJsonDocumentResponse(contentType: string | null): boolean {
if (!contentType) return false;
const mediaType = contentType.split(";")[0].trim().toLowerCase();
return mediaType === "application/json" || mediaType.endsWith("+json");
}
/**
* Whether a parsed body is RFC 8414 authorization-server metadata that is *not*
* a valid OpenID provider document — the exact shape the upstream schema
* selection rejects.
*
* Both halves matter. Without the first, any JSON body would be substituted;
* without the second, a genuine OIDC document would be diverted onto the RFC
* 8414 path and silently change behavior for servers that work today.
*/
export function isRfc8414OnlyMetadata(value: unknown): boolean {
if (!OAuthMetadataSchema.safeParse(value).success) return false;
return !OpenIdProviderDiscoveryMetadataSchema.safeParse(value).success;
}
/** The request URL a `fetch` call was made with, in any of its three forms. */
function requestUrlOf(input: RequestInfo | URL): string {
if (typeof input === "string") return input;
if (input instanceof URL) return input.href;
return input.url;
}
/**
* The headers the failed discovery request carried, so the probe is answered
* the same way the SDK's own request would have been (it sends
* `MCP-Protocol-Version` and `Accept`). Copied from the request rather than
* reconstructed, so a header the SDK adds later is carried without this module
* needing to know about it.
*/
function discoveryHeaders(
input: RequestInfo | URL,
init: RequestInit | undefined,
): Headers {
if (init?.headers) return new Headers(init.headers);
if (typeof input !== "string" && !(input instanceof URL)) {
return new Headers(input.headers);
}
return new Headers();
}
/**
* Wrap a `fetch` so a plain OAuth 2.0 authorization server publishing RFC 8414
* metadata at `/.well-known/openid-configuration` is discoverable.
*
* Inert for every request that is not a *failed* RFC 8414 discovery request, so
* the overwhelmingly common case costs one status check and one string
* comparison.
*/
export function withRfc8414OidcCompat(fetchFn: typeof fetch): typeof fetch {
return async (input, init) => {
const response = await fetchFn(input, init);
if (response.ok) return response;
if (!continuesDiscovery(response.status)) return response;
const candidates = oidcDiscoveryCandidates(requestUrlOf(input));
if (candidates.length === 0) return response;
const headers = discoveryHeaders(input, init);
for (const candidate of candidates) {
let probe: Response;
try {
probe = await fetchFn(candidate, { headers });
} catch {
// A network or CORS failure on a URL the SDK had not asked for yet is
// not this wrapper's to report — the SDK will make the same request and
// handle it. Try the next candidate.
continue;
}
if (!probe.ok) continue;
if (!isJsonDocumentResponse(probe.headers.get("content-type"))) continue;
let parsed: unknown;
let body: string;
try {
body = await probe.text();
parsed = JSON.parse(body);
} catch {
continue;
}
if (!isRfc8414OnlyMetadata(parsed)) {
// Either not metadata at all, or a genuine OpenID provider document the
// SDK can already read. Stop looking: this is the document discovery
// would have used, and it needs no help.
return response;
}
console.warn(
`[oauth] ${candidate} returned RFC 8414 OAuth 2.0 authorization server ` +
`metadata, not an OpenID provider document. The MCP TypeScript SDK ` +
`validates that path as OpenID Connect Discovery and would reject it ` +
`(modelcontextprotocol/typescript-sdk#2733), so the Inspector is ` +
`handing it to discovery as the RFC 8414 document it is.`,
);
return new Response(body, {
status: 200,
statusText: "OK",
headers: { "content-type": "application/json" },
});
}
return response;
};
}

View file

@ -238,6 +238,7 @@ import {
type HandleAuthChallengeOptions,
} from "../auth/challenge.js";
import { withOAuthEndpointOverrides } from "../auth/endpointOverrides.js";
import { withRfc8414OidcCompat } from "../auth/oidcDiscoveryCompat.js";
import type { OAuthTokens } from "@modelcontextprotocol/client";
import { silentLogger, type InspectorLogger } from "../logging/logger.js";
import { createFetchTracker } from "./fetchTracking.js";
@ -791,7 +792,15 @@ export class InspectorClient extends InspectorClientEventTarget {
this.fetchFn = withOAuthEndpointOverrides(this.fetchFn ?? fetch, () =>
this.oauthManager?.getEndpointOverrides(),
);
this.effectiveAuthFetch = this.buildEffectiveAuthFetch();
// #2172: recover discovery when a plain OAuth 2.0 authorization server
// publishes RFC 8414 metadata at `/.well-known/openid-configuration`, which
// the SDK validates as an OpenID provider document and rejects. Wraps the
// tracked fetch rather than the base one — the opposite of the overrides
// above — so the Network tab records the real 404 and the real probe rather
// than the substitution the SDK's parse sees.
this.effectiveAuthFetch = withRfc8414OidcCompat(
this.buildEffectiveAuthFetch(),
);
this.sessionId = options.sessionId;

View file

@ -0,0 +1,19 @@
{
"serverInfo": {
"name": "oauth-rfc8414-at-oidc-path",
"version": "1.0.0"
},
"tools": [{ "preset": "echo" }],
"oauth": {
"enabled": true,
"mode": "combined",
"requireAuth": true,
"asMetadataPath": "/.well-known/openid-configuration",
"scopesSupported": ["mcp"],
"supportDCR": true
},
"transport": {
"type": "streamable-http",
"port": 8083
}
}

View file

@ -613,6 +613,21 @@ export interface ServerConfig {
*/
resourceMetadataPath?: string;
/**
* Serve the RFC 8414 authorization-server metadata document from this
* non-default path *instead of* `/.well-known/oauth-authorization-server`
* (combined mode only).
*
* Set it to `/.well-known/openid-configuration` to reproduce #2172: a
* plain OAuth 2.0 authorization server — no `jwks_uri`, no
* `subject_types_supported`, no `id_token_signing_alg_values_supported` —
* publishing RFC 8414 metadata at the OIDC well-known path, which RFC 8414
* §5 permits. As with `resourceMetadataPath`, the default route is left
* unserved, so a client that cannot read the document where it actually
* lives fails outright rather than quietly succeeding elsewhere.
*/
asMetadataPath?: string;
/**
* OAuth authorization server issuer URL (combined mode AS metadata).
* If not provided, defaults to the test server's base URL.

View file

@ -22,6 +22,8 @@ export interface ConfigFileOAuth {
resource?: string;
/** Serve RFC 9728 metadata from this path and advertise it on 401 (#2071). */
resourceMetadataPath?: string;
/** Serve RFC 8414 AS metadata from this path instead of the default (#2172). */
asMetadataPath?: string;
issuerUrl?: string;
accessTokenIssuers?: string[];
jwksUri?: string;
@ -222,6 +224,12 @@ function validateConfig(
`Invalid config in ${filePath}: oauth.resourceMetadataPath must be an origin-relative path (e.g. "/custom/protected-resource") — a value such as "//host/doc" would advertise a document the server does not serve`,
);
}
const asPath = oauth.asMetadataPath;
if (asPath !== undefined && !isOriginRelativePath(asPath)) {
throw new Error(
`Invalid config in ${filePath}: oauth.asMetadataPath must be an origin-relative path (e.g. "/.well-known/openid-configuration") — a value such as "//host/doc" would move the document off this server entirely`,
);
}
if (transportType === "stdio" && oauth.enabled === true) {
throw new Error(
`Invalid config in ${filePath}: oauth requires streamable-http or sse transport`,

View file

@ -79,6 +79,13 @@ export interface WaitForOAuthWellKnownOptions {
interval?: number;
/** Max time per fetch attempt (so one hung request doesn't burn the whole timeout). Default 1000. */
requestTimeout?: number;
/**
* Origin-relative path the AS metadata document is served from. Defaults to
* the RFC 8414 well-known path; pass the configured `oauth.asMetadataPath`
* when the fixture moved the document (#2172), since polling a route the
* server deliberately does not serve can only ever time out.
*/
metadataPath?: string;
}
/**
@ -97,8 +104,9 @@ export async function waitForOAuthWellKnown(
timeout = 5000,
interval = 50,
requestTimeout = 1000,
metadataPath = "/.well-known/oauth-authorization-server",
} = options ?? {};
const wellKnownUrl = `${serverBaseUrl.replace(/\/$/, "")}/.well-known/oauth-authorization-server`;
const wellKnownUrl = `${serverBaseUrl.replace(/\/$/, "")}${metadataPath}`;
const start = Date.now();
let lastStatus: number | undefined;
let lastError: unknown;

View file

@ -2915,6 +2915,13 @@ export function createOAuthTestServerConfig(options: {
* it via `WWW-Authenticate: Bearer resource_metadata="…"` (#2071).
*/
resourceMetadataPath?: string;
/**
* Move the RFC 8414 authorization-server metadata document off the
* well-known path — set it to `/.well-known/openid-configuration` to serve
* plain OAuth 2.0 metadata where the SDK expects an OpenID provider
* document (#2172).
*/
asMetadataPath?: string;
}): Partial<ServerConfig> {
return {
oauth: {
@ -2928,6 +2935,11 @@ export function createOAuthTestServerConfig(options: {
...(options.resourceMetadataPath !== undefined
? { resourceMetadataPath: options.resourceMetadataPath }
: {}),
// Same `!== undefined` reasoning as above: an explicit `""` is invalid
// and must reach the server so it reports the bad fixture.
...(options.asMetadataPath !== undefined
? { asMetadataPath: options.asMetadataPath }
: {}),
staticClients: options.staticClients,
supportDCR: options.supportDCR ?? false,
supportCIMD: options.supportCIMD ?? false,

View file

@ -83,6 +83,24 @@ function resourceMetadataPath(config: OAuthConfig): string | undefined {
return path;
}
/**
* The path the RFC 8414 authorization-server metadata document is served from,
* validated the same way `resourceMetadataPath` is. Defaults to the well-known
* location; `asMetadataPath` moves it (see the field's doc comment).
*/
function asMetadataPath(config: OAuthConfig): string {
const path = config.asMetadataPath;
if (path === undefined) {
return "/.well-known/oauth-authorization-server";
}
if (!isOriginRelativePath(path)) {
throw new Error(
`oauth.asMetadataPath must be an origin-relative path (got ${JSON.stringify(path)})`,
);
}
return path;
}
/**
* The `WWW-Authenticate` challenge sent with every 401.
*
@ -219,42 +237,36 @@ function setupMetadataEndpoints(
if (mode === "combined") {
// OAuth Authorization Server Metadata (local AS)
app.get(
"/.well-known/oauth-authorization-server",
(req: Request, res: Response) => {
const requestBaseUrl = `${req.protocol}://${req.get("host")}`;
const actualIssuerUrl = config.issuerUrl ?? new URL(requestBaseUrl);
const metadata = {
// RFC 8414 §3.3: the issuer MUST be identical to the base URL the
// well-known path was appended to — i.e. no trailing slash. SDK v2's
// client enforces this exactly (IssuerMismatchError otherwise).
issuer: actualIssuerUrl.href.replace(/\/$/, ""),
authorization_endpoint: new URL("/oauth/authorize", actualIssuerUrl)
app.get(asMetadataPath(config), (req: Request, res: Response) => {
const requestBaseUrl = `${req.protocol}://${req.get("host")}`;
const actualIssuerUrl = config.issuerUrl ?? new URL(requestBaseUrl);
const metadata = {
// RFC 8414 §3.3: the issuer MUST be identical to the base URL the
// well-known path was appended to — i.e. no trailing slash. SDK v2's
// client enforces this exactly (IssuerMismatchError otherwise).
issuer: actualIssuerUrl.href.replace(/\/$/, ""),
authorization_endpoint: new URL("/oauth/authorize", actualIssuerUrl)
.href,
token_endpoint: new URL("/oauth/token", actualIssuerUrl).href,
scopes_supported: scopes,
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["client_secret_basic", "none"],
// RFC 9207 / SEP-2468: advertise iss on authorization responses so
// clients must validate (and our e2e can exercise reject paths).
authorization_response_iss_parameter_supported: true,
...(config.supportDCR && {
registration_endpoint: new URL("/oauth/register", actualIssuerUrl)
.href,
token_endpoint: new URL("/oauth/token", actualIssuerUrl).href,
scopes_supported: scopes,
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: [
"client_secret_basic",
"none",
],
// RFC 9207 / SEP-2468: advertise iss on authorization responses so
// clients must validate (and our e2e can exercise reject paths).
authorization_response_iss_parameter_supported: true,
...(config.supportDCR && {
registration_endpoint: new URL("/oauth/register", actualIssuerUrl)
.href,
}),
...(config.supportCIMD && {
client_id_metadata_document_supported: true,
}),
};
}),
...(config.supportCIMD && {
client_id_metadata_document_supported: true,
}),
};
res.json(metadata);
},
);
res.json(metadata);
});
}
// OAuth Protected Resource Metadata. `resourceMetadataPath` moves the