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
22 changed files with 1212 additions and 6 deletions

View file

@ -45,7 +45,13 @@ v2/main/
│ │ # providers.redirectToAuthorization, the only
│ │ # seam that sees the SDK-built authorize URL;
│ │ # authorization request only, never the token
│ │ # request — #2018)
│ │ # request — #2018;
│ │ # endpointOverrides.ts per-server
│ │ # authorization/token URL overrides — a fetch
│ │ # wrapper that rewrites the discovered AS
│ │ # metadata document, the one seam SDK v2 routes
│ │ # BOTH endpoints through (neither reaches the
│ │ # OAuthClientProvider) — #1906)
│ │ ├── browser/ # Browser-side OAuth (sessionStorage, BrowserNavigation)
│ │ ├── node/ # Node-side OAuth (NodeOAuthStorage, OAuthCallbackServer,
│ │ │ # runner-interactive-oauth loopback callback flow)

View file

@ -30,7 +30,7 @@ inspector/
│ ├── tui/ # TUI client (Ink + React, tsup bundle)
│ └── launcher/ # Shared launcher — provides the `mcp-inspector` bin, dispatches to web/cli/tui
├── core/ # Shared code consumed via the `@inspector/core` alias (no package.json)
│ ├── auth/ # OAuth: providers, discovery, storage, mid-session recovery (browser/node/remote backends)
│ ├── auth/ # OAuth: providers, discovery, storage, endpoint overrides, mid-session recovery (browser/node/remote backends)
│ ├── client/ # Install-level client config (`client.json`): browser-safe parse/validate + Node load/save, remote backend, secrets
│ ├── json/ # JSON + parameter/argument conversion utilities, and the nullable-union
│ │ # schema collapse shared by the web and TUI form builders

View file

@ -64,6 +64,7 @@ import { ResourceSubscriptionsState } from "@inspector/core/mcp/state/resourceSu
import {
cleanRoots,
oauthAuthorizationParamsFromSettings,
oauthEndpointOverridesFromSettings,
serializeMcpConfig,
} from "@inspector/core/mcp/serverList.js";
import type { ClientConfig } from "@inspector/core/client/types.js";
@ -2315,12 +2316,16 @@ function App() {
const serverAuthorizationParams = savedSettings
? oauthAuthorizationParamsFromSettings(savedSettings)
: undefined;
const serverEndpointOverrides = savedSettings
? oauthEndpointOverridesFromSettings(savedSettings)
: undefined;
const oauthFromServer =
savedSettings &&
(savedSettings.oauthClientId ||
savedSettings.oauthClientSecret ||
savedSettings.oauthScopes ||
serverAuthorizationParams ||
serverEndpointOverrides ||
savedSettings.enterpriseManaged)
? {
...(savedSettings.oauthClientId && {
@ -2335,6 +2340,7 @@ function App() {
...(serverAuthorizationParams && {
authorizationParams: serverAuthorizationParams,
}),
...serverEndpointOverrides,
...(savedSettings.enterpriseManaged && {
enterpriseManaged: true,
}),

View file

@ -701,6 +701,8 @@ describe("ServerSettingsForm", () => {
clientSecret: "",
scopes: "",
authorizationParams: [],
authorizationUrl: "",
tokenUrl: "",
enterpriseManaged: false,
});
});
@ -966,6 +968,76 @@ describe("ServerSettingsForm", () => {
expect(screen.getByText(/state, scope are set/)).toBeInTheDocument();
});
// #1906 — the endpoint overrides ride the same `onOAuthChange` callback as
// the rest of the Authorization section.
it("invokes onOAuthChange when an endpoint override is typed", async () => {
const user = userEvent.setup();
const onOAuthChange = vi.fn();
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
onOAuthChange={onOAuthChange}
settings={emptySettings}
expandedSections={["oauth"]}
/>,
);
await user.type(
screen.getByRole("textbox", { name: /Authorization URL override/i }),
"h",
);
expect(onOAuthChange).toHaveBeenCalledWith(
expect.objectContaining({ authorizationUrl: "h" }),
);
onOAuthChange.mockClear();
await user.type(
screen.getByRole("textbox", { name: /Token URL override/i }),
"h",
);
expect(onOAuthChange).toHaveBeenCalledWith(
expect.objectContaining({ tokenUrl: "h" }),
);
});
it("flags an endpoint override that is not an absolute http(s) URL", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
settings={{
...emptySettings,
oauthAuthorizationUrl: "/authorize",
oauthTokenUrl: "https://staging.test/token",
}}
expandedSections={["oauth"]}
/>,
);
expect(
screen.getByText('"/authorize" is not an absolute URL.'),
).toBeInTheDocument();
expect(screen.queryByText(/is not an http\(s\) URL/)).toBeNull();
});
it("clears an endpoint override through its clear button", async () => {
const user = userEvent.setup();
const onOAuthChange = vi.fn();
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
onOAuthChange={onOAuthChange}
settings={{
...emptySettings,
oauthTokenUrl: "https://staging.test/token",
}}
expandedSections={["oauth"]}
/>,
);
const clearButtons = screen.getAllByRole("button", { name: /clear/i });
await user.click(clearButtons[clearButtons.length - 1]);
expect(onOAuthChange).toHaveBeenCalledWith(
expect.objectContaining({ tokenUrl: "" }),
);
});
it("invokes onOAuthChange with the chosen insufficient-scope policy (SEP-2350)", async () => {
const user = userEvent.setup();
const onOAuthChange = vi.fn();
@ -1007,6 +1079,8 @@ describe("ServerSettingsForm", () => {
clientSecret: "",
scopes: "",
authorizationParams: [],
authorizationUrl: "",
tokenUrl: "",
enterpriseManaged: true,
});
});
@ -1112,6 +1186,8 @@ describe("ServerSettingsForm", () => {
clientSecret: "z",
scopes: "",
authorizationParams: [],
authorizationUrl: "",
tokenUrl: "",
enterpriseManaged: false,
});
});

View file

@ -32,6 +32,7 @@ import {
authorizationParamKeyError,
isReservedAuthorizationParam,
} from "@inspector/core/auth/authorizationParams.js";
import { oauthEndpointUrlError } from "@inspector/core/auth/endpointOverrides.js";
import { ADVERTISABLE_EXTENSIONS } from "@inspector/core/mcp/extensions.js";
import type { Root } from "@modelcontextprotocol/client";
@ -478,6 +479,8 @@ export function ServerSettingsForm({
clientSecret: settings.oauthClientSecret ?? "",
scopes: settings.oauthScopes ?? "",
authorizationParams,
authorizationUrl: settings.oauthAuthorizationUrl ?? "",
tokenUrl: settings.oauthTokenUrl ?? "",
enterpriseManaged: settings.enterpriseManaged ?? false,
onInsufficientScope: settings.oauthOnInsufficientScope,
};
@ -863,6 +866,55 @@ export function ServerSettingsForm({
+ Add Parameter
</AddButton>
</Stack>
<ClearableTextInput
label="Authorization URL override"
description="Leave blank to use the authorization_endpoint the authorization server's metadata advertises. Set it to point this server at a development or staging authorization server instead."
placeholder="https://staging.auth.example.com/authorize"
value={settings.oauthAuthorizationUrl ?? ""}
error={oauthEndpointUrlError(
settings.oauthAuthorizationUrl ?? "",
)}
onChange={(e) =>
onOAuthChange({
...currentOAuth(),
authorizationUrl: e.currentTarget.value,
})
}
rightSection={
settings.oauthAuthorizationUrl ? (
<ClearButton
onClick={() =>
onOAuthChange({
...currentOAuth(),
authorizationUrl: "",
})
}
/>
) : null
}
/>
<ClearableTextInput
label="Token URL override"
description="Leave blank to use the token_endpoint the authorization server's metadata advertises. Independent of the authorization URL — either can be overridden alone."
placeholder="https://staging.auth.example.com/token"
value={settings.oauthTokenUrl ?? ""}
error={oauthEndpointUrlError(settings.oauthTokenUrl ?? "")}
onChange={(e) =>
onOAuthChange({
...currentOAuth(),
tokenUrl: e.currentTarget.value,
})
}
rightSection={
settings.oauthTokenUrl ? (
<ClearButton
onClick={() =>
onOAuthChange({ ...currentOAuth(), tokenUrl: "" })
}
/>
) : null
}
/>
<Select
label="Insufficient-scope response"
description="On a 403 insufficient_scope challenge (SEP-2350): re-authorize with the accumulated scope union, or surface the error to you."

View file

@ -445,6 +445,52 @@ describe("ServerSettingsModal", () => {
});
});
// #1906 — a typed endpoint override lands on the settings; clearing it
// persists `undefined` rather than an empty string, so "cleared" and "never
// set" are the same state on disk.
it("persists a typed endpoint override, and clears it back to undefined", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
const { rerender } = renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "OAuth Settings" }));
await user.type(
screen.getByRole("textbox", { name: /Token URL override/i }),
"h",
);
expect(onSettingsChange).toHaveBeenCalledWith(
expect.objectContaining({ oauthTokenUrl: "h" }),
);
onSettingsChange.mockClear();
rerender(
<ServerSettingsModal
opened
settings={{
...emptySettings,
oauthTokenUrl: "https://staging.test/token",
}}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
const clearButtons = screen.getAllByRole("button", { name: /clear/i });
await user.click(clearButtons[clearButtons.length - 1]);
expect(onSettingsChange).toHaveBeenCalledWith(
expect.objectContaining({ oauthTokenUrl: undefined }),
);
});
// #2018 — the authorization-parameter rows ride the same onOAuthChange
// callback, so the modal folds them onto the settings object.
it("persists an added authorization-parameter row onto the settings", async () => {

View file

@ -183,6 +183,11 @@ export function ServerSettingsModal({
// survives a re-render; the drop-blank/omit-empty filtering happens on the
// way to disk. An emptied list persists as `[]`, which writes nothing.
oauthAuthorizationParams: oauth.authorizationParams,
// #1906: an emptied field persists as undefined rather than `""` — the
// read side treats a blank override as "not configured", and keeping the
// two in step means clearing the input really clears the setting.
oauthAuthorizationUrl: oauth.authorizationUrl || undefined,
oauthTokenUrl: oauth.tokenUrl || undefined,
enterpriseManaged: oauth.enterpriseManaged ? true : undefined,
// SEP-2350: persist only the non-default ('throw') so unset servers keep
// the SDK's `reauthorize` behavior without writing a spurious field.

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", () => {

View file

@ -496,6 +496,75 @@ describe("/api/servers routes", () => {
expect(res.status).toBe(400);
});
// #1906 — the endpoint overrides travel as plain strings and land under
// `oauth` on disk beside the credentials.
it("persists the oauth endpoint overrides on POST", async () => {
const res = await fetch(`${h.baseUrl}/api/servers`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: "endpointoverrides",
config: { type: "streamable-http", url: "https://x.test/mcp" },
settings: {
headers: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
oauthAuthorizationUrl: "https://staging.test/authorize",
oauthTokenUrl: "https://staging.test/token",
},
}),
});
expect(res.status).toBe(200);
const stored = readConfig(h.configPath).mcpServers.endpointoverrides;
expect(stored?.oauth).toEqual({
authorizationUrl: "https://staging.test/authorize",
tokenUrl: "https://staging.test/token",
});
});
it("drops an empty oauth endpoint override rather than writing it", async () => {
const res = await fetch(`${h.baseUrl}/api/servers`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: "blankoverrides",
config: { type: "streamable-http", url: "https://x.test/mcp" },
settings: {
headers: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
oauthAuthorizationUrl: "",
oauthTokenUrl: "",
},
}),
});
expect(res.status).toBe(200);
expect(
readConfig(h.configPath).mcpServers.blankoverrides,
).not.toHaveProperty("oauth");
});
it("rejects a non-string oauthTokenUrl payload", async () => {
const res = await fetch(`${h.baseUrl}/api/servers`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: "badtokenurl",
config: { type: "streamable-http", url: "https://x.test/mcp" },
settings: {
headers: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
oauthTokenUrl: 42,
},
}),
});
expect(res.status).toBe(400);
});
it("updates Inspector-extension fields at the top level on PUT", async () => {
writeFileSync(
h.configPath,

View file

@ -0,0 +1,246 @@
/**
* Per-server authorization/token endpoint overrides (#1906).
*
* The Inspector deliberately has no fields for the authorization and token URLs
* — they are resolved by authorization-server metadata discovery (RFC 8414 /
* OpenID Connect Discovery), exactly as a real MCP host resolves them. But a
* server under development often advertises its *production* authorization
* server while the person debugging it wants the staging one, and discovery
* gives them no way to say so.
*
* These overrides are that affordance: when set, they replace whatever the
* authorization server's metadata document returned.
*
* ## Why this is a fetch wrapper and not a provider hook
*
* Both endpoints reach the flow through exactly one path in SDK v2: `auth()`
* discovers the metadata document and then reads `metadata.authorization_endpoint`
* (in `startAuthorization`) and `metadata.token_endpoint` (in `fetchToken`).
* Neither is routed through `OAuthClientProvider`, and `AuthOptions` has no
* `metadata` field — so the provider seam that carries the custom authorization
* parameters (#2018) cannot reach the token endpoint at all. The one seam that
* sees both is the `fetchFn` the SDK uses for discovery, so the override is
* applied to the metadata document in flight.
*
* Two consequences worth knowing:
*
* - **A metadata document is required.** When discovery returns nothing, the SDK
* falls back to `/authorize` and `/token` on the authorization server's origin
* and there is no document to patch. That matches the feature as asked for —
* overriding "whatever urls the authorization server returns" — and a server
* publishing no metadata is already on a path where the endpoints are not
* being advertised in the first place.
* - **The Network tab shows the patched document.** The wrapper is applied to the
* base fetch, inside the request tracker, so what the tab renders is the
* metadata as the flow consumed it. That is the useful reading: it explains why
* the subsequent authorize/token requests went where they did.
*/
/**
* Per-server overrides for the two endpoints an authorization server publishes.
* Both are optional and independent — overriding only the token URL is a valid
* configuration.
*/
export interface OAuthEndpointOverrides {
/** Replaces `authorization_endpoint` in the discovered metadata. */
authorizationUrl?: string;
/** Replaces `token_endpoint` in the discovered metadata. */
tokenUrl?: string;
}
/** Field name on the metadata document each override replaces. */
const OVERRIDE_FIELDS = {
authorizationUrl: "authorization_endpoint",
tokenUrl: "token_endpoint",
} as const;
/**
* Validation message for one configured endpoint URL, or `undefined` when the
* value is acceptable. A blank value is not an error — it means "no override".
*
* Only absolute `http:`/`https:` URLs are accepted: the value is written
* straight into the metadata document, where the SDK passes it to `new URL(...)`
* with no base — so a relative path would throw deep inside the flow, far from
* the setting that caused it. `http:` is allowed because the whole point is
* reaching a local or staging authorization server.
*
* The settings form renders this message against the field, so the form and the
* runtime cannot disagree about which values are usable.
*/
export function oauthEndpointUrlError(value: string): string | undefined {
const trimmed = value.trim();
if (trimmed === "") return undefined;
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
return `"${trimmed}" is not an absolute URL.`;
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return `"${trimmed}" is not an http(s) URL.`;
}
return undefined;
}
/**
* The trimmed value, or `undefined` when it is blank or unusable. A rejected
* value warns and is dropped rather than throwing, so one bad field cannot make
* an otherwise-working server unconnectable.
*/
function normalizeEndpointUrl(
value: string | undefined,
field: keyof OAuthEndpointOverrides,
): string | undefined {
if (value === undefined) return undefined;
const trimmed = value.trim();
if (trimmed === "") return undefined;
const error = oauthEndpointUrlError(trimmed);
if (error) {
console.warn(`[oauth] Ignoring \`${field}\`: ${error}`);
return undefined;
}
return trimmed;
}
/**
* Normalize the configured overrides, dropping blank and malformed values.
* Returns `undefined` when nothing usable remains, which is the signal callers
* use to skip wrapping their fetch at all.
*/
export function normalizeOAuthEndpointOverrides(
overrides: OAuthEndpointOverrides | undefined,
): OAuthEndpointOverrides | undefined {
if (!overrides) return undefined;
const authorizationUrl = normalizeEndpointUrl(
overrides.authorizationUrl,
"authorizationUrl",
);
const tokenUrl = normalizeEndpointUrl(overrides.tokenUrl, "tokenUrl");
if (!authorizationUrl && !tokenUrl) return undefined;
return {
...(authorizationUrl && { authorizationUrl }),
...(tokenUrl && { tokenUrl }),
};
}
/**
* Whether a parsed JSON body is an authorization-server metadata document.
*
* The wrapper sees every JSON response on the connection, so this has to be
* narrow enough not to rewrite an unrelated body that happens to carry a
* similarly-named field. RFC 8414 §2 makes `issuer` REQUIRED and it appears in
* no other document the flow fetches (protected-resource metadata has
* `authorization_servers`, not `issuer`), so requiring `issuer` alongside at
* least one of the two endpoints is a precise test.
*/
export function isAuthorizationServerMetadata(
value: unknown,
): value is Record<string, unknown> {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const doc = value as Record<string, unknown>;
if (typeof doc.issuer !== "string") return false;
return (
typeof doc.authorization_endpoint === "string" ||
typeof doc.token_endpoint === "string"
);
}
/**
* Return a copy of an authorization-server metadata document with the
* configured endpoints replaced.
*
* An override is written even when the document does not advertise that
* endpoint: a metadata document missing `authorization_endpoint` already fails
* the flow (`new URL(undefined)` throws), so supplying the missing value is
* strictly better than leaving the hole.
*/
export function applyOAuthEndpointOverrides(
metadata: Record<string, unknown>,
overrides: OAuthEndpointOverrides,
): Record<string, unknown> {
const patched = { ...metadata };
for (const [key, field] of Object.entries(OVERRIDE_FIELDS) as [
keyof OAuthEndpointOverrides,
(typeof OVERRIDE_FIELDS)[keyof OAuthEndpointOverrides],
][]) {
const override = overrides[key];
if (override) patched[field] = override;
}
return patched;
}
/** Rebuild a response around a replacement body, preserving status/headers. */
function responseWithBody(response: Response, body: string): Response {
const headers = new Headers(response.headers);
// The body length changes when an override is applied, and a stale
// `content-length` on a synthesized `Response` is worse than none — consumers
// read the body we hand them, not the header.
headers.delete("content-length");
// `fetch` already decoded the body, so the replacement is plain text and an
// inherited `content-encoding` would describe an encoding it no longer has.
headers.delete("content-encoding");
return new Response(body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
/**
* Wrap a `fetch` so authorization-server metadata responses carry the
* configured endpoint overrides.
*
* `getOverrides` is called per request rather than captured once: the OAuth
* config is mutable (a settings edit can change it without rebuilding the
* client), and reading it lazily means a wrapped fetch never serves a stale
* override. When it returns nothing the response is passed through untouched,
* so the wrapper is inert for the overwhelmingly common unconfigured case.
*/
export function withOAuthEndpointOverrides(
fetchFn: typeof fetch,
getOverrides: () => OAuthEndpointOverrides | undefined,
): typeof fetch {
// Normalization is memoized on the raw pair rather than run per request: it
// warns about a malformed value, and every request on the connection passes
// through here — so without this a single typo would log on every call.
let lastKey: string | undefined;
let lastNormalized: OAuthEndpointOverrides | undefined;
const resolveOverrides = (): OAuthEndpointOverrides | undefined => {
const raw = getOverrides();
const key = JSON.stringify([raw?.authorizationUrl, raw?.tokenUrl]);
if (key !== lastKey) {
lastKey = key;
lastNormalized = normalizeOAuthEndpointOverrides(raw);
}
return lastNormalized;
};
return async (input, init) => {
const response = await fetchFn(input, init);
const overrides = resolveOverrides();
if (!overrides) return response;
if (!response.ok) return response;
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.toLowerCase().includes("json")) return response;
const body = await response.text();
let parsed: unknown;
try {
parsed = JSON.parse(body);
} catch {
// Not JSON despite the header. The body is already consumed, so hand back
// an equivalent response rather than the drained original.
return responseWithBody(response, body);
}
if (!isAuthorizationServerMetadata(parsed)) {
return responseWithBody(response, body);
}
return responseWithBody(
response,
JSON.stringify(applyOAuthEndpointOverrides(parsed, overrides)),
);
};
}

View file

@ -36,6 +36,16 @@ export {
} from "./authorizationParams.js";
export type { ReservedAuthorizationParam } from "./authorizationParams.js";
// Authorization/token endpoint overrides (#1906)
export {
oauthEndpointUrlError,
normalizeOAuthEndpointOverrides,
isAuthorizationServerMetadata,
applyOAuthEndpointOverrides,
withOAuthEndpointOverrides,
} from "./endpointOverrides.js";
export type { OAuthEndpointOverrides } from "./endpointOverrides.js";
// Storage
export type {
OAuthStorage,

View file

@ -11,7 +11,10 @@ import type {
InspectorClientOptions,
InspectorServerSettings,
} from "../mcp/types.js";
import { oauthAuthorizationParamsFromSettings } from "../mcp/serverList.js";
import {
oauthAuthorizationParamsFromSettings,
oauthEndpointOverridesFromSettings,
} from "../mcp/serverList.js";
import { loadClientConfig } from "./config.js";
import type { ClientConfig } from "./types.js";
import {
@ -74,6 +77,9 @@ export function buildRunnerClientAuthOptions(
const serverAuthorizationParams = savedSettings
? oauthAuthorizationParamsFromSettings(savedSettings)
: undefined;
const serverEndpointOverrides = savedSettings
? oauthEndpointOverridesFromSettings(savedSettings)
: undefined;
const oauthFromServer =
savedSettings &&
@ -81,6 +87,7 @@ export function buildRunnerClientAuthOptions(
savedSettings.oauthClientSecret ||
savedSettings.oauthScopes ||
serverAuthorizationParams ||
serverEndpointOverrides ||
savedSettings.enterpriseManaged)
? {
...(savedSettings.oauthClientId && {
@ -95,6 +102,7 @@ export function buildRunnerClientAuthOptions(
...(serverAuthorizationParams && {
authorizationParams: serverAuthorizationParams,
}),
...serverEndpointOverrides,
...(savedSettings.enterpriseManaged && {
enterpriseManaged: true,
}),

View file

@ -218,6 +218,7 @@ import {
type AuthChallengeOutcome,
type HandleAuthChallengeOptions,
} from "../auth/challenge.js";
import { withOAuthEndpointOverrides } from "../auth/endpointOverrides.js";
import type { OAuthTokens } from "@modelcontextprotocol/client";
import { silentLogger, type InspectorLogger } from "../logging/logger.js";
import { createFetchTracker } from "./fetchTracking.js";
@ -650,6 +651,16 @@ export class InspectorClient extends InspectorClientEventTarget {
};
// Effective auth fetch: base fetch + tracking with category 'auth'
// #1906: apply the per-server authorization/token endpoint overrides to the
// authorization-server metadata document in flight. This wraps the *base*
// fetch rather than `effectiveAuthFetch` so it also covers the discovery the
// SDK runs from inside the transport (the 401/refresh path), which is handed
// `this.fetchFn` directly. The overrides are read lazily — `oauthManager` is
// created a few lines below, and `setOAuthConfig` can change them later — so
// the wrapper is inert until a server actually configures one.
this.fetchFn = withOAuthEndpointOverrides(this.fetchFn ?? fetch, () =>
this.oauthManager?.getEndpointOverrides(),
);
this.effectiveAuthFetch = this.buildEffectiveAuthFetch();
this.sessionId = options.sessionId;
@ -5988,6 +5999,9 @@ export class InspectorClient extends InspectorClientEventTarget {
scope?: string;
/** Custom authorization-request parameters (#2018). Authorize URL only. */
authorizationParams?: Record<string, string>;
/** Endpoint overrides applied to the discovered AS metadata (#1906). */
authorizationUrl?: string;
tokenUrl?: string;
}): void {
if (!this.oauthManager) {
throw new Error(

View file

@ -5,6 +5,7 @@
*/
import { BaseOAuthClientProvider } from "../auth/providers.js";
import type { OAuthEndpointOverrides } from "../auth/endpointOverrides.js";
import type { OAuthFlowState, OAuthStep } from "../auth/types.js";
import { EMPTY_OAUTH_FLOW_STATE } from "../auth/types.js";
import type { OAuthTokens } from "@modelcontextprotocol/client";
@ -95,6 +96,8 @@ export class OAuthManager {
scope?: string;
enterpriseManaged?: boolean;
authorizationParams?: Record<string, string>;
authorizationUrl?: string;
tokenUrl?: string;
}): void {
this.oauthConfig = {
...this.oauthConfig,
@ -106,6 +109,21 @@ export class OAuthManager {
return this.params.getServerUrl();
}
/**
* The per-server authorization/token endpoint overrides, read live so a
* `setOAuthConfig` between requests takes effect without rebuilding the
* client's fetch. `InspectorClient` reads this from inside its fetch wrapper.
* (#1906)
*/
getEndpointOverrides(): OAuthEndpointOverrides | undefined {
const { authorizationUrl, tokenUrl } = this.oauthConfig;
if (!authorizationUrl && !tokenUrl) return undefined;
return {
...(authorizationUrl && { authorizationUrl }),
...(tokenUrl && { tokenUrl }),
};
}
/**
* Return the configured OAuth storage, throwing a clear error if it is
* absent. Used on the EMA re-mint/persist paths, which are only reached for

View file

@ -1131,11 +1131,22 @@ export function createRemoteApp(
clientSecret?: string;
scopes?: string;
authorizationParams?: Record<string, string>;
authorizationUrl?: string;
tokenUrl?: string;
enterpriseManaged?: boolean;
} => {
if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
const o = v as Record<string, unknown>;
for (const k of ["clientId", "clientSecret", "scopes"] as const) {
for (const k of [
"clientId",
"clientSecret",
"scopes",
// #1906 — shape only; the values are validated as absolute http(s) URLs
// where they're applied (core/auth/endpointOverrides.ts), so a typo drops
// one field with a warning instead of dropping the whole `oauth` block.
"authorizationUrl",
"tokenUrl",
] as const) {
if (o[k] !== undefined && typeof o[k] !== "string") return false;
}
if (
@ -1255,7 +1266,7 @@ export function createRemoteApp(
if ("oauth" in valObj && !isOauthObject(valObj.oauth)) {
logWarn(
{ route: "/api/servers", id, droppedKey: "oauth" },
"Dropping malformed `oauth` field — expected `{ clientId?, clientSecret?, scopes?, authorizationParams?, enterpriseManaged?, onInsufficientScope? }`.",
"Dropping malformed `oauth` field — expected `{ clientId?, clientSecret?, scopes?, authorizationParams?, authorizationUrl?, tokenUrl?, enterpriseManaged?, onInsufficientScope? }`.",
);
delete valObj.oauth;
}
@ -1522,6 +1533,10 @@ export function createRemoteApp(
"oauthClientId",
"oauthClientSecret",
"oauthScopes",
// #1906 — string-shaped on the wire; URL validity is checked where the
// override is applied, not here.
"oauthAuthorizationUrl",
"oauthTokenUrl",
] as const) {
if (obj[optional] !== undefined && typeof obj[optional] !== "string") {
return { ok: false, error: `settings.${optional} must be a string` };
@ -1660,6 +1675,18 @@ export function createRemoteApp(
if (isKvArray(obj.oauthAuthorizationParams)) {
value.oauthAuthorizationParams = obj.oauthAuthorizationParams;
}
// Empty-string coerces to absent like the credential fields above: the form
// emits `""` when a user clears the input, and an empty override on disk
// would read back as a configured-but-blank endpoint. (#1906)
if (
typeof obj.oauthAuthorizationUrl === "string" &&
obj.oauthAuthorizationUrl !== ""
) {
value.oauthAuthorizationUrl = obj.oauthAuthorizationUrl;
}
if (typeof obj.oauthTokenUrl === "string" && obj.oauthTokenUrl !== "") {
value.oauthTokenUrl = obj.oauthTokenUrl;
}
if (obj.enterpriseManaged === true) {
value.enterpriseManaged = true;
}

View file

@ -248,6 +248,29 @@ export function oauthAuthorizationParamsFromSettings(
return Object.keys(record).length > 0 ? record : undefined;
}
/**
* Collapse a server's endpoint-override fields into the
* `InspectorClientOptions.oauth` sub-shape, or `undefined` when neither is set.
* Shared by the web (`App.tsx`) and Node (`buildRunnerClientAuthOptions`) paths
* so both derive the option identically. The values are passed through as
* typed; validation (absolute http(s) URL) happens once at the point of use, in
* `core/auth/endpointOverrides.ts`. (#1906)
*/
export function oauthEndpointOverridesFromSettings(
settings: Pick<
InspectorServerSettings,
"oauthAuthorizationUrl" | "oauthTokenUrl"
>,
): { authorizationUrl?: string; tokenUrl?: string } | undefined {
const authorizationUrl = settings.oauthAuthorizationUrl?.trim();
const tokenUrl = settings.oauthTokenUrl?.trim();
if (!authorizationUrl && !tokenUrl) return undefined;
return {
...(authorizationUrl && { authorizationUrl }),
...(tokenUrl && { tokenUrl }),
};
}
/**
* Lift the Inspector-extension fields off a freshly-read `StoredMCPServer`
* into the pair-array / flat-OAuth `InspectorServerSettings` shape the form
@ -357,6 +380,11 @@ export function storedFieldsToInspectorSettings(
if (authorizationParams) {
settings.oauthAuthorizationParams = envRecordToPairs(authorizationParams);
}
// Truthiness drops empty strings like the credential fields above, so a
// cleared field reads back as unset rather than as an empty override. (#1906)
if (stored.oauth?.authorizationUrl)
settings.oauthAuthorizationUrl = stored.oauth.authorizationUrl;
if (stored.oauth?.tokenUrl) settings.oauthTokenUrl = stored.oauth.tokenUrl;
if (stored.oauth?.onInsufficientScope) {
settings.oauthOnInsufficientScope = stored.oauth.onInsufficientScope;
}
@ -474,6 +502,14 @@ export function inspectorSettingsToStoredFields(
oauthFields.authorizationParams = authParams;
}
}
// Empty/blank reads back as unset (above), so a cleared field writes nothing
// and the file diff stays minimal for servers that never set one. (#1906)
if (settings.oauthAuthorizationUrl?.trim()) {
oauthFields.authorizationUrl = settings.oauthAuthorizationUrl.trim();
}
if (settings.oauthTokenUrl?.trim()) {
oauthFields.tokenUrl = settings.oauthTokenUrl.trim();
}
if (settings.oauthOnInsufficientScope) {
oauthFields.onInsufficientScope = settings.oauthOnInsufficientScope;
}

Some files were not shown because too many files have changed in this diff Show more