Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 7 additions & 1 deletion docs/docs/features/ask/connectors.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<div className="max-w-sm mx-auto">
<Frame>
<img
Expand All @@ -80,6 +82,11 @@ Owners can change connector scopes at any time from **Settings → Workspace →
Changing connector scopes requires all users to re-authenticate with that connector.
</Warning>

<Note>
`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).
</Note>

## 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.
Expand Down Expand Up @@ -121,4 +128,3 @@ You can see all available connectors on this page. After you connect one, you ca
</Frame>
</div>


Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -154,6 +160,18 @@ function OAuthScopesInput({
aria-label={`Request ${scope}`}
/>
<span className="break-all font-mono text-xs">{scope}</span>
{scope === OFFLINE_ACCESS_SCOPE && (
<Tooltip>
<TooltipTrigger asChild>
<span className="shrink-0 text-muted-foreground" onClick={(event) => event.preventDefault()}>
<InfoIcon className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-64">
Required for refresh tokens. Without this scope, users must re-authenticate whenever their access token expires, and some connectors reject authorization entirely.
</TooltipContent>
</Tooltip>
)}
Comment thread
jsourcebot marked this conversation as resolved.
</label>
{onRemoveOAuthScope && (
<Button
Expand Down Expand Up @@ -188,6 +206,15 @@ function OAuthScopesInput({
className="min-h-20 resize-y font-mono text-sm"
/>
</div>

{(isOfflineAccessOnly || isNoScopesSelected) && (
<p className="flex items-start gap-1.5 text-xs text-muted-foreground">
<AlertTriangleIcon className="h-3.5 w-3.5 shrink-0 text-yellow-600 dark:text-yellow-400" />
{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."}
</p>
)}
</div>
);
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -584,7 +618,7 @@ export function WorkspaceAskAgentPage({ callbackStatus, callbackServer, callback
}

if (discoveredOAuthScopes.length > 0) {
resetOAuthScopeInputs();
initializeOAuthScopeSelection(discoveredOAuthScopes);
setPendingOAuthScopeSelectionServer({
name: displayName,
serverUrl: normalizedServerUrl,
Expand Down
81 changes: 81 additions & 0 deletions packages/web/src/ee/features/chat/mcp/oauthScopeUtils.test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
7 changes: 7 additions & 0 deletions packages/web/src/ee/features/chat/mcp/oauthScopeUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Comment thread
jsourcebot marked this conversation as resolved.

export function normalizeMcpRequestedOAuthScopes(oauthScopes: string[]): string[] {
return [...new Set(oauthScopes.map((scope) => scope.trim()).filter(Boolean))]
.sort();
Expand Down
Loading