Merge branch 'v2/main' into v2/fix/1919-rfc6570-uri-template-expansion

This commit is contained in:
Cliff Hall 2026-08-16 23:16:27 -04:00 • committed by GitHub
commit 63250a6c52
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 2391 additions and 126 deletions

View file

@ -117,12 +117,12 @@ describe("EmaTransportOAuthProvider", () => {
expect(await provider.clientInformation()).toEqual({ client_id: "abc" });
expect(await provider.codeVerifier()).toBe("verifier-xyz");
provider.saveClientInformation({ client_id: "new" } as never);
await provider.saveClientInformation({ client_id: "new" } as never);
expect(inner.saveClientInformation).toHaveBeenCalledWith({
client_id: "new",
});
provider.saveCodeVerifier("cv");
await provider.saveCodeVerifier("cv");
expect(inner.saveCodeVerifier).toHaveBeenCalledWith("cv");
});

View file

@ -0,0 +1,394 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import {
applyOAuthEndpointOverrides,
isAuthorizationServerMetadata,
normalizeOAuthEndpointOverrides,
oauthEndpointUrlError,
withOAuthEndpointOverrides,
} from "@inspector/core/auth/endpointOverrides.js";
const METADATA = {
issuer: "https://as.example.com",
authorization_endpoint: "https://as.example.com/authorize",
token_endpoint: "https://as.example.com/token",
response_types_supported: ["code"],
};
const AS_METADATA_URL =
"https://as.example.com/.well-known/oauth-authorization-server";
const STAGING_AUTHORIZE = "https://staging.example.com/authorize";
const STAGING_TOKEN = "https://staging.example.com/token";
function jsonResponse(body: unknown, init?: ResponseInit): Response {
return new Response(typeof body === "string" ? body : JSON.stringify(body), {
headers: { "content-type": "application/json" },
...init,
});
}
/** A `fetch` that always resolves to the same prepared response. */
function passThrough(response: Response): typeof fetch {
return async () => response;
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("oauthEndpointUrlError", () => {
it("accepts a blank value as 'no override'", () => {
expect(oauthEndpointUrlError("")).toBeUndefined();
expect(oauthEndpointUrlError(" ")).toBeUndefined();
});
it("accepts absolute http and https URLs", () => {
expect(oauthEndpointUrlError(STAGING_AUTHORIZE)).toBeUndefined();
expect(
oauthEndpointUrlError("http://localhost:9000/token"),
).toBeUndefined();
});
it("rejects a relative path", () => {
expect(oauthEndpointUrlError("/authorize")).toMatch(/not an absolute URL/);
});
it("rejects a non-http(s) scheme", () => {
expect(oauthEndpointUrlError("ftp://example.com/authorize")).toMatch(
/not an http\(s\) URL/,
);
});
// `new URL` accepts these, but Fetch rejects a request URL carrying them —
// so without this check the value passes validation and fails mid-flow.
it("rejects embedded credentials", () => {
expect(
oauthEndpointUrlError("https://user:pass@as.example.com/token"),
).toMatch(/username or password/);
expect(oauthEndpointUrlError("https://user@as.example.com/token")).toMatch(
/username or password/,
);
});
});
describe("normalizeOAuthEndpointOverrides", () => {
it("returns undefined when nothing is configured", () => {
expect(normalizeOAuthEndpointOverrides(undefined)).toBeUndefined();
expect(normalizeOAuthEndpointOverrides({})).toBeUndefined();
expect(
normalizeOAuthEndpointOverrides({ authorizationUrl: " " }),
).toBeUndefined();
});
it("trims and keeps each configured endpoint independently", () => {
expect(
normalizeOAuthEndpointOverrides({
authorizationUrl: ` ${STAGING_AUTHORIZE} `,
}),
).toEqual({ authorizationUrl: STAGING_AUTHORIZE });
expect(
normalizeOAuthEndpointOverrides({ tokenUrl: STAGING_TOKEN }),
).toEqual({ tokenUrl: STAGING_TOKEN });
});
it("drops a malformed value with a warning, keeping the valid sibling", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(
normalizeOAuthEndpointOverrides({
authorizationUrl: "not a url",
tokenUrl: STAGING_TOKEN,
}),
).toEqual({ tokenUrl: STAGING_TOKEN });
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("Ignoring `authorizationUrl`"),
);
});
it("drops a URL with embedded credentials", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(
normalizeOAuthEndpointOverrides({
tokenUrl: "https://user:pass@as.example.com/token",
}),
).toBeUndefined();
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("username or password"),
);
});
it("returns undefined when every configured value is malformed", () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
expect(
normalizeOAuthEndpointOverrides({
authorizationUrl: "ftp://example.com/a",
tokenUrl: "also not a url",
}),
).toBeUndefined();
});
});
describe("isAuthorizationServerMetadata", () => {
it("accepts a document with an issuer and at least one endpoint", () => {
expect(isAuthorizationServerMetadata(METADATA)).toBe(true);
expect(
isAuthorizationServerMetadata({
issuer: "https://as.example.com",
token_endpoint: "https://as.example.com/token",
}),
).toBe(true);
});
it("rejects a non-object body", () => {
expect(isAuthorizationServerMetadata(null)).toBe(false);
expect(isAuthorizationServerMetadata("nope")).toBe(false);
expect(isAuthorizationServerMetadata([METADATA])).toBe(false);
});
it("rejects protected-resource metadata, which has no issuer", () => {
expect(
isAuthorizationServerMetadata({
resource: "https://mcp.example.com",
authorization_servers: ["https://as.example.com"],
}),
).toBe(false);
});
it("rejects a document with an issuer but neither endpoint", () => {
expect(
isAuthorizationServerMetadata({ issuer: "https://as.example.com" }),
).toBe(false);
});
});
describe("applyOAuthEndpointOverrides", () => {
it("replaces only the configured endpoints and copies the rest", () => {
const patched = applyOAuthEndpointOverrides(METADATA, {
tokenUrl: STAGING_TOKEN,
});
expect(patched).toEqual({
...METADATA,
token_endpoint: STAGING_TOKEN,
});
expect(METADATA.token_endpoint).toBe("https://as.example.com/token");
});
it("supplies an endpoint the document never advertised", () => {
const patched = applyOAuthEndpointOverrides(
{ issuer: "https://as.example.com" },
{ authorizationUrl: STAGING_AUTHORIZE, tokenUrl: STAGING_TOKEN },
);
expect(patched).toEqual({
issuer: "https://as.example.com",
authorization_endpoint: STAGING_AUTHORIZE,
token_endpoint: STAGING_TOKEN,
});
});
});
describe("withOAuthEndpointOverrides", () => {
it("rewrites the endpoints of a metadata response", async () => {
const base: typeof fetch = async () => jsonResponse(METADATA);
const wrapped = withOAuthEndpointOverrides(base, () => ({
authorizationUrl: STAGING_AUTHORIZE,
tokenUrl: STAGING_TOKEN,
}));
const response = await wrapped(AS_METADATA_URL);
await expect(response.json()).resolves.toEqual({
...METADATA,
authorization_endpoint: STAGING_AUTHORIZE,
token_endpoint: STAGING_TOKEN,
});
expect(response.status).toBe(200);
});
it("re-reads the overrides on every call, so a settings edit takes effect", async () => {
const base: typeof fetch = async () => jsonResponse(METADATA);
// Held in a box rather than a `let`: the resolver closes over it before the
// first assignment, which is what the lazy read exists to support.
const config: { overrides?: { tokenUrl?: string } } = {};
const wrapped = withOAuthEndpointOverrides(base, () => config.overrides);
const before = await wrapped(AS_METADATA_URL);
await expect(before.json()).resolves.toMatchObject({
token_endpoint: METADATA.token_endpoint,
});
config.overrides = { tokenUrl: STAGING_TOKEN };
const after = await wrapped(AS_METADATA_URL);
await expect(after.json()).resolves.toMatchObject({
token_endpoint: STAGING_TOKEN,
});
});
// The gate that keeps this wrapper off the hot path: an ordinary JSON-RPC
// response is not even cloned, let alone parsed. Regression test for PR #2037.
it("ignores a response to a request that is not metadata discovery", async () => {
const original = jsonResponse(METADATA);
const clone = vi.spyOn(original, "clone");
const wrapped = withOAuthEndpointOverrides(passThrough(original), () => ({
tokenUrl: STAGING_TOKEN,
}));
await expect(wrapped("https://mcp.example.com/mcp")).resolves.toBe(
original,
);
expect(clone).not.toHaveBeenCalled();
});
it("recognizes discovery through every request-input form", async () => {
const wrapped = withOAuthEndpointOverrides(
// A fresh response per call: each patched one consumes its own clone.
async () => jsonResponse(METADATA),
() => ({ tokenUrl: STAGING_TOKEN }),
);
const oidcUrl = "https://as.example.com/.well-known/openid-configuration";
for (const input of [
AS_METADATA_URL,
new URL(oidcUrl),
new Request(`${AS_METADATA_URL}/tenant-a`),
]) {
const response = await wrapped(input);
await expect(response.json()).resolves.toMatchObject({
token_endpoint: STAGING_TOKEN,
});
}
});
it("warns once about a malformed override, not on every request", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const base: typeof fetch = async () => jsonResponse(METADATA);
const wrapped = withOAuthEndpointOverrides(base, () => ({
tokenUrl: "not a url",
}));
await wrapped(AS_METADATA_URL);
await wrapped(AS_METADATA_URL);
await wrapped(AS_METADATA_URL);
expect(warn).toHaveBeenCalledTimes(1);
});
it("passes the response through untouched when nothing is configured", async () => {
const original = jsonResponse(METADATA);
const wrapped = withOAuthEndpointOverrides(
passThrough(original),
() => undefined,
);
await expect(wrapped(AS_METADATA_URL)).resolves.toBe(original);
});
it("leaves an error response alone", async () => {
const original = jsonResponse(METADATA, { status: 404 });
const wrapped = withOAuthEndpointOverrides(passThrough(original), () => ({
tokenUrl: STAGING_TOKEN,
}));
await expect(wrapped(AS_METADATA_URL)).resolves.toBe(original);
});
it("leaves a non-JSON response alone", async () => {
const original = new Response("<html></html>", {
headers: { "content-type": "text/html" },
});
const wrapped = withOAuthEndpointOverrides(passThrough(original), () => ({
tokenUrl: STAGING_TOKEN,
}));
await expect(wrapped(AS_METADATA_URL)).resolves.toBe(original);
});
// Everything that is not a metadata document comes back as the caller's own
// Response — identity, not an equivalent copy — so native properties a
// synthesized Response cannot carry (`url`, `redirected`, `type`) survive.
it("returns the original response for a JSON body that is not metadata", async () => {
const original = jsonResponse({ access_token: "abc" });
const wrapped = withOAuthEndpointOverrides(passThrough(original), () => ({
tokenUrl: STAGING_TOKEN,
}));
const response = await wrapped(AS_METADATA_URL);
expect(response).toBe(original);
await expect(response.json()).resolves.toEqual({ access_token: "abc" });
});
it("returns the original response when the body is not valid JSON", async () => {
const original = jsonResponse("not json at all");
const wrapped = withOAuthEndpointOverrides(passThrough(original), () => ({
tokenUrl: STAGING_TOKEN,
}));
const response = await wrapped(AS_METADATA_URL);
expect(response).toBe(original);
await expect(response.text()).resolves.toBe("not json at all");
});
// `application/x-ndjson` satisfies a naive `includes("json")` test, and the
// streamable-HTTP transport serves its long-lived server-push channel that way
// (see `isLongLivedStreamResponse`). Awaiting `.json()` on a clone of an
// unbounded stream never resolves, so the MCP connection would hang for as
// long as an override was configured. Regression test for PR #2037 review.
it("does not read an unbounded NDJSON stream", async () => {
// A body that never closes: if the wrapper reads it, this test times out.
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode('{"jsonrpc":"2.0"}\n'));
// Deliberately never closed.
},
});
const original = new Response(stream, {
headers: { "content-type": "application/x-ndjson" },
});
const wrapped = withOAuthEndpointOverrides(passThrough(original), () => ({
tokenUrl: STAGING_TOKEN,
}));
await expect(wrapped("https://mcp.example.com/mcp")).resolves.toBe(
original,
);
await original.body?.cancel();
});
it("ignores content-type parameters when deciding a body is JSON", async () => {
const wrapped = withOAuthEndpointOverrides(
passThrough(
new Response(JSON.stringify(METADATA), {
headers: { "content-type": "application/json; charset=utf-8" },
}),
),
() => ({ tokenUrl: STAGING_TOKEN }),
);
const response = await wrapped(AS_METADATA_URL);
await expect(response.json()).resolves.toMatchObject({
token_endpoint: STAGING_TOKEN,
});
});
// Fetch forbids a body on 204/205, so rebuilding one would throw. A JSON
// content-type on an empty response is unusual but legal, and it must not
// take down an unrelated transport request.
it("passes a body-less 204 through untouched", async () => {
const original = new Response(null, {
status: 204,
headers: { "content-type": "application/json" },
});
const wrapped = withOAuthEndpointOverrides(passThrough(original), () => ({
tokenUrl: STAGING_TOKEN,
}));
await expect(wrapped(AS_METADATA_URL)).resolves.toBe(original);
});
it("drops the stale content-length and content-encoding of a rewritten body", async () => {
const body = JSON.stringify(METADATA);
const wrapped = withOAuthEndpointOverrides(
passThrough(
new Response(body, {
headers: {
"content-type": "application/json",
"content-length": String(body.length),
"content-encoding": "gzip",
},
}),
),
() => ({ tokenUrl: STAGING_TOKEN }),
);
const response = await wrapped(AS_METADATA_URL);
expect(response.headers.get("content-length")).toBeNull();
expect(response.headers.get("content-encoding")).toBeNull();
expect(response.headers.get("content-type")).toBe("application/json");
});
});

View file

@ -124,6 +124,46 @@ describe("runner client auth options", () => {
expect(buildRunnerClientAuthOptions({}, settings)).toEqual({});
});
// #1906 — the CLI/TUI leg reads the endpoint overrides from the same
// per-server settings the web leg does.
it("buildRunnerClientAuthOptions forwards the endpoint overrides", () => {
const settings: InspectorServerSettings = {
oauthAuthorizationUrl: "https://staging.example.com/authorize",
oauthTokenUrl: "https://staging.example.com/token",
requestTimeout: 0,
connectionTimeout: 0,
taskTtl: 60000,
maxFetchRequests: 10,
autoRefreshOnListChanged: false,
metadata: [],
headers: [],
env: [],
roots: [],
};
const opts = buildRunnerClientAuthOptions({}, settings);
expect(opts.oauth?.authorizationUrl).toBe(
"https://staging.example.com/authorize",
);
expect(opts.oauth?.tokenUrl).toBe("https://staging.example.com/token");
});
it("buildRunnerClientAuthOptions ignores blank endpoint overrides", () => {
const settings: InspectorServerSettings = {
oauthAuthorizationUrl: " ",
oauthTokenUrl: "",
requestTimeout: 0,
connectionTimeout: 0,
taskTtl: 60000,
maxFetchRequests: 10,
autoRefreshOnListChanged: false,
metadata: [],
headers: [],
env: [],
roots: [],
};
expect(buildRunnerClientAuthOptions({}, settings)).toEqual({});
});
it("buildRunnerClientAuthOptions returns no oauth when nothing supplies it", () => {
expect(buildRunnerClientAuthOptions({})).toEqual({});
});

View file

@ -0,0 +1,143 @@
import { describe, it, expect } from "vitest";
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
import type { CreateTransportOptions } from "@inspector/core/mcp/types.js";
import { BrowserOAuthStorage } from "@inspector/core/auth/browser/storage.js";
import type { JSONRPCMessage, Transport } from "@modelcontextprotocol/client";
/**
* #1906 — the per-server authorization/token endpoint overrides are applied by
* wrapping the client's base fetch, so the authorization-server metadata
* document carries the overridden endpoints wherever the SDK discovers it —
* including the discovery the transport runs on its own (the 401/refresh path),
* which is handed this same fetch.
*
* The assertion goes through the fetch the client actually hands the transport,
* rather than through a private field, so it fails if the wiring is removed.
*/
class InertTransport implements Transport {
onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage) => void;
async start(): Promise<void> {}
async send(): Promise<void> {
throw new Error("not connected");
}
async close(): Promise<void> {
this.onclose?.();
}
}
/**
* The real browser storage plus inert navigation/redirect stubs — enough for the
* OAuth manager to exist (which is what installs the override wrapper) without
* any stored tokens, so `connect()` still builds a transport with no auth
* provider attached.
*/
function oauthEnvironment() {
return {
storage: new BrowserOAuthStorage(),
navigation: { navigateToAuthorization: () => {} },
redirectUrlProvider: { getRedirectUrl: () => "http://localhost/callback" },
};
}
const METADATA = {
issuer: "https://as.example.com",
authorization_endpoint: "https://as.example.com/authorize",
token_endpoint: "https://as.example.com/token",
};
/**
* Build a client, drive `connect()` far enough to create the transport, and
* return the `fetchFn` the transport was given.
*/
async function transportFetchFor(
oauth: Record<string, string> | undefined,
): Promise<typeof fetch | undefined> {
let captured: CreateTransportOptions | undefined;
const baseFetch: typeof fetch = async () =>
new Response(JSON.stringify(METADATA), {
headers: { "content-type": "application/json" },
});
const client = new InspectorClient(
{ type: "streamable-http", url: "https://mcp.example/mcp" },
{
environment: {
fetch: baseFetch,
oauth: oauthEnvironment(),
transport: (_config, options) => {
captured = options;
return { transport: new InertTransport() };
},
},
...(oauth && { oauth }),
},
);
await client.connect().catch(() => undefined);
return captured?.fetchFn;
}
describe("InspectorClient OAuth endpoint overrides (#1906)", () => {
it("rewrites the discovered metadata endpoints for the transport's fetch", async () => {
const fetchFn = await transportFetchFor({
authorizationUrl: "https://staging.example.com/authorize",
tokenUrl: "https://staging.example.com/token",
});
expect(fetchFn).toBeDefined();
const response = await fetchFn!(
"https://as.example.com/.well-known/oauth-authorization-server",
);
await expect(response.json()).resolves.toEqual({
issuer: "https://as.example.com",
authorization_endpoint: "https://staging.example.com/authorize",
token_endpoint: "https://staging.example.com/token",
});
});
it("leaves the metadata untouched when no override is configured", async () => {
const fetchFn = await transportFetchFor(undefined);
expect(fetchFn).toBeDefined();
const response = await fetchFn!(
"https://as.example.com/.well-known/oauth-authorization-server",
);
await expect(response.json()).resolves.toEqual(METADATA);
});
it("picks up an override applied after construction via setOAuthConfig", async () => {
let captured: CreateTransportOptions | undefined;
const client = new InspectorClient(
{ type: "streamable-http", url: "https://mcp.example/mcp" },
{
environment: {
fetch: async () =>
new Response(JSON.stringify(METADATA), {
headers: { "content-type": "application/json" },
}),
oauth: oauthEnvironment(),
transport: (_config, options) => {
captured = options;
return { transport: new InertTransport() };
},
},
oauth: { clientId: "cid" },
},
);
await client.connect().catch(() => undefined);
client.setOAuthConfig({ tokenUrl: "https://staging.example.com/token" });
const response = await captured!.fetchFn!(
"https://as.example.com/.well-known/oauth-authorization-server",
);
await expect(response.json()).resolves.toMatchObject({
token_endpoint: "https://staging.example.com/token",
});
});
});

View file

@ -115,6 +115,53 @@ describe("OAuthManager", () => {
});
});
// #1906 — the endpoint overrides are read live from the config so a settings
// edit reaches the already-built fetch wrapper.
describe("getEndpointOverrides", () => {
it("returns undefined when neither endpoint is configured", () => {
const manager = new OAuthManager(createMockParams());
expect(manager.getEndpointOverrides()).toBeUndefined();
});
it("reflects each endpoint set through setOAuthConfig", () => {
const manager = new OAuthManager(createMockParams());
manager.setOAuthConfig({
authorizationUrl: "https://staging.test/authorize",
});
expect(manager.getEndpointOverrides()).toEqual({
authorizationUrl: "https://staging.test/authorize",
});
manager.setOAuthConfig({ tokenUrl: "https://staging.test/token" });
expect(manager.getEndpointOverrides()).toEqual({
authorizationUrl: "https://staging.test/authorize",
tokenUrl: "https://staging.test/token",
});
});
// The EMA leg authorizes against the enterprise IdP — a different
// authorization server, whose OIDC discovery runs through the same fetch the
// override wrapper is installed on. Applying a resource-AS override there
// would redirect the IdP login (or the IdP code exchange) to the resource
// AS. Regression test for the review finding on PR #2037.
it("suppresses the overrides under enterprise-managed authorization", () => {
const manager = new OAuthManager(createMockParams());
manager.setOAuthConfig({
authorizationUrl: "https://staging.test/authorize",
tokenUrl: "https://staging.test/token",
});
expect(manager.getEndpointOverrides()).toBeDefined();
manager.setOAuthConfig({ enterpriseManaged: true });
expect(manager.getEndpointOverrides()).toBeUndefined();
manager.setOAuthConfig({ enterpriseManaged: false });
expect(manager.getEndpointOverrides()).toEqual({
authorizationUrl: "https://staging.test/authorize",
tokenUrl: "https://staging.test/token",
});
});
});
describe("getServerUrl propagation", () => {
it("createOAuthProviderForTransport throws when getServerUrl throws", async () => {
const params = createMockParams({
@ -1588,7 +1635,7 @@ describe("OAuthManager", () => {
});
const provider = await manager.createOAuthProviderForTransport();
provider.redirectToAuthorization(
await provider.redirectToAuthorization(
new URL("https://as.example.com/authorize?client_id=abc&state=xyz"),
);
@ -1610,7 +1657,7 @@ describe("OAuthManager", () => {
});
const provider = await manager.createOAuthProviderForTransport();
provider.redirectToAuthorization(
await provider.redirectToAuthorization(
new URL("https://as.example.com/authorize?client_id=abc&state=xyz"),
);

View file

@ -12,6 +12,7 @@ import {
mergeSecretsIntoStored,
normalizeServerType,
oauthAuthorizationParamsFromSettings,
oauthEndpointOverridesFromSettings,
serverEntriesToMcpConfig,
serializeMcpConfig,
storedFieldsToInspectorSettings,
@ -1288,6 +1289,89 @@ describe("oauthOnInsufficientScope (SEP-2350)", () => {
).toBeUndefined();
expect(oauthAuthorizationParamsFromSettings({})).toBeUndefined();
});
// #1906 — authorization/token endpoint overrides.
it("lifts oauth endpoint overrides into settings fields", () => {
const settings = storedFieldsToInspectorSettings({
oauth: {
authorizationUrl: "https://staging.example.com/authorize",
tokenUrl: "https://staging.example.com/token",
},
});
expect(settings?.oauthAuthorizationUrl).toBe(
"https://staging.example.com/authorize",
);
expect(settings?.oauthTokenUrl).toBe("https://staging.example.com/token");
});
// Hand-edited `mcp.json` reaches the CLI/TUI unvalidated, and
// `oauthEndpointOverridesFromSettings` calls `.trim()` on these — so a
// non-string would crash the server load rather than being ignored.
it("drops a non-string endpoint override from disk", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const settings = storedFieldsToInspectorSettings({
oauth: {
clientId: "cid",
// Types the field as a string; disk does not honor that.
authorizationUrl: 42 as unknown as string,
tokenUrl: "https://staging.example.com/token",
},
});
expect(settings?.oauthAuthorizationUrl).toBeUndefined();
expect(settings?.oauthTokenUrl).toBe("https://staging.example.com/token");
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("oauth.authorizationUrl"),
);
// Restored here rather than left to a global teardown: a later describe
// spies on `console.warn` again and would inherit this spy's recorded call.
warn.mockRestore();
});
it("reads a blank endpoint override back as unset", () => {
const settings = storedFieldsToInspectorSettings({
oauth: { clientId: "cid", authorizationUrl: "", tokenUrl: "" },
});
expect(settings?.oauthAuthorizationUrl).toBeUndefined();
expect(settings?.oauthTokenUrl).toBeUndefined();
});
it("persists endpoint overrides under oauth, trimming whitespace", () => {
const stored = inspectorSettingsToStoredFields({
...baseSettings,
oauthAuthorizationUrl: " https://staging.example.com/authorize ",
oauthTokenUrl: "https://staging.example.com/token",
});
expect(stored.oauth).toEqual({
authorizationUrl: "https://staging.example.com/authorize",
tokenUrl: "https://staging.example.com/token",
});
});
it("omits the oauth block when the endpoint overrides are blank", () => {
const stored = inspectorSettingsToStoredFields({
...baseSettings,
oauthAuthorizationUrl: " ",
oauthTokenUrl: "",
});
expect(stored).not.toHaveProperty("oauth");
});
it("derives the endpoint-override client option, or undefined when blank", () => {
expect(
oauthEndpointOverridesFromSettings({
oauthAuthorizationUrl: " https://staging.example.com/authorize ",
}),
).toEqual({ authorizationUrl: "https://staging.example.com/authorize" });
expect(
oauthEndpointOverridesFromSettings({
oauthTokenUrl: "https://staging.example.com/token",
}),
).toEqual({ tokenUrl: "https://staging.example.com/token" });
expect(
oauthEndpointOverridesFromSettings({ oauthAuthorizationUrl: " " }),
).toBeUndefined();
expect(oauthEndpointOverridesFromSettings({})).toBeUndefined();
});
});
describe("envRecordToPairs / envPairsToRecord", () => {

View file

@ -969,6 +969,146 @@ describe("useServers", () => {
);
});
it("parses CRLF-delimited SSE frames as change notifications (#2006)", async () => {
// SSE permits CRLF line endings, and this hook is a hand-rolled parser
// (EventSource can't send `x-mcp-remote-auth`). Matching only `\n\n` left
// a CRLF frame buffered forever, so an external mcp.json edit never
// reached the list.
writeFileSync(
h.configPath,
JSON.stringify({
mcpServers: { seed: { type: "stdio", command: "s" } },
}),
);
// The frame is held back until the test has mutated the file, so the
// resulting list can only come from a refresh this frame triggered.
let releaseFrame: (() => void) | undefined;
const framed = new Promise<void>((r) => {
releaseFrame = r;
});
const encoder = new TextEncoder();
// Referentially stable: an inline arrow re-subscribes on every render, and
// the re-subscription's own mount refresh would pick the edit up on its
// own — passing whether or not the frame was ever parsed.
const fetchFn: typeof fetch = async (input, init) => {
const url = input instanceof Request ? input.url : String(input);
if (url.endsWith("/api/servers/events")) {
// A real ReadableStream in a real Response — `pull` is only invoked
// when the hook's reader asks for a chunk, so awaiting inside it
// gates delivery exactly the way a hand-rolled reader double would,
// while keeping the mock type-checked against the Response API.
const stream = new ReadableStream<Uint8Array>({
async pull(controller) {
await framed;
controller.enqueue(encoder.encode("event: change\r\n\r\n"));
controller.close();
},
});
return new Response(stream, { status: 200 });
}
return h.fetchFn(url, init);
};
const { result } = renderHook(() =>
useServers({ baseUrl: "http://test.local", fetchFn }),
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.servers.map((s) => s.id)).toEqual(["seed"]);
writeFileSync(
h.configPath,
JSON.stringify({
mcpServers: { aftercrlf: { type: "stdio", command: "x" } },
}),
);
releaseFrame?.();
await waitFor(
() => {
expect(result.current.servers.map((s) => s.id)).toEqual(["aftercrlf"]);
},
{ timeout: 3000 },
);
});
it("does not split a CRLF frame separator straddling two chunks (#2006)", async () => {
// The bare-CR terminator is deliberately not accepted: a chunk ending in
// `…\r\n\r` would otherwise read as a completed frame, and the `\n`
// arriving next would start a bogus one. Deliver exactly that split and
// assert the frame is recognized once — only after both halves land.
writeFileSync(
h.configPath,
JSON.stringify({
mcpServers: { seed: { type: "stdio", command: "s" } },
}),
);
let releaseFirstHalf: (() => void) | undefined;
const firstHalf = new Promise<void>((r) => {
releaseFirstHalf = r;
});
let releaseSecondHalf: (() => void) | undefined;
const secondHalf = new Promise<void>((r) => {
releaseSecondHalf = r;
});
let listGets = 0;
let chunks = 0;
const encoder = new TextEncoder();
const fetchFn: typeof fetch = async (input, init) => {
const url = input instanceof Request ? input.url : String(input);
if (url.endsWith("/api/servers/events")) {
// `pull` runs once per chunk the hook's reader consumes, so `chunks`
// reaching 2 proves the half-separator chunk was read and parsed.
const stream = new ReadableStream<Uint8Array>({
async pull(controller) {
chunks += 1;
if (chunks === 1) {
await firstHalf;
controller.enqueue(encoder.encode("event: change\r\n\r"));
return;
}
if (chunks === 2) {
await secondHalf;
controller.enqueue(encoder.encode("\n"));
return;
}
controller.close();
},
});
return new Response(stream, { status: 200 });
}
if (url.endsWith("/api/servers")) listGets += 1;
return h.fetchFn(url, init);
};
const { result } = renderHook(() =>
useServers({ baseUrl: "http://test.local", fetchFn }),
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(listGets).toBe(1);
// Half a separator is not a frame: no refresh yet.
releaseFirstHalf?.();
await waitFor(() => expect(chunks).toBeGreaterThanOrEqual(2));
await new Promise((r) => setTimeout(r, 100));
expect(listGets).toBe(1);
writeFileSync(
h.configPath,
JSON.stringify({
mcpServers: { aftersplit: { type: "stdio", command: "x" } },
}),
);
releaseSecondHalf?.();
await waitFor(
() => {
expect(result.current.servers.map((s) => s.id)).toEqual(["aftersplit"]);
},
{ timeout: 3000 },
);
expect(listGets).toBe(2);
});
it("ignores the backend's inert priming comment frame (#1858)", async () => {
// The backend opens every SSE stream with a `:` comment so a streaming
// fetch() resolves on Firefox at all. That frame carries no event/data