Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,30 @@ 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. GET-only, and the well-known
│ │ # match requires a `/` boundary, so a token
│ │ # endpoint living under that prefix keeps its
│ │ # own error. Sits on the BASE fetch like
│ │ # endpointOverrides — the one seam that also
│ │ # covers the discovery the SDK runs inside the
│ │ # transport — so the substituted response
│ │ # carries COMPAT_SOURCE_HEADER naming the URL
│ │ # its body came from — #2172;
│ │ # secret-storage-info.ts browser-safe
│ │ # descriptor of WHERE a typed secret lands
│ │ # — kind/plaintext/durable plus the label,
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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.

The metadata shown in the Auth tab is exactly what the server sent — no invented `jwks_uri`. In the Network tab the substituted response is captured against the RFC 8414 URL (the wrapper sits on the base fetch, below the tracker, because that is the only seam that also covers the discovery the SDK runs from inside the transport), so it carries an `x-inspector-oauth-metadata-source` response header naming the URL its body was actually fetched from. The same URL is printed as a console warning.

#### 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`.
Expand Down
83 changes: 83 additions & 0 deletions clients/cli/__tests__/stored-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -873,3 +873,86 @@ describe("--wait-for-auth", () => {
expect(result.stderr).toContain("positive number of seconds");
});
});

/**
* The stored-token refresh path calls SDK discovery directly rather than
* through `InspectorClient.effectiveAuthFetch`, so it carries its own copy of
* the #2172 compatibility wrapper. This test injects no `discover`, because
* the wrapper lives in the *default* — injecting one would bypass exactly what
* is under test.
*/
describe("refreshStoredAuthToken discovery compatibility (#2172)", () => {
const REFRESHED = {
access_token: "refreshed-access-token",
token_type: "Bearer",
refresh_token: "rotated-refresh-token",
expires_in: 3600,
};

it("refreshes against an AS publishing RFC 8414 metadata at the OIDC path", async () => {
let base = "";
let rfc8414Probes = 0;
const server: Server = createServer((req, res) => {
const path = req.url ?? "";
if (path.startsWith("/.well-known/oauth-authorization-server")) {
rfc8414Probes += 1;
res.writeHead(404).end();
return;
}
// Plain OAuth 2.0: no jwks_uri, no subject_types_supported, no
// id_token_signing_alg_values_supported — the shape the SDK rejects when
// it finds it under this filename.
if (path === "/mcp/.well-known/openid-configuration") {
res.writeHead(200, { "content-type": "application/json" });
res.end(
JSON.stringify({
issuer: `${base}/mcp`,
authorization_endpoint: `${base}/oauth/authorize`,
token_endpoint: `${base}/oauth/token`,
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
token_endpoint_auth_methods_supported: ["client_secret_post"],
}),
);
return;
}
if (path === "/oauth/token") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(REFRESHED));
return;
}
res.writeHead(404).end();
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", resolve);
});
const addr = server.address();
base =
typeof addr === "object" && addr ? `http://127.0.0.1:${addr.port}` : "";
const serverUrl = `${base}/mcp`;
const fixture = writeOAuthFixture({
[serverUrl]: {
tokens: { refresh_token: "old-refresh", token_type: "Bearer" },
clientInformation: { client_id: "cid", client_secret: "sec" },
},
});
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
await expect(refreshStoredAuthToken(serverUrl, fixture)).resolves.toBe(
"refreshed-access-token",
);
// The RFC 8414 location really was tried and really did 404, so the
// document could only have come from the OIDC path.
expect(rfc8414Probes).toBeGreaterThan(0);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("/mcp/.well-known/openid-configuration"),
);
} finally {
warn.mockRestore();
rmSync(dirname(fixture), { recursive: true, force: true });
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
});
});
24 changes: 23 additions & 1 deletion clients/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
getAuthorizationServerUrl,
getAuthorizationServerUrlCandidates,
} from "@inspector/core/auth/discovery.js";
import { withRfc8414OidcCompat } from "@inspector/core/auth/oidcDiscoveryCompat.js";
import { writeStoreFile } from "@inspector/core/storage/store-io.js";
import {
refreshAuthorization,
Expand Down Expand Up @@ -329,7 +330,27 @@ export async function refreshStoredAuthToken(
deps: RefreshStoredAuthDeps = {},
): Promise<string> {
const refresh = deps.refresh ?? refreshAuthorization;
const discover = deps.discover ?? discoverAuthorizationServerMetadata;
// #2172: this path calls SDK discovery directly rather than through
// `InspectorClient.effectiveAuthFetch`, so it needs the same compatibility
// wrapper — otherwise a stored refresh token with no persisted
// `serverMetadata` still cannot refresh against an authorization server that
// publishes RFC 8414 metadata at the OIDC well-known path (Copilot).
//
// Built over `createProxyFetch()` for the same reason `environment.fetch` is
// (#2067): this whole function runs outside `InspectorClient`, so nothing
// else puts a proxy under it, and a server reachable only through
// `HTTPS_PROXY` would otherwise be probed directly. The same fetch is handed
// to the token request below, so neither leg bypasses the proxy (Copilot).
const storedAuthFetch = withRfc8414OidcCompat(createProxyFetch() ?? fetch);
const discover: typeof discoverAuthorizationServerMetadata =
deps.discover ??
((authorizationServerUrl, options) =>
discoverAuthorizationServerMetadata(authorizationServerUrl, {
...options,
// A caller-supplied fetch is left alone — it is theirs to compose. The
// walker below passes no options, so in practice this is ours.
fetchFn: options?.fetchFn ?? storedAuthFetch,
}));

const snapshot = await readOAuthSnapshot(statePath);
const servers = snapshot.servers as StoredServers;
Expand Down Expand Up @@ -380,6 +401,7 @@ export async function refreshStoredAuthToken(
clientInformation,
refreshToken,
resource: new URL(serverUrl),
fetchFn: storedAuthFetch,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
Expand Down
Loading