From 364d8c9dbc01de49b2c7d28366332e00dd514d23 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 16 Aug 2026 20:23:25 -0400 Subject: [PATCH 1/5] feat(auth): per-server authorization and token URL overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two optional per-server fields — `oauth.authorizationUrl` and `oauth.tokenUrl` — that override the endpoints authorization-server metadata discovery resolved, so a server can be pointed at a development or staging authorization server without changing what it advertises. SDK v2 routes both endpoints through the discovered metadata document and neither through OAuthClientProvider, so the override is applied by wrapping the client's base fetch and patching the metadata document in flight (core/auth/endpointOverrides.ts). Wrapping the base fetch — rather than only the auth fetch — also covers the discovery the SDK runs from inside the transport on the 401/refresh path. The fields round-trip through mcp.json, the /api/servers routes, the runner (CLI/TUI), and the Server Settings → Authorization panel, where a value that is not an absolute http(s) URL is flagged inline; at runtime such a value is dropped with a single warning rather than failing the connection. Signed-off-by: cliffhall --- AGENTS.md | 8 +- README.md | 2 +- clients/web/src/App.tsx | 6 + .../ServerSettingsForm.test.tsx | 76 +++++ .../ServerSettingsForm/ServerSettingsForm.tsx | 52 ++++ .../ServerSettingsModal.test.tsx | 46 +++ .../ServerSettingsModal.tsx | 5 + .../test/core/auth/endpointOverrides.test.ts | 276 ++++++++++++++++++ .../web/src/test/core/client/runner.test.ts | 40 +++ ...torClient-oauth-endpoint-overrides.test.ts | 143 +++++++++ .../src/test/core/mcp/oauthManager.test.ts | 24 ++ .../web/src/test/core/mcp/serverList.test.ts | 61 ++++ .../mcp/remote/servers-route.test.ts | 69 +++++ core/auth/endpointOverrides.ts | 246 ++++++++++++++++ core/auth/index.ts | 10 + core/client/runner.ts | 10 +- core/mcp/inspectorClient.ts | 14 + core/mcp/oauthManager.ts | 18 ++ core/mcp/remote/node/server.ts | 31 +- core/mcp/serverList.ts | 36 +++ core/mcp/types.ts | 39 +++ docs/mcp-server-configuration.md | 6 +- 22 files changed, 1212 insertions(+), 6 deletions(-) create mode 100644 clients/web/src/test/core/auth/endpointOverrides.test.ts create mode 100644 clients/web/src/test/core/mcp/inspectorClient-oauth-endpoint-overrides.test.ts create mode 100644 core/auth/endpointOverrides.ts diff --git a/AGENTS.md b/AGENTS.md index d07e84f4d..3deca03ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) diff --git a/README.md b/README.md index cb57dd359..a9fab0c1e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 72d8bd1f3..5837f27f5 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -64,6 +64,7 @@ import { ResourceSubscriptionsState } from "@inspector/core/mcp/state/resourceSu import { cleanRoots, oauthAuthorizationParamsFromSettings, + oauthEndpointOverridesFromSettings, serializeMcpConfig, } from "@inspector/core/mcp/serverList.js"; import type { ClientConfig } from "@inspector/core/client/types.js"; @@ -2315,12 +2316,16 @@ function App() { const serverAuthorizationParams = savedSettings ? oauthAuthorizationParamsFromSettings(savedSettings) : undefined; + const serverEndpointOverrides = savedSettings + ? oauthEndpointOverridesFromSettings(savedSettings) + : undefined; const oauthFromServer = savedSettings && (savedSettings.oauthClientId || savedSettings.oauthClientSecret || savedSettings.oauthScopes || serverAuthorizationParams || + serverEndpointOverrides || savedSettings.enterpriseManaged) ? { ...(savedSettings.oauthClientId && { @@ -2335,6 +2340,7 @@ function App() { ...(serverAuthorizationParams && { authorizationParams: serverAuthorizationParams, }), + ...serverEndpointOverrides, ...(savedSettings.enterpriseManaged && { enterpriseManaged: true, }), diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx index d238228a1..ac26d2eb6 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx @@ -701,6 +701,8 @@ describe("ServerSettingsForm", () => { clientSecret: "", scopes: "", authorizationParams: [], + authorizationUrl: "", + tokenUrl: "", enterpriseManaged: false, }); }); @@ -966,6 +968,76 @@ describe("ServerSettingsForm", () => { expect(screen.getByText(/state, scope are set/)).toBeInTheDocument(); }); + // #1906 — the endpoint overrides ride the same `onOAuthChange` callback as + // the rest of the Authorization section. + it("invokes onOAuthChange when an endpoint override is typed", async () => { + const user = userEvent.setup(); + const onOAuthChange = vi.fn(); + renderWithMantine( + , + ); + 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( + , + ); + 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( + , + ); + const clearButtons = screen.getAllByRole("button", { name: /clear/i }); + await user.click(clearButtons[clearButtons.length - 1]); + expect(onOAuthChange).toHaveBeenCalledWith( + expect.objectContaining({ tokenUrl: "" }), + ); + }); + it("invokes onOAuthChange with the chosen insufficient-scope policy (SEP-2350)", async () => { const user = userEvent.setup(); const onOAuthChange = vi.fn(); @@ -1007,6 +1079,8 @@ describe("ServerSettingsForm", () => { clientSecret: "", scopes: "", authorizationParams: [], + authorizationUrl: "", + tokenUrl: "", enterpriseManaged: true, }); }); @@ -1112,6 +1186,8 @@ describe("ServerSettingsForm", () => { clientSecret: "z", scopes: "", authorizationParams: [], + authorizationUrl: "", + tokenUrl: "", enterpriseManaged: false, }); }); diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx index 276c9cf9d..887555c3b 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx @@ -32,6 +32,7 @@ import { authorizationParamKeyError, isReservedAuthorizationParam, } from "@inspector/core/auth/authorizationParams.js"; +import { oauthEndpointUrlError } from "@inspector/core/auth/endpointOverrides.js"; import { ADVERTISABLE_EXTENSIONS } from "@inspector/core/mcp/extensions.js"; import type { Root } from "@modelcontextprotocol/client"; @@ -478,6 +479,8 @@ export function ServerSettingsForm({ clientSecret: settings.oauthClientSecret ?? "", scopes: settings.oauthScopes ?? "", authorizationParams, + authorizationUrl: settings.oauthAuthorizationUrl ?? "", + tokenUrl: settings.oauthTokenUrl ?? "", enterpriseManaged: settings.enterpriseManaged ?? false, onInsufficientScope: settings.oauthOnInsufficientScope, }; @@ -863,6 +866,55 @@ export function ServerSettingsForm({ + Add Parameter + + onOAuthChange({ + ...currentOAuth(), + authorizationUrl: e.currentTarget.value, + }) + } + rightSection={ + settings.oauthAuthorizationUrl ? ( + + onOAuthChange({ + ...currentOAuth(), + authorizationUrl: "", + }) + } + /> + ) : null + } + /> + + onOAuthChange({ + ...currentOAuth(), + tokenUrl: e.currentTarget.value, + }) + } + rightSection={ + settings.oauthTokenUrl ? ( + + onOAuthChange({ ...currentOAuth(), tokenUrl: "" }) + } + /> + ) : null + } + />