Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions clients/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 && {
Expand All @@ -2335,6 +2340,7 @@ function App() {
...(serverAuthorizationParams && {
authorizationParams: serverAuthorizationParams,
}),
...serverEndpointOverrides,
...(savedSettings.enterpriseManaged && {
enterpriseManaged: true,
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,8 @@ describe("ServerSettingsForm", () => {
clientSecret: "",
scopes: "",
authorizationParams: [],
authorizationUrl: "",
tokenUrl: "",
enterpriseManaged: false,
});
});
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -1007,6 +1079,8 @@ describe("ServerSettingsForm", () => {
clientSecret: "",
scopes: "",
authorizationParams: [],
authorizationUrl: "",
tokenUrl: "",
enterpriseManaged: true,
});
});
Expand Down Expand Up @@ -1112,6 +1186,8 @@ describe("ServerSettingsForm", () => {
clientSecret: "z",
scopes: "",
authorizationParams: [],
authorizationUrl: "",
tokenUrl: "",
enterpriseManaged: false,
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading