diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9fadcf22d..ab841e434 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 authorization when `offline_access` was not enabled. When adding a connector, the scopes dialog now pre-selects `offline_access` (admins can untick it) and warns when it is the only selected scope. [#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 2d743519a..bf472beb6 100644
--- a/docs/docs/features/ask/connectors.mdx
+++ b/docs/docs/features/ask/connectors.mdx
@@ -56,6 +56,8 @@ Owners can configure which OAuth scopes users authorize when connecting to a con
Sourcebot checks the connector for discoverable scopes and shows them as options. You can also add custom scopes.
+When you select scopes, most providers grant only what you request, so include the resource scopes the connector's tools need.
+
+
+`offline_access` is pre-selected when you add a connector that offers it because token refresh requires it. You can deselect 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.
+For more information, see the [OpenID Connect `offline_access` documentation](https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess).
+
+
## 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.
@@ -121,4 +128,3 @@ You can see all available connectors on this page. After you connect one, you ca
-
diff --git a/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx b/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx
index 08879ee85..f71ed904b 100644
--- a/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx
+++ b/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx
@@ -22,16 +22,17 @@ 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";
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";
-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";
@@ -89,6 +90,11 @@ function OAuthScopesInput({
const [oauthScopeSearchInput, setOAuthScopeSearchInput] = useState("");
const selectedOAuthScopeSet = new Set(selectedOAuthScopes);
const requestedOAuthScopes = getMcpRequestedOAuthScopes(selectedOAuthScopes, customOAuthScopeInput);
+ const hasDiscoveredResourceScopes = discoveredOAuthScopes.some((scope) => scope !== OFFLINE_ACCESS_SCOPE);
+ const isOfflineAccessOnly = requestedOAuthScopes.length === 1
+ && requestedOAuthScopes[0] === OFFLINE_ACCESS_SCOPE
+ && hasDiscoveredResourceScopes;
+ const isNoScopesSelected = requestedOAuthScopes.length === 0 && hasDiscoveredResourceScopes;
const filteredOAuthScopes = useMemo(() => {
const query = oauthScopeSearchInput.trim().toLowerCase();
if (!query) {
@@ -154,6 +160,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 && (
+
+ {(isOfflineAccessOnly || isNoScopesSelected) && (
+
+
+ {isOfflineAccessOnly
+ ? "Only offline_access is selected. Without any resource scopes, the connector may not be able to access anything."
+ : "No scopes are selected. Without any resource scopes, the connector may not be able to access anything."}
+
+ )}
);
}
@@ -440,6 +467,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 +606,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 +618,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/ee/features/chat/mcp/oauthScopeUtils.test.ts b/packages/web/src/ee/features/chat/mcp/oauthScopeUtils.test.ts
new file mode 100644
index 000000000..8b1504038
--- /dev/null
+++ b/packages/web/src/ee/features/chat/mcp/oauthScopeUtils.test.ts
@@ -0,0 +1,81 @@
+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('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('does not special-case offline_access; it is enabled only when requested', () => {
+ const entries = buildMcpOAuthScopeEntries({
+ availableOAuthScopes: ['offline_access', 'read'],
+ requestedOAuthScopes: [],
+ });
+
+ expect(entries).toEqual([
+ { scope: 'offline_access', enabled: false },
+ { scope: 'read', enabled: false },
+ ]);
+ });
+
+ 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']);
+ });
+});
diff --git a/packages/web/src/ee/features/chat/mcp/oauthScopeUtils.ts b/packages/web/src/ee/features/chat/mcp/oauthScopeUtils.ts
index ccf099a17..4c26d2617 100644
--- a/packages/web/src/ee/features/chat/mcp/oauthScopeUtils.ts
+++ b/packages/web/src/ee/features/chat/mcp/oauthScopeUtils.ts
@@ -2,6 +2,13 @@ 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,
+// so the admin UI pre-selects it whenever the connector advertises it. Admins can still
+// untick it to opt out of refresh tokens.
+// See https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess
+export const OFFLINE_ACCESS_SCOPE = 'offline_access';
+
export function normalizeMcpRequestedOAuthScopes(oauthScopes: string[]): string[] {
return [...new Set(oauthScopes.map((scope) => scope.trim()).filter(Boolean))]
.sort();