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;