diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index d566e65a..60849b70 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -2602,7 +2602,8 @@ function App() { savedSettings.oauthScopes || serverAuthorizationParams || serverEndpointOverrides || - savedSettings.enterpriseManaged) + savedSettings.enterpriseManaged || + savedSettings.oauthRequestRefreshToken === false) ? { ...(savedSettings.oauthClientId && { clientId: savedSettings.oauthClientId, @@ -2620,6 +2621,11 @@ function App() { ...(savedSettings.enterpriseManaged && { enterpriseManaged: true, }), + // #2068: only the explicit opt-out is forwarded; omitting the key + // leaves the provider's default (declare `refresh_token`) in place. + ...(savedSettings.oauthRequestRefreshToken === false && { + requestRefreshToken: false, + }), } : undefined; const oauth = diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx index 9cc428ad..9f2912a7 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx @@ -706,9 +706,51 @@ describe("ServerSettingsForm", () => { authorizationUrl: "", tokenUrl: "", enterpriseManaged: false, + requestRefreshToken: true, }); }); + // #2068 — the refresh-token opt-out. On by default; unchecking it is what + // stops the SDK adding `offline_access` and then `prompt=consent`. + it("renders Request refresh token checked by default", () => { + renderWithMantine( + , + ); + expect(screen.getByLabelText("Request refresh token")).toBeChecked(); + }); + + it("renders Request refresh token unchecked when the server opted out", () => { + renderWithMantine( + , + ); + expect(screen.getByLabelText("Request refresh token")).not.toBeChecked(); + }); + + it("toggles Request refresh token through onOAuthChange", async () => { + const user = userEvent.setup(); + const onOAuthChange = vi.fn(); + renderWithMantine( + , + ); + await user.click(screen.getByLabelText("Request refresh token")); + expect(onOAuthChange).toHaveBeenLastCalledWith( + expect.objectContaining({ requestRefreshToken: false }), + ); + }); + // #2018 — custom authorization-request parameters. it("shows the empty hint for authorization parameters when none are set", () => { renderWithMantine( @@ -1112,6 +1154,7 @@ describe("ServerSettingsForm", () => { authorizationUrl: "", tokenUrl: "", enterpriseManaged: true, + requestRefreshToken: true, }); }); @@ -1219,6 +1262,7 @@ describe("ServerSettingsForm", () => { authorizationUrl: "", tokenUrl: "", enterpriseManaged: false, + requestRefreshToken: true, }); }); diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx index 2de2e314..8c03176e 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx @@ -518,6 +518,8 @@ export function ServerSettingsForm({ tokenUrl: settings.oauthTokenUrl ?? "", enterpriseManaged: settings.enterpriseManaged ?? false, onInsufficientScope: settings.oauthOnInsufficientScope, + // Unset means the default, on — only an explicit opt-out is stored. + requestRefreshToken: settings.oauthRequestRefreshToken ?? true, }; } @@ -879,6 +881,17 @@ export function ServerSettingsForm({ ) : null } /> + + onOAuthChange({ + ...currentOAuth(), + requestRefreshToken: e.currentTarget.checked, + }) + } + /> Additional authorization parameters diff --git a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx index 9e00e644..d9933631 100644 --- a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx +++ b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx @@ -119,6 +119,43 @@ describe("ServerSettingsModal", () => { ); }); + // #2068 — only the opt-out is persisted; re-checking the box clears the field + // rather than writing `true`, so a default-on server keeps a minimal entry. + it("maps the refresh-token opt-out into settings, and back to unset", async () => { + const user = userEvent.setup(); + const onSettingsChange = vi.fn(); + const { rerender } = renderWithMantine( + , + ); + await user.click(screen.getByRole("button", { name: /OAuth Settings/i })); + await user.click(screen.getByLabelText("Request refresh token")); + expect(onSettingsChange).toHaveBeenLastCalledWith( + expect.objectContaining({ oauthRequestRefreshToken: false }), + ); + + rerender( + , + ); + await user.click(screen.getByLabelText("Request refresh token")); + expect(onSettingsChange).toHaveBeenLastCalledWith( + expect.objectContaining({ oauthRequestRefreshToken: undefined }), + ); + }); + it("maps the selected protocol era into settings (#1626)", async () => { const user = userEvent.setup(); const onSettingsChange = vi.fn(); diff --git a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx index fbdfa7ee..ef6394ab 100644 --- a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx +++ b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx @@ -187,6 +187,10 @@ export function ServerSettingsModal({ // the SDK's `reauthorize` behavior without writing a spurious field. oauthOnInsufficientScope: oauth.onInsufficientScope === "throw" ? "throw" : undefined, + // #2068: persist only the non-default (off). `undefined` means on, so a + // server that never touched the switch writes no field at all. + oauthRequestRefreshToken: + oauth.requestRefreshToken === false ? false : undefined, }); } diff --git a/clients/web/src/test/core/auth/providers.test.ts b/clients/web/src/test/core/auth/providers.test.ts index 67b2f9c7..1a4f68b8 100644 --- a/clients/web/src/test/core/auth/providers.test.ts +++ b/clients/web/src/test/core/auth/providers.test.ts @@ -464,6 +464,48 @@ describe("OAuthNavigation", () => { expect(provider.clientMetadata.application_type).toBe("native"); }); + describe("requestRefreshToken (#2068)", () => { + function makeProviderWithRefresh( + requestRefreshToken: boolean | undefined, + ): BaseOAuthClientProvider { + return new BaseOAuthClientProvider(SERVER, { + storage: makeStorage(), + redirectUrlProvider: new MutableRedirectUrlProvider(), + navigation: new CallbackNavigation(vi.fn()), + requestRefreshToken, + }); + } + + it("declares the refresh_token grant by default", () => { + expect(makeProvider(makeStorage()).clientMetadata.grant_types).toEqual([ + "authorization_code", + "refresh_token", + ]); + }); + + it("declares the refresh_token grant when explicitly enabled", () => { + expect( + makeProviderWithRefresh(true).clientMetadata.grant_types, + ).toEqual(["authorization_code", "refresh_token"]); + }); + + it("treats an omitted setting as enabled", () => { + expect( + makeProviderWithRefresh(undefined).clientMetadata.grant_types, + ).toEqual(["authorization_code", "refresh_token"]); + }); + + // The point of the opt-out: the SDK's determineScope() only appends + // `offline_access` when the client metadata declares `refresh_token`, and + // startAuthorization() only forces `prompt=consent` when `offline_access` + // is in scope. Dropping the grant is what breaks that chain. + it("drops the refresh_token grant when disabled", () => { + expect( + makeProviderWithRefresh(false).clientMetadata.grant_types, + ).toEqual(["authorization_code"]); + }); + }); + describe("SEP-2352 issuer threading", () => { it("forwards ctx.issuer to storage on clientInformation/tokens reads", async () => { const storage = makeStorage(); diff --git a/clients/web/src/test/core/client/runner.test.ts b/clients/web/src/test/core/client/runner.test.ts index 0c8660a4..de5c080a 100644 --- a/clients/web/src/test/core/client/runner.test.ts +++ b/clients/web/src/test/core/client/runner.test.ts @@ -86,6 +86,34 @@ describe("runner client auth options", () => { expect(opts.oauth?.clientId).toBe("resource-client"); }); + // #2068 — the refresh-token opt-out is a per-server setting, so the CLI/TUI + // leg honors it exactly as the web leg does. + it("buildRunnerClientAuthOptions forwards the refresh-token opt-out", () => { + const base: InspectorServerSettings = { + requestTimeout: 0, + connectionTimeout: 0, + taskTtl: 60000, + maxFetchRequests: 10, + metadata: {}, + headers: [], + env: [], + roots: [], + }; + expect( + buildRunnerClientAuthOptions( + {}, + { + ...base, + oauthRequestRefreshToken: false, + }, + ).oauth?.requestRefreshToken, + ).toBe(false); + // On (the default) forwards nothing, so the provider keeps its own default. + expect( + buildRunnerClientAuthOptions({}, base).oauth?.requestRefreshToken, + ).toBeUndefined(); + }); + // #2018 — the CLI/TUI leg picks the authorization parameters up from the same // per-server settings the web leg reads, so all three clients agree. it("buildRunnerClientAuthOptions forwards custom authorization params", () => { diff --git a/clients/web/src/test/core/mcp/serverList.test.ts b/clients/web/src/test/core/mcp/serverList.test.ts index 1a0f79cc..de7b53e0 100644 --- a/clients/web/src/test/core/mcp/serverList.test.ts +++ b/clients/web/src/test/core/mcp/serverList.test.ts @@ -1193,6 +1193,68 @@ describe("enterpriseManaged oauth settings", () => { }); }); +describe("oauth.requestRefreshToken (#2068)", () => { + const baseSettings = { + headers: [], + env: [], + metadata: {}, + connectionTimeout: 0, + requestTimeout: 0, + taskTtl: 60000, + maxFetchRequests: 1000, + roots: [], + }; + + it("lifts an explicit false to settings.oauthRequestRefreshToken", () => { + const settings = storedFieldsToInspectorSettings({ + oauth: { clientId: "cid", requestRefreshToken: false }, + }); + expect(settings?.oauthRequestRefreshToken).toBe(false); + }); + + it("leaves the setting unset when the field is absent (default: on)", () => { + const settings = storedFieldsToInspectorSettings({ + oauth: { clientId: "cid" }, + }); + expect(settings?.oauthRequestRefreshToken).toBeUndefined(); + }); + + it("leaves the setting unset for an explicit true, which means the default", () => { + const settings = storedFieldsToInspectorSettings({ + oauth: { clientId: "cid", requestRefreshToken: true }, + }); + expect(settings?.oauthRequestRefreshToken).toBeUndefined(); + }); + + it("persists the opt-out under oauth on disk", () => { + const stored = inspectorSettingsToStoredFields({ + ...baseSettings, + oauthRequestRefreshToken: false, + }); + expect(stored.oauth?.requestRefreshToken).toBe(false); + }); + + it("writes nothing when the setting is on", () => { + expect( + inspectorSettingsToStoredFields({ + ...baseSettings, + oauthRequestRefreshToken: true, + }).oauth, + ).toBeUndefined(); + expect(inspectorSettingsToStoredFields(baseSettings).oauth).toBeUndefined(); + }); + + it("round-trips the opt-out", () => { + const stored = inspectorSettingsToStoredFields({ + ...baseSettings, + oauthRequestRefreshToken: false, + }); + expect( + storedFieldsToInspectorSettings(stored)?.oauthRequestRefreshToken, + ).toBe(false); + }); +}); + describe("oauthOnInsufficientScope (SEP-2350)", () => { const baseSettings = { headers: [], diff --git a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts index 56eb46f3..168c5cc1 100644 --- a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts +++ b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts @@ -631,6 +631,14 @@ describe("server.ts supplemental coverage", () => { expect((await res.json()).error).toMatch(/enterpriseManaged/); }); + it("rejects a non-boolean oauthRequestRefreshToken (#2068)", async () => { + const res = await postSettings({ + ...base, + oauthRequestRefreshToken: "no", + }); + expect((await res.json()).error).toMatch(/oauthRequestRefreshToken/); + }); + it("rejects malformed roots", async () => { const res = await postSettings({ ...base, roots: [{ uri: 1 }] }); expect((await res.json()).error).toMatch(/roots/); @@ -682,6 +690,7 @@ describe("server.ts supplemental coverage", () => { oauthClientId: "cid", oauthScopes: "a b", enterpriseManaged: true, + oauthRequestRefreshToken: false, roots: [{ uri: "file:///x", name: "x" }], }); expect(res.status).toBe(200); @@ -824,6 +833,57 @@ describe("server.ts supplemental coverage", () => { } }); + // #2068 — same all-or-nothing rule for the refresh-token opt-out: a + // non-boolean drops the whole `oauth` node rather than reaching the + // provider, where only an explicit `false` means anything. + it("drops oauth whose requestRefreshToken is not a boolean", async () => { + const h = await start({ + seedConfig: JSON.stringify({ + mcpServers: { + srv: { + type: "streamable-http", + url: "https://x.test/mcp", + oauth: { requestRefreshToken: "no" }, + }, + }, + }), + }); + try { + const res = await fetch(`${h.baseUrl}/api/servers`); + const body = (await res.json()) as { + mcpServers: Record>; + }; + expect(body.mcpServers.srv).not.toHaveProperty("oauth"); + } finally { + await stop(h); + } + }); + + it("keeps a well-formed requestRefreshToken opt-out on read (#2068)", async () => { + const h = await start({ + seedConfig: JSON.stringify({ + mcpServers: { + srv: { + type: "streamable-http", + url: "https://x.test/mcp", + oauth: { requestRefreshToken: false }, + }, + }, + }), + }); + try { + const res = await fetch(`${h.baseUrl}/api/servers`); + const body = (await res.json()) as { + mcpServers: Record>; + }; + expect(body.mcpServers.srv?.oauth).toEqual({ + requestRefreshToken: false, + }); + } finally { + await stop(h); + } + }); + it("keeps a well-formed authorizationParams record on read", async () => { const h = await start({ seedConfig: JSON.stringify({ diff --git a/core/auth/providers.ts b/core/auth/providers.ts index 0c492064..2c959e81 100644 --- a/core/auth/providers.ts +++ b/core/auth/providers.ts @@ -105,6 +105,21 @@ export type OAuthProviderConfig = { * unaffected. */ authorizationParams?: Record; + /** + * Whether to declare the `refresh_token` grant in the registered client + * metadata (#2068). Defaults to `true`; set `false` to register (and + * authorize) as an authorization-code-only client. + * + * This is not merely cosmetic. The SDK's `determineScope()` appends + * `offline_access` to the effective scope whenever the authorization server + * advertises it **and** the client metadata declares `refresh_token`, and + * `startAuthorization()` then appends `prompt=consent` whenever + * `offline_access` is in scope. Against Microsoft Entra ID, that forced + * consent prompt routes a non-admin user into the admin-consent workflow and + * fails with `AADSTS90094` even after a tenant admin has consented. Dropping + * the grant here breaks that chain at its source. + */ + requestRefreshToken?: boolean; }; /** @@ -126,6 +141,8 @@ export class BaseOAuthClientProvider implements OAuthClientProvider { public clientMetadataUrl?: string; /** Custom authorization-request parameters (#2018). Authorize URL only. */ protected authorizationParams?: Record; + /** Declare the `refresh_token` grant in {@link clientMetadata} (#2068). */ + protected requestRefreshToken: boolean; constructor(serverUrl: string, oauthConfig: OAuthProviderConfig) { this.serverUrl = serverUrl; @@ -134,6 +151,7 @@ export class BaseOAuthClientProvider implements OAuthClientProvider { this.navigation = oauthConfig.navigation; this.clientMetadataUrl = oauthConfig.clientMetadataUrl; this.authorizationParams = oauthConfig.authorizationParams; + this.requestRefreshToken = oauthConfig.requestRefreshToken ?? true; } /** @@ -186,7 +204,13 @@ export class BaseOAuthClientProvider implements OAuthClientProvider { const metadata: OAuthClientMetadata = { redirect_uris: this.redirect_uris, token_endpoint_auth_method: "none", - grant_types: ["authorization_code", "refresh_token"], + // #2068: `refresh_token` is declared by default, and dropped when the + // server's "Request refresh token" setting is off — which also stops the + // SDK adding `offline_access` (and therefore `prompt=consent`) to the + // authorization request. See `OAuthProviderConfig.requestRefreshToken`. + grant_types: this.requestRefreshToken + ? ["authorization_code", "refresh_token"] + : ["authorization_code"], response_types: ["code"], client_name: "MCP Inspector", client_uri: "https://github.com/modelcontextprotocol/inspector", diff --git a/core/client/runner.ts b/core/client/runner.ts index a77db074..411ef22d 100644 --- a/core/client/runner.ts +++ b/core/client/runner.ts @@ -87,7 +87,8 @@ export function buildRunnerClientAuthOptions( savedSettings.oauthScopes || serverAuthorizationParams || serverEndpointOverrides || - savedSettings.enterpriseManaged) + savedSettings.enterpriseManaged || + savedSettings.oauthRequestRefreshToken === false) ? { ...(savedSettings.oauthClientId && { clientId: savedSettings.oauthClientId, @@ -105,6 +106,11 @@ export function buildRunnerClientAuthOptions( ...(savedSettings.enterpriseManaged && { enterpriseManaged: true, }), + // #2068: only the explicit opt-out is forwarded; omitting the key + // leaves the provider's default (declare `refresh_token`) in place. + ...(savedSettings.oauthRequestRefreshToken === false && { + requestRefreshToken: false, + }), } : undefined; diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 542ac8ae..ea0e8bce 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -6330,6 +6330,8 @@ export class InspectorClient extends InspectorClientEventTarget { /** Endpoint overrides applied to the discovered AS metadata (#1906). */ authorizationUrl?: string; tokenUrl?: string; + /** Declare the `refresh_token` grant in client metadata (#2068). */ + requestRefreshToken?: boolean; }): void { if (!this.oauthManager) { throw new Error( diff --git a/core/mcp/oauthManager.ts b/core/mcp/oauthManager.ts index 732f03d1..711b7e3f 100644 --- a/core/mcp/oauthManager.ts +++ b/core/mcp/oauthManager.ts @@ -98,6 +98,8 @@ export class OAuthManager { authorizationParams?: Record; authorizationUrl?: string; tokenUrl?: string; + /** Declare the `refresh_token` grant in client metadata (#2068). */ + requestRefreshToken?: boolean; }): void { this.oauthConfig = { ...this.oauthConfig, @@ -169,6 +171,10 @@ export class OAuthManager { // read from mcp.json, and nothing in the flow needs to persist or union // them. authorizationParams: this.oauthConfig.authorizationParams, + // #2068: per-server opt-out of the `refresh_token` grant. Like + // `authorizationParams` this is pure config off mcp.json, so it lives on + // the provider rather than in OAuth storage. + requestRefreshToken: this.oauthConfig.requestRefreshToken, }); provider.setEventTarget(this.params.getEventTarget()); diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index 75d8d9c4..2c6d4fa3 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -1391,6 +1391,7 @@ export function createRemoteApp( authorizationUrl?: string; tokenUrl?: string; enterpriseManaged?: boolean; + requestRefreshToken?: boolean; } => { if (v === null || typeof v !== "object" || Array.isArray(v)) return false; const o = v as Record; @@ -1418,6 +1419,14 @@ export function createRemoteApp( ) { return false; } + // #2068 — shape only; the read side keeps just an explicit `false`, so a + // stray `true` from a hand-edited file reads back as the default anyway. + if ( + o.requestRefreshToken !== undefined && + typeof o.requestRefreshToken !== "boolean" + ) { + return false; + } return true; }; // A roots array: each entry must have a string `uri` and, when present, a @@ -1531,7 +1540,7 @@ export function createRemoteApp( if ("oauth" in valObj && !isOauthObject(valObj.oauth)) { logWarn( { route: "/api/servers", id, droppedKey: "oauth" }, - "Dropping malformed `oauth` field — expected `{ clientId?, clientSecret?, scopes?, authorizationParams?, authorizationUrl?, tokenUrl?, enterpriseManaged?, onInsufficientScope? }`.", + "Dropping malformed `oauth` field — expected `{ clientId?, clientSecret?, scopes?, authorizationParams?, authorizationUrl?, tokenUrl?, enterpriseManaged?, onInsufficientScope?, requestRefreshToken? }`.", ); delete valObj.oauth; } @@ -1832,6 +1841,15 @@ export function createRemoteApp( error: "settings.enterpriseManaged must be a boolean", }; } + if ( + obj.oauthRequestRefreshToken !== undefined && + typeof obj.oauthRequestRefreshToken !== "boolean" + ) { + return { + ok: false, + error: "settings.oauthRequestRefreshToken must be a boolean", + }; + } if ( obj.oauthOnInsufficientScope !== undefined && obj.oauthOnInsufficientScope !== "reauthorize" && @@ -1959,6 +1977,11 @@ export function createRemoteApp( if (obj.enterpriseManaged === true) { value.enterpriseManaged = true; } + // #2068: the default is on, so only the explicit opt-out is carried; a + // `true` on the wire reads back as unset, which means the same thing. + if (obj.oauthRequestRefreshToken === false) { + value.oauthRequestRefreshToken = false; + } if ( obj.oauthOnInsufficientScope === "reauthorize" || obj.oauthOnInsufficientScope === "throw" diff --git a/core/mcp/serverList.ts b/core/mcp/serverList.ts index e30a3efd..00da60b9 100644 --- a/core/mcp/serverList.ts +++ b/core/mcp/serverList.ts @@ -555,6 +555,12 @@ export function storedFieldsToInspectorSettings( if (stored.oauth?.enterpriseManaged === true) { settings.enterpriseManaged = true; } + // Inverted from the flags above: the default is *on*, so only an explicit + // `false` is carried through. Anything else on disk (absent, or a + // hand-edited non-boolean) reads back as unset, i.e. the default. (#2068) + if (stored.oauth?.requestRefreshToken === false) { + settings.oauthRequestRefreshToken = false; + } // Mirror the stdio working directory for the form. Like the OAuth fields, an // empty string coerces to absent so the form's "(inherit)" placeholder shows. if (stored.cwd) settings.cwd = stored.cwd; @@ -682,6 +688,12 @@ export function inspectorSettingsToStoredFields( if (settings.enterpriseManaged === true) { oauthFields.enterpriseManaged = true; } + // Only the non-default (off) is written; an absent field reads back as the + // default (on), so writing `true` would inject the key into files that never + // set it and break byte-stable round-trips. (#2068) + if (settings.oauthRequestRefreshToken === false) { + oauthFields.requestRefreshToken = false; + } if (Object.keys(oauthFields).length > 0) { out.oauth = oauthFields; } diff --git a/core/mcp/types.ts b/core/mcp/types.ts index 64f35cc4..b4688385 100644 --- a/core/mcp/types.ts +++ b/core/mcp/types.ts @@ -180,6 +180,15 @@ export type StoredMCPServer = MCPServerConfig & { enterpriseManaged?: boolean; /** SEP-2350 step-up policy for `403 insufficient_scope` (default `reauthorize`). */ onInsufficientScope?: OnInsufficientScopePolicy; + /** + * Whether the Inspector declares the `refresh_token` grant when it + * registers. Defaults to `true`; only `false` is written to disk, so an + * entry that never touched the setting keeps a minimal diff. Turning it off + * also stops the SDK adding `offline_access` to the requested scope and + * `prompt=consent` to the authorization request. Inspector-specific. + * (#2068) + */ + requestRefreshToken?: boolean; /** * Custom query parameters appended to the OAuth **authorization request** * (never the token request) — e.g. Keycloak's `kc_idp_hint`, OIDC's @@ -588,6 +597,11 @@ export interface OAuthSettings { tokenUrl?: string; enterpriseManaged?: boolean; onInsufficientScope?: OnInsufficientScopePolicy; + /** + * Whether to declare the `refresh_token` grant (#2068). Optional for the same + * reason as `authorizationParams`; `undefined` means the default, on. + */ + requestRefreshToken?: boolean; } /** @@ -773,6 +787,14 @@ export interface InspectorServerSettings { * server's HTTP transport. Defaults to `reauthorize` when unset. */ oauthOnInsufficientScope?: OnInsufficientScopePolicy; + /** + * Whether the Inspector declares the `refresh_token` grant in its OAuth + * client metadata for this server. `undefined` (the default) means on; + * persisted as `oauth.requestRefreshToken` only when explicitly off. Turning + * it off also drops the SDK's automatic `offline_access` scope and the + * `prompt=consent` it forces — the Entra admin-consent failure in #2068. + */ + oauthRequestRefreshToken?: boolean; /** * When true, connect via the configured enterprise IdP (EMA) instead of * interactive OAuth to the MCP authorization server. Per-server OAuth @@ -1160,6 +1182,11 @@ export interface InspectorClientOptions { */ authorizationUrl?: string; tokenUrl?: string; + /** + * Declare the `refresh_token` grant in the registered client metadata + * (#2068). Defaults to `true` when omitted. + */ + requestRefreshToken?: boolean; }; /** diff --git a/docs/mcp-server-configuration.md b/docs/mcp-server-configuration.md index b394862a..fd347ada 100644 --- a/docs/mcp-server-configuration.md +++ b/docs/mcp-server-configuration.md @@ -175,7 +175,7 @@ These have no analog in the broader `mcp.json` ecosystem. Each is **omitted on w | `paginatedLists` | `false` | Fetch tools/resources/prompts one page at a time instead of auto-aggregating | | `advertisedExtensions` | — | Per-extension overrides for what the Inspector declares in `capabilities.extensions` | | `maxFetchRequests` | `1000` | Network-log retention for this server (`DEFAULT_MAX_FETCH_REQUESTS`); `0` means unlimited | -| `oauth` | — | `{ clientId, clientSecret, scopes, authorizationParams, authorizationUrl, tokenUrl, enterpriseManaged, onInsufficientScope }` | +| `oauth` | — | `{ clientId, clientSecret, scopes, requestRefreshToken, authorizationParams, authorizationUrl, tokenUrl, enterpriseManaged, onInsufficientScope }` | `metadata` is a JSON **object**, and its values may be any JSON — an object, an array, a number, a boolean, `null` — not only a string. The MCP spec does not restrict `_meta` value types in general, and the SDK models the field as a passthrough object, so the Inspector does not narrow it either ([#1910](https://github.com/modelcontextprotocol/inspector/issues/1910)). Edit it in Server Settings → Request Metadata, which is a JSON editor rather than the key/value rows `headers` and `env` use; text that is not a JSON object is flagged inline and not applied. @@ -193,6 +193,10 @@ These have no analog in the broader `mcp.json` ecosystem. Each is **omitted on w > **Reading an older file.** Before #1910 this field was a `[{ "key": …, "value": … }]` pair array. That shape is still **read**, so an existing `mcp.json` keeps working unchanged; it is never written back, so the field is rewritten as an object the next time the Inspector saves the entry. +`oauth.requestRefreshToken` (default `true`) controls whether the Inspector declares the `refresh_token` grant when it registers its OAuth client. Set it to `false` — or uncheck **Request refresh token** in Server Settings → Authorization — when that grant costs you more than it buys. Declaring it makes the SDK append `offline_access` to the requested scope whenever the authorization server advertises that scope, and appending `offline_access` in turn makes the SDK append `prompt=consent` to the authorization request. On Microsoft Entra ID, a forced `prompt=consent` from a non-admin user, for an app whose permissions require admin consent, is routed into the admin-consent workflow and rejected with `AADSTS90094` — even after a tenant admin has already granted consent ([#2068](https://github.com/modelcontextprotocol/inspector/issues/2068)). Turning the setting off breaks that chain at its source: no grant, no `offline_access`, no forced prompt. The cost is that an expired access token needs a fresh interactive sign-in rather than a silent refresh. Only `false` is written to disk, so a server that never touched the setting keeps a minimal entry. + +> Turning it off changes the **next** authorization request immediately, because the SDK reads the grant list off the Inspector's live client metadata rather than off whatever was registered earlier. The registration held at the authorization server is not rewritten, though — if you also want the AS to stop listing the grant, clear the stored OAuth state for the server (Server Settings → Authorization → **Clear stored OAuth state**) so the next connect registers afresh. + `oauth.authorizationParams` is a string→string record of extra query parameters merged into the OAuth **authorization request** URL only — never the token request. Use it for provider-specific hints the core specs don't standardize (Keycloak's `kc_idp_hint`, OIDC's `login_hint` / `prompt` / `acr_values`, Auth0's `audience`). The protocol-critical parameters — `client_id`, `code_challenge`, `code_challenge_method`, `redirect_uri`, `resource`, `response_type`, `scope`, `state` — are **reserved**: the web form rejects them inline, and any that reach the merge anyway are dropped with a warning rather than overriding what the flow set (overriding them breaks PKCE, the CSRF state binding, or RFC 8707). Edit them in Server Settings → Authorization ("Additional authorization parameters"), beside Scopes. `oauth.authorizationUrl` and `oauth.tokenUrl` override the `authorization_endpoint` and `token_endpoint` that authorization-server metadata discovery resolved. The Inspector deliberately has no such fields by default — it resolves both from the AS's metadata document, exactly as a real MCP host does — but a server under development often advertises its _production_ authorization server while you want to hit staging. Set either (they are independent) to an absolute `http(s)` URL and it replaces what the metadata returned, for both the authorization request and the token request; leave blank to use discovery. A malformed value is flagged inline in the form and dropped with a warning at connect time rather than failing the connection. Edit them in Server Settings → Authorization ("Authorization URL override" / "Token URL override").