Merge pull request #2037 from modelcontextprotocol/v2/feat/1906-auth-token-url-overrides
feat(auth): Auth URL and Token URL overrides in Server Settings
This commit is contained in:
commit
4e4aa49db1
22 changed files with 1558 additions and 6 deletions
|
|
@ -45,7 +45,13 @@ v2/main/
|
||||||
│ │ # providers.redirectToAuthorization, the only
|
│ │ # providers.redirectToAuthorization, the only
|
||||||
│ │ # seam that sees the SDK-built authorize URL;
|
│ │ # seam that sees the SDK-built authorize URL;
|
||||||
│ │ # authorization request only, never the token
|
│ │ # 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)
|
│ │ ├── browser/ # Browser-side OAuth (sessionStorage, BrowserNavigation)
|
||||||
│ │ ├── node/ # Node-side OAuth (NodeOAuthStorage, OAuthCallbackServer,
|
│ │ ├── node/ # Node-side OAuth (NodeOAuthStorage, OAuthCallbackServer,
|
||||||
│ │ │ # runner-interactive-oauth loopback callback flow)
|
│ │ │ # runner-interactive-oauth loopback callback flow)
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ inspector/
|
||||||
│ ├── tui/ # TUI client (Ink + React, tsup bundle)
|
│ ├── tui/ # TUI client (Ink + React, tsup bundle)
|
||||||
│ └── launcher/ # Shared launcher — provides the `mcp-inspector` bin, dispatches to web/cli/tui
|
│ └── 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)
|
├── 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
|
│ ├── 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
|
│ ├── json/ # JSON + parameter/argument conversion utilities, and the nullable-union
|
||||||
│ │ # schema collapse shared by the web and TUI form builders
|
│ │ # schema collapse shared by the web and TUI form builders
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ import { ResourceSubscriptionsState } from "@inspector/core/mcp/state/resourceSu
|
||||||
import {
|
import {
|
||||||
cleanRoots,
|
cleanRoots,
|
||||||
oauthAuthorizationParamsFromSettings,
|
oauthAuthorizationParamsFromSettings,
|
||||||
|
oauthEndpointOverridesFromSettings,
|
||||||
serializeMcpConfig,
|
serializeMcpConfig,
|
||||||
} from "@inspector/core/mcp/serverList.js";
|
} from "@inspector/core/mcp/serverList.js";
|
||||||
import type { ClientConfig } from "@inspector/core/client/types.js";
|
import type { ClientConfig } from "@inspector/core/client/types.js";
|
||||||
|
|
@ -2315,12 +2316,16 @@ function App() {
|
||||||
const serverAuthorizationParams = savedSettings
|
const serverAuthorizationParams = savedSettings
|
||||||
? oauthAuthorizationParamsFromSettings(savedSettings)
|
? oauthAuthorizationParamsFromSettings(savedSettings)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const serverEndpointOverrides = savedSettings
|
||||||
|
? oauthEndpointOverridesFromSettings(savedSettings)
|
||||||
|
: undefined;
|
||||||
const oauthFromServer =
|
const oauthFromServer =
|
||||||
savedSettings &&
|
savedSettings &&
|
||||||
(savedSettings.oauthClientId ||
|
(savedSettings.oauthClientId ||
|
||||||
savedSettings.oauthClientSecret ||
|
savedSettings.oauthClientSecret ||
|
||||||
savedSettings.oauthScopes ||
|
savedSettings.oauthScopes ||
|
||||||
serverAuthorizationParams ||
|
serverAuthorizationParams ||
|
||||||
|
serverEndpointOverrides ||
|
||||||
savedSettings.enterpriseManaged)
|
savedSettings.enterpriseManaged)
|
||||||
? {
|
? {
|
||||||
...(savedSettings.oauthClientId && {
|
...(savedSettings.oauthClientId && {
|
||||||
|
|
@ -2335,6 +2340,7 @@ function App() {
|
||||||
...(serverAuthorizationParams && {
|
...(serverAuthorizationParams && {
|
||||||
authorizationParams: serverAuthorizationParams,
|
authorizationParams: serverAuthorizationParams,
|
||||||
}),
|
}),
|
||||||
|
...serverEndpointOverrides,
|
||||||
...(savedSettings.enterpriseManaged && {
|
...(savedSettings.enterpriseManaged && {
|
||||||
enterpriseManaged: true,
|
enterpriseManaged: true,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -701,6 +701,8 @@ describe("ServerSettingsForm", () => {
|
||||||
clientSecret: "",
|
clientSecret: "",
|
||||||
scopes: "",
|
scopes: "",
|
||||||
authorizationParams: [],
|
authorizationParams: [],
|
||||||
|
authorizationUrl: "",
|
||||||
|
tokenUrl: "",
|
||||||
enterpriseManaged: false,
|
enterpriseManaged: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -966,6 +968,104 @@ describe("ServerSettingsForm", () => {
|
||||||
expect(screen.getByText(/state, scope are set/)).toBeInTheDocument();
|
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("says the endpoint overrides are unused under enterprise-managed auth", () => {
|
||||||
|
const { rerender } = renderWithMantine(
|
||||||
|
<ServerSettingsForm
|
||||||
|
{...baseHandlers}
|
||||||
|
settings={{ ...emptySettings, enterpriseManaged: true }}
|
||||||
|
expandedSections={["oauth"]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getAllByText(
|
||||||
|
/Not applied while Enterprise-managed authorization is on/,
|
||||||
|
),
|
||||||
|
).toHaveLength(2);
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<ServerSettingsForm
|
||||||
|
{...baseHandlers}
|
||||||
|
settings={{ ...emptySettings, enterpriseManaged: false }}
|
||||||
|
expandedSections={["oauth"]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.queryByText(
|
||||||
|
/Not applied while Enterprise-managed authorization is on/,
|
||||||
|
),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
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 () => {
|
it("invokes onOAuthChange with the chosen insufficient-scope policy (SEP-2350)", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onOAuthChange = vi.fn();
|
const onOAuthChange = vi.fn();
|
||||||
|
|
@ -1007,6 +1107,8 @@ describe("ServerSettingsForm", () => {
|
||||||
clientSecret: "",
|
clientSecret: "",
|
||||||
scopes: "",
|
scopes: "",
|
||||||
authorizationParams: [],
|
authorizationParams: [],
|
||||||
|
authorizationUrl: "",
|
||||||
|
tokenUrl: "",
|
||||||
enterpriseManaged: true,
|
enterpriseManaged: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -1112,6 +1214,8 @@ describe("ServerSettingsForm", () => {
|
||||||
clientSecret: "z",
|
clientSecret: "z",
|
||||||
scopes: "",
|
scopes: "",
|
||||||
authorizationParams: [],
|
authorizationParams: [],
|
||||||
|
authorizationUrl: "",
|
||||||
|
tokenUrl: "",
|
||||||
enterpriseManaged: false,
|
enterpriseManaged: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import {
|
||||||
authorizationParamKeyError,
|
authorizationParamKeyError,
|
||||||
isReservedAuthorizationParam,
|
isReservedAuthorizationParam,
|
||||||
} from "@inspector/core/auth/authorizationParams.js";
|
} from "@inspector/core/auth/authorizationParams.js";
|
||||||
|
import { oauthEndpointUrlError } from "@inspector/core/auth/endpointOverrides.js";
|
||||||
import { ADVERTISABLE_EXTENSIONS } from "@inspector/core/mcp/extensions.js";
|
import { ADVERTISABLE_EXTENSIONS } from "@inspector/core/mcp/extensions.js";
|
||||||
import type { Root } from "@modelcontextprotocol/client";
|
import type { Root } from "@modelcontextprotocol/client";
|
||||||
|
|
||||||
|
|
@ -478,11 +479,20 @@ export function ServerSettingsForm({
|
||||||
clientSecret: settings.oauthClientSecret ?? "",
|
clientSecret: settings.oauthClientSecret ?? "",
|
||||||
scopes: settings.oauthScopes ?? "",
|
scopes: settings.oauthScopes ?? "",
|
||||||
authorizationParams,
|
authorizationParams,
|
||||||
|
authorizationUrl: settings.oauthAuthorizationUrl ?? "",
|
||||||
|
tokenUrl: settings.oauthTokenUrl ?? "",
|
||||||
enterpriseManaged: settings.enterpriseManaged ?? false,
|
enterpriseManaged: settings.enterpriseManaged ?? false,
|
||||||
onInsufficientScope: settings.oauthOnInsufficientScope,
|
onInsufficientScope: settings.oauthOnInsufficientScope,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #1906 — the overrides are suppressed under EMA for the same reason the
|
||||||
|
// custom authorization parameters are: that leg authorizes against the
|
||||||
|
// enterprise IdP, a different authorization server.
|
||||||
|
const endpointOverrideEmaNote = settings.enterpriseManaged
|
||||||
|
? " Not applied while Enterprise-managed authorization is on: that flow authorizes against the enterprise IdP, a different authorization server."
|
||||||
|
: "";
|
||||||
|
|
||||||
const rejectedParamKeys = authorizationParams
|
const rejectedParamKeys = authorizationParams
|
||||||
.map((p) => p.key.trim())
|
.map((p) => p.key.trim())
|
||||||
.filter((key) => isReservedAuthorizationParam(key));
|
.filter((key) => isReservedAuthorizationParam(key));
|
||||||
|
|
@ -863,6 +873,55 @@ export function ServerSettingsForm({
|
||||||
+ Add Parameter
|
+ Add Parameter
|
||||||
</AddButton>
|
</AddButton>
|
||||||
</Stack>
|
</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. Endpoints only — the discovered issuer is unchanged, so an authorization server advertising a different issuer is rejected on callback (RFC 9207).${endpointOverrideEmaNote}`}
|
||||||
|
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.${endpointOverrideEmaNote}`}
|
||||||
|
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
|
<Select
|
||||||
label="Insufficient-scope response"
|
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."
|
description="On a 403 insufficient_scope challenge (SEP-2350): re-authorize with the accumulated scope union, or surface the error to you."
|
||||||
|
|
|
||||||
|
|
@ -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
|
// #2018 — the authorization-parameter rows ride the same onOAuthChange
|
||||||
// callback, so the modal folds them onto the settings object.
|
// callback, so the modal folds them onto the settings object.
|
||||||
it("persists an added authorization-parameter row onto the settings", async () => {
|
it("persists an added authorization-parameter row onto the settings", async () => {
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,11 @@ export function ServerSettingsModal({
|
||||||
// survives a re-render; the drop-blank/omit-empty filtering happens on the
|
// survives a re-render; the drop-blank/omit-empty filtering happens on the
|
||||||
// way to disk. An emptied list persists as `[]`, which writes nothing.
|
// way to disk. An emptied list persists as `[]`, which writes nothing.
|
||||||
oauthAuthorizationParams: oauth.authorizationParams,
|
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,
|
enterpriseManaged: oauth.enterpriseManaged ? true : undefined,
|
||||||
// SEP-2350: persist only the non-default ('throw') so unset servers keep
|
// SEP-2350: persist only the non-default ('throw') so unset servers keep
|
||||||
// the SDK's `reauthorize` behavior without writing a spurious field.
|
// the SDK's `reauthorize` behavior without writing a spurious field.
|
||||||
|
|
|
||||||
394
clients/web/src/test/core/auth/endpointOverrides.test.ts
Normal file
394
clients/web/src/test/core/auth/endpointOverrides.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -124,6 +124,46 @@ describe("runner client auth options", () => {
|
||||||
expect(buildRunnerClientAuthOptions({}, settings)).toEqual({});
|
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", () => {
|
it("buildRunnerClientAuthOptions returns no oauth when nothing supplies it", () => {
|
||||||
expect(buildRunnerClientAuthOptions({})).toEqual({});
|
expect(buildRunnerClientAuthOptions({})).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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", () => {
|
describe("getServerUrl propagation", () => {
|
||||||
it("createOAuthProviderForTransport throws when getServerUrl throws", async () => {
|
it("createOAuthProviderForTransport throws when getServerUrl throws", async () => {
|
||||||
const params = createMockParams({
|
const params = createMockParams({
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
mergeSecretsIntoStored,
|
mergeSecretsIntoStored,
|
||||||
normalizeServerType,
|
normalizeServerType,
|
||||||
oauthAuthorizationParamsFromSettings,
|
oauthAuthorizationParamsFromSettings,
|
||||||
|
oauthEndpointOverridesFromSettings,
|
||||||
serverEntriesToMcpConfig,
|
serverEntriesToMcpConfig,
|
||||||
serializeMcpConfig,
|
serializeMcpConfig,
|
||||||
storedFieldsToInspectorSettings,
|
storedFieldsToInspectorSettings,
|
||||||
|
|
@ -1288,6 +1289,89 @@ describe("oauthOnInsufficientScope (SEP-2350)", () => {
|
||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
expect(oauthAuthorizationParamsFromSettings({})).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", () => {
|
describe("envRecordToPairs / envPairsToRecord", () => {
|
||||||
|
|
|
||||||
|
|
@ -496,6 +496,75 @@ describe("/api/servers routes", () => {
|
||||||
expect(res.status).toBe(400);
|
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 () => {
|
it("updates Inspector-extension fields at the top level on PUT", async () => {
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
h.configPath,
|
h.configPath,
|
||||||
|
|
|
||||||
342
core/auth/endpointOverrides.ts
Normal file
342
core/auth/endpointOverrides.ts
Normal file
|
|
@ -0,0 +1,342 @@
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* Four 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.
|
||||||
|
* - **They do not apply to the enterprise-managed (EMA) leg.** That flow
|
||||||
|
* authorizes against the enterprise IdP — a different authorization server —
|
||||||
|
* and its OIDC discovery runs through this same fetch, so `OAuthManager`
|
||||||
|
* suppresses the overrides when the server is enterprise-managed. This mirrors
|
||||||
|
* `redirectToExternalAuthorization` skipping the custom authorization
|
||||||
|
* parameters (#2018).
|
||||||
|
* - **They redirect endpoints, they do not re-point the issuer.** `issuer` is
|
||||||
|
* left exactly as discovery returned it, deliberately: it is the authorization
|
||||||
|
* server's *identity*, and it is what RFC 9207 / SEP-2352 check the callback's
|
||||||
|
* `iss` against to defend against an authorization-server mix-up. So these
|
||||||
|
* overrides fit alternate endpoints of the same logical issuer (a staging
|
||||||
|
* deployment fronting the same issuer, a local proxy). Aim one at an
|
||||||
|
* authorization server that advertises a *different* issuer and the callback
|
||||||
|
* is rejected as an issuer mismatch before the code is redeemed — which is the
|
||||||
|
* mix-up defense working. Suppressing that check to make the override "work"
|
||||||
|
* would trade a real security property for a debugging convenience, so the
|
||||||
|
* limit is documented rather than removed.
|
||||||
|
* - **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.`;
|
||||||
|
}
|
||||||
|
// `new URL` accepts embedded credentials but Fetch rejects an HTTP(S) request
|
||||||
|
// URL that carries them, so without this the value would pass both the form
|
||||||
|
// and the runtime and then fail deep in the OAuth exchange — the failure mode
|
||||||
|
// this validation exists to prevent. (Sending credentials to an endpoint the
|
||||||
|
// flow discovered is not something to support quietly, either.)
|
||||||
|
if (parsed.username || parsed.password) {
|
||||||
|
return "URL must not contain a username or password.";
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The well-known paths authorization-server metadata is discovered at: RFC 8414
|
||||||
|
* (§3.1, including the path-suffixed and path-prefixed variants the SDK's
|
||||||
|
* `buildDiscoveryUrls` emits) and OpenID Connect Discovery. Matched as a
|
||||||
|
* substring of the pathname so every variant is covered by two literals.
|
||||||
|
*/
|
||||||
|
const AS_METADATA_WELL_KNOWN_PATHS = [
|
||||||
|
"/.well-known/oauth-authorization-server",
|
||||||
|
"/.well-known/openid-configuration",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** The request URL a `fetch` call was made with, in any of its three forms. */
|
||||||
|
function requestUrlOf(input: RequestInfo | URL): string {
|
||||||
|
if (typeof input === "string") return input;
|
||||||
|
if (input instanceof URL) return input.href;
|
||||||
|
return input.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a request is an authorization-server metadata discovery request.
|
||||||
|
*
|
||||||
|
* This is what keeps the wrapper off the hot path. Without it, every successful
|
||||||
|
* `application/json` response on the connection — every `tools/call` result, and
|
||||||
|
* every resource payload on the direct CLI/TUI transports — would be buffered
|
||||||
|
* and parsed a second time before the SDK could consume it, purely to discover
|
||||||
|
* that it is not a metadata document. Gating on the discovery URL first means
|
||||||
|
* ordinary traffic is untouched: one string test and the response is returned.
|
||||||
|
*
|
||||||
|
* A URL that will not parse falls back to testing the raw string, which is the
|
||||||
|
* conservative direction — worst case a request is *considered*, and the
|
||||||
|
* metadata predicate then rejects it.
|
||||||
|
*/
|
||||||
|
function isAuthorizationServerMetadataRequest(
|
||||||
|
input: RequestInfo | URL,
|
||||||
|
): boolean {
|
||||||
|
const raw = requestUrlOf(input);
|
||||||
|
let pathname: string;
|
||||||
|
try {
|
||||||
|
pathname = new URL(raw).pathname;
|
||||||
|
} catch {
|
||||||
|
pathname = raw;
|
||||||
|
}
|
||||||
|
return AS_METADATA_WELL_KNOWN_PATHS.some((path) => pathname.includes(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a `content-type` names a **whole JSON document** — something that can
|
||||||
|
* be read to completion.
|
||||||
|
*
|
||||||
|
* This must be an exact media-type match, not a substring test for "json". The
|
||||||
|
* streamable-HTTP transport's long-lived server-push channel can be served as
|
||||||
|
* `application/x-ndjson` (see `isLongLivedStreamResponse` in
|
||||||
|
* `core/mcp/fetchTracking.ts`), which is an unbounded stream: awaiting `.json()`
|
||||||
|
* on a clone of it would never resolve, and since this wrapper awaits before
|
||||||
|
* returning, the MCP connection would hang for as long as an override is
|
||||||
|
* configured. Metadata documents are plain `application/json`, so the narrow
|
||||||
|
* test loses nothing. The `+json` structured suffix is accepted for the same
|
||||||
|
* reason it exists — it names a concrete document, not a stream.
|
||||||
|
*/
|
||||||
|
function isJsonDocumentResponse(contentType: string | null): boolean {
|
||||||
|
if (!contentType) return false;
|
||||||
|
// Strip parameters (`; charset=utf-8`) and compare the media type itself.
|
||||||
|
const mediaType = contentType.split(";")[0].trim().toLowerCase();
|
||||||
|
return mediaType === "application/json" || mediaType.endsWith("+json");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
// Cheapest tests first: only a discovery request can carry the document
|
||||||
|
// this wrapper rewrites, so ordinary traffic never reaches the clone below.
|
||||||
|
if (!isAuthorizationServerMetadataRequest(input)) return response;
|
||||||
|
if (!response.ok) return response;
|
||||||
|
if (!isJsonDocumentResponse(response.headers.get("content-type")))
|
||||||
|
return response;
|
||||||
|
|
||||||
|
// Read through a clone so every path that does NOT patch can return the
|
||||||
|
// caller's own `Response`, untouched. Rebuilding unconditionally would drop
|
||||||
|
// native properties a synthesized `Response` cannot carry (`url`,
|
||||||
|
// `redirected`, `type`) from responses this wrapper has no business
|
||||||
|
// altering — and would throw outright on a body-less 204/205, whose status
|
||||||
|
// Fetch forbids a body on. A clone also makes a non-JSON body a plain
|
||||||
|
// parse failure with nothing consumed.
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = await response.clone().json();
|
||||||
|
} catch {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
if (!isAuthorizationServerMetadata(parsed)) return response;
|
||||||
|
return responseWithBody(
|
||||||
|
response,
|
||||||
|
JSON.stringify(applyOAuthEndpointOverrides(parsed, overrides)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -36,6 +36,16 @@ export {
|
||||||
} from "./authorizationParams.js";
|
} from "./authorizationParams.js";
|
||||||
export type { ReservedAuthorizationParam } 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
|
// Storage
|
||||||
export type {
|
export type {
|
||||||
OAuthStorage,
|
OAuthStorage,
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,10 @@ import type {
|
||||||
InspectorClientOptions,
|
InspectorClientOptions,
|
||||||
InspectorServerSettings,
|
InspectorServerSettings,
|
||||||
} from "../mcp/types.js";
|
} from "../mcp/types.js";
|
||||||
import { oauthAuthorizationParamsFromSettings } from "../mcp/serverList.js";
|
import {
|
||||||
|
oauthAuthorizationParamsFromSettings,
|
||||||
|
oauthEndpointOverridesFromSettings,
|
||||||
|
} from "../mcp/serverList.js";
|
||||||
import { loadClientConfig } from "./config.js";
|
import { loadClientConfig } from "./config.js";
|
||||||
import type { ClientConfig } from "./types.js";
|
import type { ClientConfig } from "./types.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -74,6 +77,9 @@ export function buildRunnerClientAuthOptions(
|
||||||
const serverAuthorizationParams = savedSettings
|
const serverAuthorizationParams = savedSettings
|
||||||
? oauthAuthorizationParamsFromSettings(savedSettings)
|
? oauthAuthorizationParamsFromSettings(savedSettings)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const serverEndpointOverrides = savedSettings
|
||||||
|
? oauthEndpointOverridesFromSettings(savedSettings)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const oauthFromServer =
|
const oauthFromServer =
|
||||||
savedSettings &&
|
savedSettings &&
|
||||||
|
|
@ -81,6 +87,7 @@ export function buildRunnerClientAuthOptions(
|
||||||
savedSettings.oauthClientSecret ||
|
savedSettings.oauthClientSecret ||
|
||||||
savedSettings.oauthScopes ||
|
savedSettings.oauthScopes ||
|
||||||
serverAuthorizationParams ||
|
serverAuthorizationParams ||
|
||||||
|
serverEndpointOverrides ||
|
||||||
savedSettings.enterpriseManaged)
|
savedSettings.enterpriseManaged)
|
||||||
? {
|
? {
|
||||||
...(savedSettings.oauthClientId && {
|
...(savedSettings.oauthClientId && {
|
||||||
|
|
@ -95,6 +102,7 @@ export function buildRunnerClientAuthOptions(
|
||||||
...(serverAuthorizationParams && {
|
...(serverAuthorizationParams && {
|
||||||
authorizationParams: serverAuthorizationParams,
|
authorizationParams: serverAuthorizationParams,
|
||||||
}),
|
}),
|
||||||
|
...serverEndpointOverrides,
|
||||||
...(savedSettings.enterpriseManaged && {
|
...(savedSettings.enterpriseManaged && {
|
||||||
enterpriseManaged: true,
|
enterpriseManaged: true,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -218,6 +218,7 @@ import {
|
||||||
type AuthChallengeOutcome,
|
type AuthChallengeOutcome,
|
||||||
type HandleAuthChallengeOptions,
|
type HandleAuthChallengeOptions,
|
||||||
} from "../auth/challenge.js";
|
} from "../auth/challenge.js";
|
||||||
|
import { withOAuthEndpointOverrides } from "../auth/endpointOverrides.js";
|
||||||
import type { OAuthTokens } from "@modelcontextprotocol/client";
|
import type { OAuthTokens } from "@modelcontextprotocol/client";
|
||||||
import { silentLogger, type InspectorLogger } from "../logging/logger.js";
|
import { silentLogger, type InspectorLogger } from "../logging/logger.js";
|
||||||
import { createFetchTracker } from "./fetchTracking.js";
|
import { createFetchTracker } from "./fetchTracking.js";
|
||||||
|
|
@ -650,6 +651,16 @@ export class InspectorClient extends InspectorClientEventTarget {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Effective auth fetch: base fetch + tracking with category 'auth'
|
// 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.effectiveAuthFetch = this.buildEffectiveAuthFetch();
|
||||||
|
|
||||||
this.sessionId = options.sessionId;
|
this.sessionId = options.sessionId;
|
||||||
|
|
@ -5988,6 +5999,9 @@ export class InspectorClient extends InspectorClientEventTarget {
|
||||||
scope?: string;
|
scope?: string;
|
||||||
/** Custom authorization-request parameters (#2018). Authorize URL only. */
|
/** Custom authorization-request parameters (#2018). Authorize URL only. */
|
||||||
authorizationParams?: Record<string, string>;
|
authorizationParams?: Record<string, string>;
|
||||||
|
/** Endpoint overrides applied to the discovered AS metadata (#1906). */
|
||||||
|
authorizationUrl?: string;
|
||||||
|
tokenUrl?: string;
|
||||||
}): void {
|
}): void {
|
||||||
if (!this.oauthManager) {
|
if (!this.oauthManager) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { BaseOAuthClientProvider } from "../auth/providers.js";
|
import { BaseOAuthClientProvider } from "../auth/providers.js";
|
||||||
|
import type { OAuthEndpointOverrides } from "../auth/endpointOverrides.js";
|
||||||
import type { OAuthFlowState, OAuthStep } from "../auth/types.js";
|
import type { OAuthFlowState, OAuthStep } from "../auth/types.js";
|
||||||
import { EMPTY_OAUTH_FLOW_STATE } from "../auth/types.js";
|
import { EMPTY_OAUTH_FLOW_STATE } from "../auth/types.js";
|
||||||
import type { OAuthTokens } from "@modelcontextprotocol/client";
|
import type { OAuthTokens } from "@modelcontextprotocol/client";
|
||||||
|
|
@ -95,6 +96,8 @@ export class OAuthManager {
|
||||||
scope?: string;
|
scope?: string;
|
||||||
enterpriseManaged?: boolean;
|
enterpriseManaged?: boolean;
|
||||||
authorizationParams?: Record<string, string>;
|
authorizationParams?: Record<string, string>;
|
||||||
|
authorizationUrl?: string;
|
||||||
|
tokenUrl?: string;
|
||||||
}): void {
|
}): void {
|
||||||
this.oauthConfig = {
|
this.oauthConfig = {
|
||||||
...this.oauthConfig,
|
...this.oauthConfig,
|
||||||
|
|
@ -106,6 +109,31 @@ export class OAuthManager {
|
||||||
return this.params.getServerUrl();
|
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)
|
||||||
|
*
|
||||||
|
* Returns nothing under enterprise-managed authorization, for the same reason
|
||||||
|
* `redirectToExternalAuthorization` skips the custom authorization parameters
|
||||||
|
* (#2018): the EMA leg authorizes against the enterprise IdP, a *different*
|
||||||
|
* authorization server, and its OIDC discovery runs through this same fetch
|
||||||
|
* (`emaFlow` → `idpOidc`). Rewriting that document would point the IdP login —
|
||||||
|
* or the IdP code/refresh-token exchange — at the resource server's
|
||||||
|
* authorization server, which is both wrong and a way to leak an IdP
|
||||||
|
* credential across an authorization-server boundary.
|
||||||
|
*/
|
||||||
|
getEndpointOverrides(): OAuthEndpointOverrides | undefined {
|
||||||
|
if (this.isEnterpriseManaged()) return 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
|
* 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
|
* absent. Used on the EMA re-mint/persist paths, which are only reached for
|
||||||
|
|
|
||||||
|
|
@ -1147,11 +1147,22 @@ export function createRemoteApp(
|
||||||
clientSecret?: string;
|
clientSecret?: string;
|
||||||
scopes?: string;
|
scopes?: string;
|
||||||
authorizationParams?: Record<string, string>;
|
authorizationParams?: Record<string, string>;
|
||||||
|
authorizationUrl?: string;
|
||||||
|
tokenUrl?: string;
|
||||||
enterpriseManaged?: boolean;
|
enterpriseManaged?: boolean;
|
||||||
} => {
|
} => {
|
||||||
if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
|
if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
|
||||||
const o = v as Record<string, unknown>;
|
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 (o[k] !== undefined && typeof o[k] !== "string") return false;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
|
@ -1271,7 +1282,7 @@ export function createRemoteApp(
|
||||||
if ("oauth" in valObj && !isOauthObject(valObj.oauth)) {
|
if ("oauth" in valObj && !isOauthObject(valObj.oauth)) {
|
||||||
logWarn(
|
logWarn(
|
||||||
{ route: "/api/servers", id, droppedKey: "oauth" },
|
{ 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;
|
delete valObj.oauth;
|
||||||
}
|
}
|
||||||
|
|
@ -1538,6 +1549,10 @@ export function createRemoteApp(
|
||||||
"oauthClientId",
|
"oauthClientId",
|
||||||
"oauthClientSecret",
|
"oauthClientSecret",
|
||||||
"oauthScopes",
|
"oauthScopes",
|
||||||
|
// #1906 — string-shaped on the wire; URL validity is checked where the
|
||||||
|
// override is applied, not here.
|
||||||
|
"oauthAuthorizationUrl",
|
||||||
|
"oauthTokenUrl",
|
||||||
] as const) {
|
] as const) {
|
||||||
if (obj[optional] !== undefined && typeof obj[optional] !== "string") {
|
if (obj[optional] !== undefined && typeof obj[optional] !== "string") {
|
||||||
return { ok: false, error: `settings.${optional} must be a string` };
|
return { ok: false, error: `settings.${optional} must be a string` };
|
||||||
|
|
@ -1676,6 +1691,18 @@ export function createRemoteApp(
|
||||||
if (isKvArray(obj.oauthAuthorizationParams)) {
|
if (isKvArray(obj.oauthAuthorizationParams)) {
|
||||||
value.oauthAuthorizationParams = 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) {
|
if (obj.enterpriseManaged === true) {
|
||||||
value.enterpriseManaged = true;
|
value.enterpriseManaged = true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -248,6 +248,56 @@ export function oauthAuthorizationParamsFromSettings(
|
||||||
return Object.keys(record).length > 0 ? record : undefined;
|
return Object.keys(record).length > 0 ? record : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate one of a server's `oauth` endpoint overrides as it comes off disk,
|
||||||
|
* or `undefined` when there is nothing usable. (#1906)
|
||||||
|
*
|
||||||
|
* Same reasoning as `cleanAuthorizationParams` above: `StoredMCPServer` types
|
||||||
|
* these as `string`, but that is a compile-time promise a hand-edited file does
|
||||||
|
* not keep, and the CLI/TUI read `mcp.json` directly. A non-string reaches
|
||||||
|
* `oauthEndpointOverridesFromSettings`, which calls `.trim()` on it — so without
|
||||||
|
* this the whole server list fails to load. URL *validity* is not checked here;
|
||||||
|
* that happens where the override is applied, so a typo drops one field with a
|
||||||
|
* warning instead of anything louder.
|
||||||
|
*/
|
||||||
|
export function cleanEndpointOverride(
|
||||||
|
value: string | undefined,
|
||||||
|
field: "authorizationUrl" | "tokenUrl",
|
||||||
|
): string | undefined {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
// Keep: unreachable per the parameter type, reachable from disk.
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
console.warn(
|
||||||
|
`Ignoring \`oauth.${field}\`: expected a string, got ${typeof value}.`,
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return value.trim() || 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`
|
* Lift the Inspector-extension fields off a freshly-read `StoredMCPServer`
|
||||||
* into the pair-array / flat-OAuth `InspectorServerSettings` shape the form
|
* into the pair-array / flat-OAuth `InspectorServerSettings` shape the form
|
||||||
|
|
@ -357,6 +407,21 @@ export function storedFieldsToInspectorSettings(
|
||||||
if (authorizationParams) {
|
if (authorizationParams) {
|
||||||
settings.oauthAuthorizationParams = envRecordToPairs(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. The
|
||||||
|
// `string` type is a compile-time promise a hand-edited `mcp.json` does not
|
||||||
|
// keep, and only the web client's `/api/servers` route checks it — the CLI and
|
||||||
|
// TUI read the file directly and then call `.trim()` on these in
|
||||||
|
// `oauthEndpointOverridesFromSettings`, so a non-string would crash the server
|
||||||
|
// load. Sanitized like `authorizationParams` above rather than trusted.
|
||||||
|
// (#1906)
|
||||||
|
const authorizationUrl = cleanEndpointOverride(
|
||||||
|
stored.oauth?.authorizationUrl,
|
||||||
|
"authorizationUrl",
|
||||||
|
);
|
||||||
|
if (authorizationUrl) settings.oauthAuthorizationUrl = authorizationUrl;
|
||||||
|
const tokenUrl = cleanEndpointOverride(stored.oauth?.tokenUrl, "tokenUrl");
|
||||||
|
if (tokenUrl) settings.oauthTokenUrl = tokenUrl;
|
||||||
if (stored.oauth?.onInsufficientScope) {
|
if (stored.oauth?.onInsufficientScope) {
|
||||||
settings.oauthOnInsufficientScope = stored.oauth.onInsufficientScope;
|
settings.oauthOnInsufficientScope = stored.oauth.onInsufficientScope;
|
||||||
}
|
}
|
||||||
|
|
@ -474,6 +539,14 @@ export function inspectorSettingsToStoredFields(
|
||||||
oauthFields.authorizationParams = authParams;
|
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) {
|
if (settings.oauthOnInsufficientScope) {
|
||||||
oauthFields.onInsufficientScope = settings.oauthOnInsufficientScope;
|
oauthFields.onInsufficientScope = settings.oauthOnInsufficientScope;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue