diff --git a/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/__tests__/route.test.ts b/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/__tests__/route.test.ts index ff75e25e7..ffb4d0c8d 100644 --- a/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/__tests__/route.test.ts +++ b/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/__tests__/route.test.ts @@ -186,7 +186,12 @@ describe('GET /api/mcp-oauth/initiate/[connectionId]', () => { authorizationEndpoint: 'https://linear.app/oauth/authorize', tokenEndpoint: 'https://api.linear.app/oauth/token', }); - getMcpIntegrationOauthScopesMock.mockReturnValue(['read', 'write']); + getMcpIntegrationOauthScopesMock.mockReturnValue([ + 'read', + 'write', + 'app:assignable', + 'app:mentionable', + ]); getMcpIntegrationOauthScopeSeparatorMock.mockReturnValue(','); getMcpIntegrationAuthorizationParametersMock.mockReturnValue([ { name: 'actor', value: 'app' }, @@ -204,7 +209,9 @@ describe('GET /api/mcp-oauth/initiate/[connectionId]', () => { const authUrl = new URL(response.headers.get('location')!); expect(authUrl.origin).toBe('https://linear.app'); expect(authUrl.pathname).toBe('/oauth/authorize'); - expect(authUrl.searchParams.get('scope')).toBe('read,write'); + expect(authUrl.searchParams.get('scope')).toBe( + 'read,write,app:assignable,app:mentionable', + ); expect(authUrl.searchParams.get('actor')).toBe('app'); expect(authUrl.searchParams.get('redirect_uri')).toBe(PUBLIC_CALLBACK); expect(discoverOAuthEndpointsMock).not.toHaveBeenCalled(); diff --git a/apps/web/src/components/settings/Integrations.test.tsx b/apps/web/src/components/settings/Integrations.test.tsx index 6ebbe083e..c0ea6e074 100644 --- a/apps/web/src/components/settings/Integrations.test.tsx +++ b/apps/web/src/components/settings/Integrations.test.tsx @@ -529,6 +529,20 @@ describe('Integrations settings', () => { ).not.toBeInTheDocument(); }); + it('lets administrators reconnect a configured Linear workspace', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Reconnect Linear' })); + + expect(mutations.connectLinear).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), + ); + }); + it('surfaces the server error when starting Linear fails', () => { state.linearInstallation = null; mutations.connectLinear.mockImplementation((_variables, options) => { diff --git a/apps/web/src/components/settings/Integrations.tsx b/apps/web/src/components/settings/Integrations.tsx index a6640085e..b2e39708b 100644 --- a/apps/web/src/components/settings/Integrations.tsx +++ b/apps/web/src/components/settings/Integrations.tsx @@ -1290,6 +1290,21 @@ export function Integrations() { (userMcpConnections.data ?? []).map((entry) => [entry.mcpId, entry]), ); const canSetUpLinearOauth = isAdmin && linearOauthUnavailable; + const canReconnectLinear = + isAdmin && Boolean(linearInstallation.data) && !linearOauthUnavailable; + const startLinearConnection = () => { + connectLinear.mutate(undefined, { + onSuccess: (url) => { + window.location.href = url; + }, + onError: (error) => + toast.error( + error instanceof Error + ? error.message + : 'Failed to enable Linear. Please try again.', + ), + }); + }; const openMcpToolDialog = (integration: McpIntegrationDefinition) => setToolDialogState({ mcpId: integration.id, @@ -1346,7 +1361,15 @@ export function Integrations() { linearOauthSetup.isPending || linearOauthSetup.data == null, icon: , } - : undefined, + : canReconnectLinear + ? { + label: 'Reconnect', + ariaLabel: 'Reconnect Linear', + onAction: startLinearConnection, + isPending: connectLinear.isPending, + icon: , + } + : undefined, onAction: linearOauthUnavailable ? undefined : () => { @@ -1364,17 +1387,7 @@ export function Integrations() { return; } - connectLinear.mutate(undefined, { - onSuccess: (url) => { - window.location.href = url; - }, - onError: (error) => - toast.error( - error instanceof Error - ? error.message - : 'Failed to enable Linear. Please try again.', - ), - }); + startLinearConnection(); }, }, ...visibleMcpIntegrations diff --git a/packages/types/src/__tests__/mcp-oauth.test.ts b/packages/types/src/__tests__/mcp-oauth.test.ts new file mode 100644 index 000000000..11c004fc6 --- /dev/null +++ b/packages/types/src/__tests__/mcp-oauth.test.ts @@ -0,0 +1,18 @@ +import { + getMcpIntegrationOauthScopes, + LINEAR_APP_OAUTH_SCOPES, +} from '../mcp-oauth'; + +describe('Linear OAuth scopes', () => { + it('makes deployment app actors assignable and mentionable', () => { + expect( + getMcpIntegrationOauthScopes('linear', 'linear_org_install'), + ).toEqual(LINEAR_APP_OAUTH_SCOPES); + }); + + it('keeps personal account links read-only', () => { + expect(getMcpIntegrationOauthScopes('linear', 'linear_user_link')).toEqual([ + 'read', + ]); + }); +}); diff --git a/packages/types/src/mcp-oauth.ts b/packages/types/src/mcp-oauth.ts index 8ea0d4134..4ead8447a 100644 --- a/packages/types/src/mcp-oauth.ts +++ b/packages/types/src/mcp-oauth.ts @@ -166,6 +166,13 @@ export type McpConnectionRole = | 'linear_org_install' | 'linear_user_link'; +export const LINEAR_APP_OAUTH_SCOPES = [ + 'read', + 'write', + 'app:assignable', + 'app:mentionable', +] as const; + /** * MCP Server Configuration * This is what gets written to .roomote/mcp.json in the workspace @@ -633,7 +640,9 @@ export function getMcpIntegrationOauthScopes( } if (integration.id === 'linear') { - return role === 'linear_user_link' ? ['read'] : ['read', 'write']; + return role === 'linear_user_link' + ? ['read'] + : [...LINEAR_APP_OAUTH_SCOPES]; } return integration.oauthScopes;