From bf33868b08f73307da70e0746233ecf20a49930f Mon Sep 17 00:00:00 2001 From: Gavin Williams Date: Tue, 9 Jun 2026 14:02:27 +0100 Subject: [PATCH 01/11] fix(web): always request `offline_access` for MCP `refresh_token` grant Atlassian (and other providers) only honour the `refresh_token` grant when `offline_access` is included in the authorization scope. The client was already declaring `refresh_token` in `clientMetadata.grant_types` but never injecting `offline_access` into `requestedOAuthScopes`, so the /authorize request was incomplete and Atlassian rejected it. - `PrismaOAuthClientProvider` now appends `OFFLINE_ACCESS_SCOPE` before normalization so it appears in both `clientMetadata.scope` and the /authorize request. Injection is unconditional (matching the existing behaviour of always declaring `refresh_token`); a comment explains the tradeoff vs checking `oauthScopesSupported`. - `buildMcpOAuthScopeEntries` defaults `offline_access` to enabled when present in available scopes so the admin UI reflects what will be sent. - New `oauthScopeUtils.test.ts` covers the default-enabled behaviour and general normalization/filtering helpers. - Updated `prismaOAuthClientProvider.test.ts` to assert `offline_access` is always present and that it is not duplicated when already supplied. - Added a note to the connectors doc explaining why `offline_access` is pre-ticked in the OAuth scopes UI. Co-Authored-By: Claude Sonnet 4.6 --- docs/docs/features/ask/connectors.mdx | 4 + .../features/chat/mcp/oauthScopeUtils.test.ts | 115 ++++++++++++++++++ .../ee/features/chat/mcp/oauthScopeUtils.ts | 8 +- .../mcp/prismaOAuthClientProvider.test.ts | 16 ++- .../chat/mcp/prismaOAuthClientProvider.ts | 13 +- 5 files changed, 149 insertions(+), 7 deletions(-) create mode 100644 packages/web/src/ee/features/chat/mcp/oauthScopeUtils.test.ts diff --git a/docs/docs/features/ask/connectors.mdx b/docs/docs/features/ask/connectors.mdx index 2d743519a..efe94309f 100644 --- a/docs/docs/features/ask/connectors.mdx +++ b/docs/docs/features/ask/connectors.mdx @@ -80,6 +80,10 @@ Owners can change connector scopes at any time from **Settings → Workspace → Changing connector scopes requires all users to re-authenticate with that connector. + +`offline_access` is enabled by default when offered by the connector. It is required for token refresh, so Sourcebot includes it regardless of whether an admin ticks the box. + + ## Tool Permissions Owners can configure how Ask Sourcebot may use each tool exposed by a connector. Changes take effect immediately and do not require users to re-authenticate. diff --git a/packages/web/src/ee/features/chat/mcp/oauthScopeUtils.test.ts b/packages/web/src/ee/features/chat/mcp/oauthScopeUtils.test.ts new file mode 100644 index 000000000..9cf423787 --- /dev/null +++ b/packages/web/src/ee/features/chat/mcp/oauthScopeUtils.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from 'vitest'; +import { + buildMcpOAuthScopeEntries, + normalizeMcpRequestedOAuthScopes, + getEnabledMcpOAuthScopeNames, +} from './oauthScopeUtils'; + +describe('normalizeMcpRequestedOAuthScopes', () => { + test('deduplicates, trims, and sorts scopes', () => { + expect(normalizeMcpRequestedOAuthScopes([' repo ', 'read:user', 'repo'])).toEqual([ + 'read:user', + 'repo', + ]); + }); + + test('returns an empty array for empty input', () => { + expect(normalizeMcpRequestedOAuthScopes([])).toEqual([]); + }); + + test('filters blank strings', () => { + expect(normalizeMcpRequestedOAuthScopes(['', ' ', 'read'])).toEqual(['read']); + }); +}); + +describe('buildMcpOAuthScopeEntries', () => { + test('enables scopes that are in requestedOAuthScopes', () => { + const entries = buildMcpOAuthScopeEntries({ + availableOAuthScopes: ['read', 'write'], + requestedOAuthScopes: ['read'], + }); + + expect(entries).toEqual([ + { scope: 'read', enabled: true }, + { scope: 'write', enabled: false }, + ]); + }); + + test('enables offline_access by default when present in available scopes', () => { + const entries = buildMcpOAuthScopeEntries({ + availableOAuthScopes: ['offline_access', 'read'], + requestedOAuthScopes: ['read'], + }); + + expect(entries).toEqual([ + { scope: 'offline_access', enabled: true }, + { scope: 'read', enabled: true }, + ]); + }); + + test('enables offline_access by default even when not in requestedOAuthScopes', () => { + const entries = buildMcpOAuthScopeEntries({ + availableOAuthScopes: ['offline_access', 'read', 'write'], + requestedOAuthScopes: [], + }); + + expect(entries).toEqual([ + { scope: 'offline_access', enabled: true }, + { scope: 'read', enabled: false }, + { scope: 'write', enabled: false }, + ]); + }); + + test('does not add offline_access when it is absent from available scopes', () => { + const entries = buildMcpOAuthScopeEntries({ + availableOAuthScopes: ['read', 'write'], + requestedOAuthScopes: [], + }); + + expect(entries.find((e) => e.scope === 'offline_access')).toBeUndefined(); + }); + + test('merges requested scopes not in available scopes into the output', () => { + const entries = buildMcpOAuthScopeEntries({ + availableOAuthScopes: ['read'], + requestedOAuthScopes: ['write'], + }); + + expect(entries).toEqual([ + { scope: 'read', enabled: false }, + { scope: 'write', enabled: true }, + ]); + }); + + test('returns sorted, deduplicated entries', () => { + const entries = buildMcpOAuthScopeEntries({ + availableOAuthScopes: ['write', 'read', 'write'], + requestedOAuthScopes: ['write', 'write'], + }); + + expect(entries).toEqual([ + { scope: 'read', enabled: false }, + { scope: 'write', enabled: true }, + ]); + }); +}); + +describe('getEnabledMcpOAuthScopeNames', () => { + test('returns only enabled scopes, sorted and deduplicated', () => { + const scopes = getEnabledMcpOAuthScopeNames([ + { scope: 'write', enabled: false }, + { scope: 'read', enabled: true }, + { scope: 'offline_access', enabled: true }, + ]); + + expect(scopes).toEqual(['offline_access', 'read']); + }); + + test('returns an empty array when no scopes are enabled', () => { + const scopes = getEnabledMcpOAuthScopeNames([ + { scope: 'read', enabled: false }, + ]); + + expect(scopes).toEqual([]); + }); +}); diff --git a/packages/web/src/ee/features/chat/mcp/oauthScopeUtils.ts b/packages/web/src/ee/features/chat/mcp/oauthScopeUtils.ts index ccf099a17..d0bad02ff 100644 --- a/packages/web/src/ee/features/chat/mcp/oauthScopeUtils.ts +++ b/packages/web/src/ee/features/chat/mcp/oauthScopeUtils.ts @@ -2,6 +2,10 @@ import type { McpServerOAuthScopeEntry } from './types'; export const OAUTH_SCOPE_TOKEN_REGEX = /^[\x21\x23-\x5B\x5D-\x7E]+$/; +// Required for the refresh_token grant that all clients declare. Providers such as +// Atlassian only honour that grant when this scope is included in the authorization request. +export const OFFLINE_ACCESS_SCOPE = 'offline_access'; + export function normalizeMcpRequestedOAuthScopes(oauthScopes: string[]): string[] { return [...new Set(oauthScopes.map((scope) => scope.trim()).filter(Boolean))] .sort(); @@ -50,7 +54,9 @@ export function buildMcpOAuthScopeEntries({ return normalizedAvailableOAuthScopes.map((scope) => ({ scope, - enabled: requestedScopeSet.has(scope), + // offline_access is enabled by default because all clients declare the refresh_token + // grant; an admin who leaves it unticked would produce a broken authorization request. + enabled: scope === OFFLINE_ACCESS_SCOPE || requestedScopeSet.has(scope), })); } diff --git a/packages/web/src/ee/features/chat/mcp/prismaOAuthClientProvider.test.ts b/packages/web/src/ee/features/chat/mcp/prismaOAuthClientProvider.test.ts index 5123d0941..735fc2327 100644 --- a/packages/web/src/ee/features/chat/mcp/prismaOAuthClientProvider.test.ts +++ b/packages/web/src/ee/features/chat/mcp/prismaOAuthClientProvider.test.ts @@ -79,18 +79,26 @@ describe('PrismaOAuthClientProvider modes', () => { }); describe('PrismaOAuthClientProvider client metadata', () => { - test('omits scope when no scopes were requested', () => { + test('always includes offline_access even when no scopes were requested', () => { const provider = createProvider(); - expect(provider.clientMetadata.scope).toBeUndefined(); + expect(provider.clientMetadata.scope).toBe('offline_access'); }); - test('includes normalized requested scopes', () => { + test('includes offline_access alongside normalized requested scopes', () => { const provider = createProvider(createPrismaMock(), { requestedOAuthScopes: [' repo ', 'read:user', 'repo'], }); - expect(provider.clientMetadata.scope).toBe('read:user repo'); + expect(provider.clientMetadata.scope).toBe('offline_access read:user repo'); + }); + + test('does not duplicate offline_access when already present in requested scopes', () => { + const provider = createProvider(createPrismaMock(), { + requestedOAuthScopes: ['offline_access', 'read:user'], + }); + + expect(provider.clientMetadata.scope).toBe('offline_access read:user'); }); }); diff --git a/packages/web/src/ee/features/chat/mcp/prismaOAuthClientProvider.ts b/packages/web/src/ee/features/chat/mcp/prismaOAuthClientProvider.ts index e0100dd76..245135462 100644 --- a/packages/web/src/ee/features/chat/mcp/prismaOAuthClientProvider.ts +++ b/packages/web/src/ee/features/chat/mcp/prismaOAuthClientProvider.ts @@ -9,7 +9,7 @@ import { McpServerClientInfoSource, type PrismaClient } from '@sourcebot/db'; import { encryptOAuthToken, decryptOAuthToken, createLogger } from '@sourcebot/shared'; import { __unsafePrisma } from '@/prisma'; import { createMcpOAuthState } from './mcpOAuthReturnTo'; -import { normalizeMcpRequestedOAuthScopes } from './oauthScopeUtils'; +import { normalizeMcpRequestedOAuthScopes, OFFLINE_ACCESS_SCOPE } from './oauthScopeUtils'; type McpOAuthPrismaClient = Pick; const logger = createLogger('mcp-oauth-client-provider'); @@ -113,7 +113,16 @@ export class PrismaOAuthClientProvider implements OAuthClientProvider { this.userId = userId; this.callbackUrl = callbackUrl; this.callbackReturnTo = callbackReturnTo; - this.requestedOAuthScopes = normalizeMcpRequestedOAuthScopes(requestedOAuthScopes); + // offline_access is always injected because every client declares the refresh_token grant + // and providers such as Atlassian reject /authorize when the grant is declared but + // offline_access is absent. We inject unconditionally rather than checking the provider's + // advertised scopes because oauthScopesSupported is not plumbed through to this constructor; + // the tradeoff (a benign unknown-scope rejection on strict providers) is the same as the + // existing behaviour of always declaring refresh_token. + this.requestedOAuthScopes = normalizeMcpRequestedOAuthScopes([ + ...requestedOAuthScopes, + OFFLINE_ACCESS_SCOPE, + ]); if (allowClientRegistration) { this.saveClientInformation = async (info: OAuthClientInformation) => { From c42d19fb1d7d4e841448c50ffceb79f3db88f0b5 Mon Sep 17 00:00:00 2001 From: Gavin Williams Date: Tue, 9 Jun 2026 14:03:49 +0100 Subject: [PATCH 02/11] chore: add `CHANGELOG.md` entry for `offline_access` MCP fix [#1292] Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fadcf22d..34b02a760 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed issue where using multiple identity providers of the same type (e.g., gitlab) would result in unexpected behaviours. [#1177](https://github.com/sourcebot-dev/sourcebot/pull/1177) - Fixed a race condition where large repositories could be indexed twice within a single reindex interval. [#1298](https://github.com/sourcebot-dev/sourcebot/pull/1298) - Upgraded `shell-quote` to `^1.8.4`. [#1299](https://github.com/sourcebot-dev/sourcebot/pull/1299) +- [EE] Fixed MCP OAuth connectors (e.g. Atlassian) rejecting the authorization request when `offline_access` was not explicitly enabled by an admin. `offline_access` is now always included in the requested scope and is enabled by default in the admin UI. [#1292](https://github.com/sourcebot-dev/sourcebot/pull/1292) ## [5.0.1] - 2026-06-04 From 6d5dd9f47f2d3840c987669b45b69644d192c041 Mon Sep 17 00:00:00 2001 From: Gavin Williams Date: Tue, 9 Jun 2026 14:46:28 +0100 Subject: [PATCH 03/11] test(web): update `connect` and `callback` route tests for `offline_access` injection Both mocks asserted `clientMetadata.scope === 'repo'`; the scope is now `'offline_access repo'` after the fix in the previous commit. Co-Authored-By: Claude Sonnet 4.6 --- .../web/src/app/api/(server)/ee/askmcp/callback/route.test.ts | 2 +- .../web/src/app/api/(server)/ee/askmcp/connect/route.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/web/src/app/api/(server)/ee/askmcp/callback/route.test.ts b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.test.ts index 895b94d1b..094ebc710 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/callback/route.test.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.test.ts @@ -150,7 +150,7 @@ describe('GET /api/ee/askmcp/callback', () => { const state = createMcpOAuthState('state-1', '/settings/workspaceAskAgent'); mocks.mcpAuth.mockImplementation(async (provider) => { expect('saveClientInformation' in provider).toBe(false); - expect(provider.clientMetadata.scope).toBe('repo'); + expect(provider.clientMetadata.scope).toBe('offline_access repo'); await provider.invalidateCredentials('all'); const error = new Error('invalid_client client_secret=client-secret refresh_token=refresh-token'); Object.assign(error, { diff --git a/packages/web/src/app/api/(server)/ee/askmcp/connect/route.test.ts b/packages/web/src/app/api/(server)/ee/askmcp/connect/route.test.ts index c4798d5c6..27638fcc0 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/connect/route.test.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/connect/route.test.ts @@ -148,7 +148,7 @@ describe('POST /api/ee/askmcp/connect', () => { mocks.mcpAuth.mockImplementation(async (provider, options) => { expect('saveClientInformation' in provider).toBe(true); expect(provider.saveClientInformation).toEqual(expect.any(Function)); - expect(provider.clientMetadata.scope).toBe('repo'); + expect(provider.clientMetadata.scope).toBe('offline_access repo'); expect(options.fetchFn).toEqual(expect.any(Function)); await provider.saveClientInformation({ client_id: 'client-1' }); From b294cf0352457801e58afed6a85ef2ff1b4cf424 Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:00:53 -0700 Subject: [PATCH 04/11] clean up comments to be more concise --- .../web/src/ee/features/chat/mcp/oauthScopeUtils.ts | 3 +-- .../ee/features/chat/mcp/prismaOAuthClientProvider.ts | 10 ++++------ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/web/src/ee/features/chat/mcp/oauthScopeUtils.ts b/packages/web/src/ee/features/chat/mcp/oauthScopeUtils.ts index d0bad02ff..da6cd0a65 100644 --- a/packages/web/src/ee/features/chat/mcp/oauthScopeUtils.ts +++ b/packages/web/src/ee/features/chat/mcp/oauthScopeUtils.ts @@ -54,8 +54,7 @@ export function buildMcpOAuthScopeEntries({ return normalizedAvailableOAuthScopes.map((scope) => ({ scope, - // offline_access is enabled by default because all clients declare the refresh_token - // grant; an admin who leaves it unticked would produce a broken authorization request. + // Force-enabled regardless of admin selection — see OFFLINE_ACCESS_SCOPE. enabled: scope === OFFLINE_ACCESS_SCOPE || requestedScopeSet.has(scope), })); } diff --git a/packages/web/src/ee/features/chat/mcp/prismaOAuthClientProvider.ts b/packages/web/src/ee/features/chat/mcp/prismaOAuthClientProvider.ts index 245135462..bc5de3041 100644 --- a/packages/web/src/ee/features/chat/mcp/prismaOAuthClientProvider.ts +++ b/packages/web/src/ee/features/chat/mcp/prismaOAuthClientProvider.ts @@ -113,12 +113,10 @@ export class PrismaOAuthClientProvider implements OAuthClientProvider { this.userId = userId; this.callbackUrl = callbackUrl; this.callbackReturnTo = callbackReturnTo; - // offline_access is always injected because every client declares the refresh_token grant - // and providers such as Atlassian reject /authorize when the grant is declared but - // offline_access is absent. We inject unconditionally rather than checking the provider's - // advertised scopes because oauthScopesSupported is not plumbed through to this constructor; - // the tradeoff (a benign unknown-scope rejection on strict providers) is the same as the - // existing behaviour of always declaring refresh_token. + // Always inject offline_access (see OFFLINE_ACCESS_SCOPE). We do so unconditionally rather + // than checking the provider's advertised scopes because oauthScopesSupported is not plumbed + // through to this constructor; the tradeoff (a benign unknown-scope rejection on strict + // providers) is the same as the existing behaviour of always declaring refresh_token. this.requestedOAuthScopes = normalizeMcpRequestedOAuthScopes([ ...requestedOAuthScopes, OFFLINE_ACCESS_SCOPE, From 13e8df621c8db6e72cfaa6e1bcec4a69a5c7bce5 Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:04:47 -0700 Subject: [PATCH 05/11] clean up tests to keep only the higher value ones --- .../features/chat/mcp/oauthScopeUtils.test.ts | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/packages/web/src/ee/features/chat/mcp/oauthScopeUtils.test.ts b/packages/web/src/ee/features/chat/mcp/oauthScopeUtils.test.ts index 9cf423787..218bf5de3 100644 --- a/packages/web/src/ee/features/chat/mcp/oauthScopeUtils.test.ts +++ b/packages/web/src/ee/features/chat/mcp/oauthScopeUtils.test.ts @@ -13,10 +13,6 @@ describe('normalizeMcpRequestedOAuthScopes', () => { ]); }); - test('returns an empty array for empty input', () => { - expect(normalizeMcpRequestedOAuthScopes([])).toEqual([]); - }); - test('filters blank strings', () => { expect(normalizeMcpRequestedOAuthScopes(['', ' ', 'read'])).toEqual(['read']); }); @@ -35,18 +31,6 @@ describe('buildMcpOAuthScopeEntries', () => { ]); }); - test('enables offline_access by default when present in available scopes', () => { - const entries = buildMcpOAuthScopeEntries({ - availableOAuthScopes: ['offline_access', 'read'], - requestedOAuthScopes: ['read'], - }); - - expect(entries).toEqual([ - { scope: 'offline_access', enabled: true }, - { scope: 'read', enabled: true }, - ]); - }); - test('enables offline_access by default even when not in requestedOAuthScopes', () => { const entries = buildMcpOAuthScopeEntries({ availableOAuthScopes: ['offline_access', 'read', 'write'], @@ -104,12 +88,4 @@ describe('getEnabledMcpOAuthScopeNames', () => { expect(scopes).toEqual(['offline_access', 'read']); }); - - test('returns an empty array when no scopes are enabled', () => { - const scopes = getEnabledMcpOAuthScopeNames([ - { scope: 'read', enabled: false }, - ]); - - expect(scopes).toEqual([]); - }); }); From 9c50a7c5166213ce43e897c1a872dc575262dd8e Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:56:03 -0700 Subject: [PATCH 06/11] Change the approach to enable the scope by default if it is found during discovery. Still allow an admin to remove the scope if they want. --- CHANGELOG.md | 2 +- docs/docs/features/ask/connectors.mdx | 2 +- .../workspaceAskAgent/workspaceAskAgentPage.tsx | 13 ++++++++++--- .../(server)/ee/askmcp/callback/route.test.ts | 2 +- .../api/(server)/ee/askmcp/connect/route.test.ts | 2 +- .../ee/features/chat/mcp/oauthScopeUtils.test.ts | 16 +++------------- .../src/ee/features/chat/mcp/oauthScopeUtils.ts | 7 ++++--- .../chat/mcp/prismaOAuthClientProvider.test.ts | 16 ++++------------ .../chat/mcp/prismaOAuthClientProvider.ts | 11 ++--------- 9 files changed, 27 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34b02a760..d066ecbb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed issue where using multiple identity providers of the same type (e.g., gitlab) would result in unexpected behaviours. [#1177](https://github.com/sourcebot-dev/sourcebot/pull/1177) - Fixed a race condition where large repositories could be indexed twice within a single reindex interval. [#1298](https://github.com/sourcebot-dev/sourcebot/pull/1298) - Upgraded `shell-quote` to `^1.8.4`. [#1299](https://github.com/sourcebot-dev/sourcebot/pull/1299) -- [EE] Fixed MCP OAuth connectors (e.g. Atlassian) rejecting the authorization request when `offline_access` was not explicitly enabled by an admin. `offline_access` is now always included in the requested scope and is enabled by default in the admin UI. [#1292](https://github.com/sourcebot-dev/sourcebot/pull/1292) +- [EE] Fixed MCP OAuth connectors (e.g. Atlassian) rejecting the authorization request when `offline_access` was not explicitly enabled by an admin. The OAuth scopes dialog now pre-selects `offline_access` when the connector offers it; admins can still untick it to opt out of refresh tokens. [#1292](https://github.com/sourcebot-dev/sourcebot/pull/1292) ## [5.0.1] - 2026-06-04 diff --git a/docs/docs/features/ask/connectors.mdx b/docs/docs/features/ask/connectors.mdx index efe94309f..c1c755aba 100644 --- a/docs/docs/features/ask/connectors.mdx +++ b/docs/docs/features/ask/connectors.mdx @@ -81,7 +81,7 @@ Changing connector scopes requires all users to re-authenticate with that connec -`offline_access` is enabled by default when offered by the connector. It is required for token refresh, so Sourcebot includes it regardless of whether an admin ticks the box. +`offline_access` is pre-selected when offered by the connector because token refresh requires it. You can untick it to opt out of refresh tokens, but users will need to re-authenticate every time their access token expires. Some connectors (such as Atlassian) reject authorization entirely without it. ## Tool Permissions diff --git a/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx b/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx index 08879ee85..0db9fef73 100644 --- a/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx +++ b/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx @@ -27,7 +27,7 @@ import { ConnectMcpButton } from "@/ee/features/chat/mcp/components/connectMcpBu import { ConnectorCard } from "@/ee/features/chat/mcp/components/connectorCard"; import { useMcpToolMetadata } from "@/ee/features/chat/mcp/hooks/useMcpToolMetadata"; import { invalidateMcpConfigurationQueries, mcpQueryKeys } from "@/ee/features/chat/mcp/queryKeys"; -import { buildMcpOAuthScopeEntries, getMcpRequestedOAuthScopes, normalizeMcpRequestedOAuthScopes } from "@/ee/features/chat/mcp/oauthScopeUtils"; +import { buildMcpOAuthScopeEntries, getMcpRequestedOAuthScopes, normalizeMcpRequestedOAuthScopes, OFFLINE_ACCESS_SCOPE } from "@/ee/features/chat/mcp/oauthScopeUtils"; import { pluralize } from "@/features/chat/mcp/utils"; import { cn, isServiceError } from "@/lib/utils"; import { useQuery, useQueryClient } from "@tanstack/react-query"; @@ -440,6 +440,13 @@ export function WorkspaceAskAgentPage({ callbackStatus, callbackServer, callback setCustomOAuthScopeInput(""); }; + // Pre-select offline_access so admins can see the scope token refresh depends on; + // they can still untick it to opt out of refresh tokens. + const initializeOAuthScopeSelection = (discoveredOAuthScopes: string[]) => { + setSelectedOAuthScopes(discoveredOAuthScopes.includes(OFFLINE_ACCESS_SCOPE) ? [OFFLINE_ACCESS_SCOPE] : []); + setCustomOAuthScopeInput(""); + }; + const handleCloseClientCredentialsDialog = () => { setIsClientCredentialsDialogOpen(false); setPendingClientCredentialsServer(null); @@ -572,7 +579,7 @@ export function WorkspaceAskAgentPage({ callbackStatus, callbackServer, callback const discoveredOAuthScopes = normalizeMcpRequestedOAuthScopes(dcrSupport.oauthScopesSupported); if (dcrSupport.isKnown && !dcrSupport.supportsDcr) { - resetOAuthScopeInputs(); + initializeOAuthScopeSelection(discoveredOAuthScopes); setPendingClientCredentialsServer({ name: displayName, serverUrl: normalizedServerUrl, @@ -584,7 +591,7 @@ export function WorkspaceAskAgentPage({ callbackStatus, callbackServer, callback } if (discoveredOAuthScopes.length > 0) { - resetOAuthScopeInputs(); + initializeOAuthScopeSelection(discoveredOAuthScopes); setPendingOAuthScopeSelectionServer({ name: displayName, serverUrl: normalizedServerUrl, diff --git a/packages/web/src/app/api/(server)/ee/askmcp/callback/route.test.ts b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.test.ts index 094ebc710..895b94d1b 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/callback/route.test.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.test.ts @@ -150,7 +150,7 @@ describe('GET /api/ee/askmcp/callback', () => { const state = createMcpOAuthState('state-1', '/settings/workspaceAskAgent'); mocks.mcpAuth.mockImplementation(async (provider) => { expect('saveClientInformation' in provider).toBe(false); - expect(provider.clientMetadata.scope).toBe('offline_access repo'); + expect(provider.clientMetadata.scope).toBe('repo'); await provider.invalidateCredentials('all'); const error = new Error('invalid_client client_secret=client-secret refresh_token=refresh-token'); Object.assign(error, { diff --git a/packages/web/src/app/api/(server)/ee/askmcp/connect/route.test.ts b/packages/web/src/app/api/(server)/ee/askmcp/connect/route.test.ts index 27638fcc0..c4798d5c6 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/connect/route.test.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/connect/route.test.ts @@ -148,7 +148,7 @@ describe('POST /api/ee/askmcp/connect', () => { mocks.mcpAuth.mockImplementation(async (provider, options) => { expect('saveClientInformation' in provider).toBe(true); expect(provider.saveClientInformation).toEqual(expect.any(Function)); - expect(provider.clientMetadata.scope).toBe('offline_access repo'); + expect(provider.clientMetadata.scope).toBe('repo'); expect(options.fetchFn).toEqual(expect.any(Function)); await provider.saveClientInformation({ client_id: 'client-1' }); diff --git a/packages/web/src/ee/features/chat/mcp/oauthScopeUtils.test.ts b/packages/web/src/ee/features/chat/mcp/oauthScopeUtils.test.ts index 218bf5de3..8b1504038 100644 --- a/packages/web/src/ee/features/chat/mcp/oauthScopeUtils.test.ts +++ b/packages/web/src/ee/features/chat/mcp/oauthScopeUtils.test.ts @@ -31,28 +31,18 @@ describe('buildMcpOAuthScopeEntries', () => { ]); }); - test('enables offline_access by default even when not in requestedOAuthScopes', () => { + test('does not special-case offline_access; it is enabled only when requested', () => { const entries = buildMcpOAuthScopeEntries({ - availableOAuthScopes: ['offline_access', 'read', 'write'], + availableOAuthScopes: ['offline_access', 'read'], requestedOAuthScopes: [], }); expect(entries).toEqual([ - { scope: 'offline_access', enabled: true }, + { scope: 'offline_access', enabled: false }, { scope: 'read', enabled: false }, - { scope: 'write', enabled: false }, ]); }); - test('does not add offline_access when it is absent from available scopes', () => { - const entries = buildMcpOAuthScopeEntries({ - availableOAuthScopes: ['read', 'write'], - requestedOAuthScopes: [], - }); - - expect(entries.find((e) => e.scope === 'offline_access')).toBeUndefined(); - }); - test('merges requested scopes not in available scopes into the output', () => { const entries = buildMcpOAuthScopeEntries({ availableOAuthScopes: ['read'], diff --git a/packages/web/src/ee/features/chat/mcp/oauthScopeUtils.ts b/packages/web/src/ee/features/chat/mcp/oauthScopeUtils.ts index da6cd0a65..a4ff70b3c 100644 --- a/packages/web/src/ee/features/chat/mcp/oauthScopeUtils.ts +++ b/packages/web/src/ee/features/chat/mcp/oauthScopeUtils.ts @@ -3,7 +3,9 @@ import type { McpServerOAuthScopeEntry } from './types'; export const OAUTH_SCOPE_TOKEN_REGEX = /^[\x21\x23-\x5B\x5D-\x7E]+$/; // Required for the refresh_token grant that all clients declare. Providers such as -// Atlassian only honour that grant when this scope is included in the authorization request. +// Atlassian only honour that grant when this scope is included in the authorization request, +// so the admin UI pre-selects it whenever the connector advertises it. Admins can still +// untick it to opt out of refresh tokens. export const OFFLINE_ACCESS_SCOPE = 'offline_access'; export function normalizeMcpRequestedOAuthScopes(oauthScopes: string[]): string[] { @@ -54,8 +56,7 @@ export function buildMcpOAuthScopeEntries({ return normalizedAvailableOAuthScopes.map((scope) => ({ scope, - // Force-enabled regardless of admin selection — see OFFLINE_ACCESS_SCOPE. - enabled: scope === OFFLINE_ACCESS_SCOPE || requestedScopeSet.has(scope), + enabled: requestedScopeSet.has(scope), })); } diff --git a/packages/web/src/ee/features/chat/mcp/prismaOAuthClientProvider.test.ts b/packages/web/src/ee/features/chat/mcp/prismaOAuthClientProvider.test.ts index 735fc2327..5123d0941 100644 --- a/packages/web/src/ee/features/chat/mcp/prismaOAuthClientProvider.test.ts +++ b/packages/web/src/ee/features/chat/mcp/prismaOAuthClientProvider.test.ts @@ -79,26 +79,18 @@ describe('PrismaOAuthClientProvider modes', () => { }); describe('PrismaOAuthClientProvider client metadata', () => { - test('always includes offline_access even when no scopes were requested', () => { + test('omits scope when no scopes were requested', () => { const provider = createProvider(); - expect(provider.clientMetadata.scope).toBe('offline_access'); + expect(provider.clientMetadata.scope).toBeUndefined(); }); - test('includes offline_access alongside normalized requested scopes', () => { + test('includes normalized requested scopes', () => { const provider = createProvider(createPrismaMock(), { requestedOAuthScopes: [' repo ', 'read:user', 'repo'], }); - expect(provider.clientMetadata.scope).toBe('offline_access read:user repo'); - }); - - test('does not duplicate offline_access when already present in requested scopes', () => { - const provider = createProvider(createPrismaMock(), { - requestedOAuthScopes: ['offline_access', 'read:user'], - }); - - expect(provider.clientMetadata.scope).toBe('offline_access read:user'); + expect(provider.clientMetadata.scope).toBe('read:user repo'); }); }); diff --git a/packages/web/src/ee/features/chat/mcp/prismaOAuthClientProvider.ts b/packages/web/src/ee/features/chat/mcp/prismaOAuthClientProvider.ts index bc5de3041..e0100dd76 100644 --- a/packages/web/src/ee/features/chat/mcp/prismaOAuthClientProvider.ts +++ b/packages/web/src/ee/features/chat/mcp/prismaOAuthClientProvider.ts @@ -9,7 +9,7 @@ import { McpServerClientInfoSource, type PrismaClient } from '@sourcebot/db'; import { encryptOAuthToken, decryptOAuthToken, createLogger } from '@sourcebot/shared'; import { __unsafePrisma } from '@/prisma'; import { createMcpOAuthState } from './mcpOAuthReturnTo'; -import { normalizeMcpRequestedOAuthScopes, OFFLINE_ACCESS_SCOPE } from './oauthScopeUtils'; +import { normalizeMcpRequestedOAuthScopes } from './oauthScopeUtils'; type McpOAuthPrismaClient = Pick; const logger = createLogger('mcp-oauth-client-provider'); @@ -113,14 +113,7 @@ export class PrismaOAuthClientProvider implements OAuthClientProvider { this.userId = userId; this.callbackUrl = callbackUrl; this.callbackReturnTo = callbackReturnTo; - // Always inject offline_access (see OFFLINE_ACCESS_SCOPE). We do so unconditionally rather - // than checking the provider's advertised scopes because oauthScopesSupported is not plumbed - // through to this constructor; the tradeoff (a benign unknown-scope rejection on strict - // providers) is the same as the existing behaviour of always declaring refresh_token. - this.requestedOAuthScopes = normalizeMcpRequestedOAuthScopes([ - ...requestedOAuthScopes, - OFFLINE_ACCESS_SCOPE, - ]); + this.requestedOAuthScopes = normalizeMcpRequestedOAuthScopes(requestedOAuthScopes); if (allowClientRegistration) { this.saveClientInformation = async (info: OAuthClientInformation) => { From 60fd09ce8d40f3d4226204ea22a0a470c3bcf887 Mon Sep 17 00:00:00 2001 From: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Date: Wed, 10 Jun 2026 18:05:28 -0700 Subject: [PATCH 07/11] Add support for a tooltip warning admins that this scope is required for refresh tokens. --- .../workspaceAskAgent/workspaceAskAgentPage.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx b/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx index 0db9fef73..4b33de741 100644 --- a/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx +++ b/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx @@ -22,6 +22,7 @@ import { Label } from "@/components/ui/label"; import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; import { Textarea } from "@/components/ui/textarea"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { checkMcpServerDynamicClientRegistration, createMcpServer, createStaticOAuthMcpServer, deleteMcpServer, updateMcpServerOAuthScopes } from "@/ee/features/chat/mcp/actions"; import { ConnectMcpButton } from "@/ee/features/chat/mcp/components/connectMcpButton"; import { ConnectorCard } from "@/ee/features/chat/mcp/components/connectorCard"; @@ -31,7 +32,7 @@ import { buildMcpOAuthScopeEntries, getMcpRequestedOAuthScopes, normalizeMcpRequ import { pluralize } from "@/features/chat/mcp/utils"; import { cn, isServiceError } from "@/lib/utils"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { AlertTriangleIcon, CableIcon, CopyIcon, KeyRoundIcon, Loader2, MoreHorizontalIcon, PlusIcon, Trash2Icon, WrenchIcon } from "lucide-react"; +import { AlertTriangleIcon, CableIcon, CopyIcon, InfoIcon, KeyRoundIcon, Loader2, MoreHorizontalIcon, PlusIcon, Trash2Icon, WrenchIcon } from "lucide-react"; import { PrefabConnectorPopover } from "@/ee/features/chat/mcp/components/prefabConnectorPopover"; import Markdown from "react-markdown"; import { getStaticOAuthDescription, type PrefabMcpServer } from "@/ee/features/chat/mcp/prefabMcpServers"; @@ -154,6 +155,18 @@ function OAuthScopesInput({ aria-label={`Request ${scope}`} /> {scope} + {scope === OFFLINE_ACCESS_SCOPE && ( + + + event.preventDefault()}> + + + + + Required for refresh tokens. Without this scope, users must re-authenticate whenever their access token expires, and some connectors reject authorization entirely. + + + )} {onRemoveOAuthScope && (