feat(auth): per-server authorization and token URL overrides

Adds two optional per-server fields — `oauth.authorizationUrl` and
`oauth.tokenUrl` — that override the endpoints authorization-server
metadata discovery resolved, so a server can be pointed at a development
or staging authorization server without changing what it advertises.

SDK v2 routes both endpoints through the discovered metadata document and
neither through OAuthClientProvider, so the override is applied by
wrapping the client's base fetch and patching the metadata document in
flight (core/auth/endpointOverrides.ts). Wrapping the base fetch — rather
than only the auth fetch — also covers the discovery the SDK runs from
inside the transport on the 401/refresh path.

The fields round-trip through mcp.json, the /api/servers routes, the
runner (CLI/TUI), and the Server Settings → Authorization panel, where a
value that is not an absolute http(s) URL is flagged inline; at runtime
such a value is dropped with a single warning rather than failing the
connection.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-16 20:23:25 -04:00
commit 364d8c9dbc
5 changed files with 1212 additions and 6 deletions

View file

@ -0,0 +1,276 @@
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 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/,
);
});
});
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("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("https://as.example.com/.well-known/x");
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("https://as.example.com/.well-known/x");
await expect(before.json()).resolves.toMatchObject({
token_endpoint: METADATA.token_endpoint,
});
config.overrides = { tokenUrl: STAGING_TOKEN };
const after = await wrapped("https://as.example.com/.well-known/x");
await expect(after.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("https://as.example.com/x");
await wrapped("https://as.example.com/x");
await wrapped("https://as.example.com/x");
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("https://as.example.com/x")).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("https://as.example.com/x")).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("https://as.example.com/x")).resolves.toBe(original);
});
it("preserves a JSON body that is not metadata, and stays readable", async () => {
const wrapped = withOAuthEndpointOverrides(
passThrough(jsonResponse({ access_token: "abc" })),
() => ({ tokenUrl: STAGING_TOKEN }),
);
const response = await wrapped("https://as.example.com/token");
await expect(response.json()).resolves.toEqual({ access_token: "abc" });
});
it("hands back a readable response when the body is not valid JSON", async () => {
const wrapped = withOAuthEndpointOverrides(
passThrough(jsonResponse("not json at all")),
() => ({ tokenUrl: STAGING_TOKEN }),
);
const response = await wrapped("https://as.example.com/x");
await expect(response.text()).resolves.toBe("not json at all");
});
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("https://as.example.com/x");
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,30 @@ 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",
});
});
});
describe("getServerUrl propagation", () => {
it("createOAuthProviderForTransport throws when getServerUrl throws", async () => {
const params = createMockParams({

View file

@ -12,6 +12,7 @@ import {
mergeSecretsIntoStored,
normalizeServerType,
oauthAuthorizationParamsFromSettings,
oauthEndpointOverridesFromSettings,
serverEntriesToMcpConfig,
serializeMcpConfig,
storedFieldsToInspectorSettings,
@ -1288,6 +1289,66 @@ 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");
});
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", () => {