diff --git a/apps/docs/integrations/linear.mdx b/apps/docs/integrations/linear.mdx index 47da38a8a..64aac38ab 100644 --- a/apps/docs/integrations/linear.mdx +++ b/apps/docs/integrations/linear.mdx @@ -14,6 +14,12 @@ priority, or discussion. ## Setup +For self-hosted deployments, create a Linear OAuth application before +connecting the workspace. Configure its callback as +`/api/mcp-oauth/callback`, then set +`R_LINEAR_CLIENT_ID`, `R_LINEAR_CLIENT_SECRET`, and +`R_LINEAR_WEBHOOK_SECRET` on the Roomote deployment. + In Roomote, go to **Settings > Integrations** and connect your Linear diff --git a/apps/web/src/app/api/mcp-oauth/callback/__tests__/route.test.ts b/apps/web/src/app/api/mcp-oauth/callback/__tests__/route.test.ts index 56b006a4a..daac6838b 100644 --- a/apps/web/src/app/api/mcp-oauth/callback/__tests__/route.test.ts +++ b/apps/web/src/app/api/mcp-oauth/callback/__tests__/route.test.ts @@ -8,6 +8,7 @@ const { exchangeCodeForTokensMock, getClientInformationMock, getMcpIntegrationMock, + getMcpIntegrationOauthEndpointsMock, hydrateLinearMcpConnectionAfterOauthMock, isDeploymentScopedMcpIntegrationMock, isSelfServeMcpIntegrationMock, @@ -24,6 +25,7 @@ const { exchangeCodeForTokensMock: vi.fn(), getClientInformationMock: vi.fn(), getMcpIntegrationMock: vi.fn(), + getMcpIntegrationOauthEndpointsMock: vi.fn(), hydrateLinearMcpConnectionAfterOauthMock: vi.fn(), isDeploymentScopedMcpIntegrationMock: vi.fn(), isSelfServeMcpIntegrationMock: vi.fn(), @@ -83,6 +85,7 @@ vi.mock('@roomote/sdk/server', () => ({ vi.mock('@roomote/types', () => ({ getMcpIntegration: getMcpIntegrationMock, + getMcpIntegrationOauthEndpoints: getMcpIntegrationOauthEndpointsMock, isDeploymentScopedMcpIntegration: isDeploymentScopedMcpIntegrationMock, isSelfServeMcpIntegration: isSelfServeMcpIntegrationMock, })); @@ -127,6 +130,10 @@ describe('GET /api/mcp-oauth/callback', () => { name: 'Linear', url: 'https://mcp.linear.app/mcp', }); + getMcpIntegrationOauthEndpointsMock.mockReturnValue({ + authorizationEndpoint: 'https://linear.app/oauth/authorize', + tokenEndpoint: 'https://api.linear.app/oauth/token', + }); isSelfServeMcpIntegrationMock.mockReturnValue(true); isDeploymentScopedMcpIntegrationMock.mockReturnValue(false); getClientInformationMock.mockResolvedValue({ @@ -152,12 +159,13 @@ describe('GET /api/mcp-oauth/callback', () => { 'https://customer.example/settings?mcp=connected', ); expect(exchangeCodeForTokensMock).toHaveBeenCalledWith( - 'https://mcp.linear.app/token', + 'https://api.linear.app/oauth/token', 'auth-code', 'verifier-1', { client_id: 'client-1' }, PUBLIC_CALLBACK, ); + expect(discoverOAuthEndpointsMock).not.toHaveBeenCalled(); }); it('falls back to R_APP_URL for token exchange redirect_uri when R_PUBLIC_URL is unset', async () => { @@ -172,7 +180,7 @@ describe('GET /api/mcp-oauth/callback', () => { 'http://localhost:13000/settings?mcp=connected', ); expect(exchangeCodeForTokensMock).toHaveBeenCalledWith( - 'https://mcp.linear.app/token', + 'https://api.linear.app/oauth/token', 'auth-code', 'verifier-1', { client_id: 'client-1' }, @@ -223,7 +231,7 @@ describe('GET /api/mcp-oauth/callback', () => { expect(resumedResponse.headers.get('set-cookie')).toContain('Max-Age=0'); expect(consumeOAuthStateMock).toHaveBeenCalledTimes(1); expect(exchangeCodeForTokensMock).toHaveBeenCalledWith( - 'https://mcp.linear.app/token', + 'https://api.linear.app/oauth/token', 'auth-code', 'verifier-1', { client_id: 'client-1' }, diff --git a/apps/web/src/app/api/mcp-oauth/callback/route.ts b/apps/web/src/app/api/mcp-oauth/callback/route.ts index 6ad071fe1..444944ff6 100644 --- a/apps/web/src/app/api/mcp-oauth/callback/route.ts +++ b/apps/web/src/app/api/mcp-oauth/callback/route.ts @@ -9,6 +9,7 @@ import { } from '@roomote/db/server'; import { getMcpIntegration, + getMcpIntegrationOauthEndpoints, isDeploymentScopedMcpIntegration, isSelfServeMcpIntegration, } from '@roomote/types'; @@ -302,12 +303,14 @@ export async function GET(request: NextRequest) { } failureStage = 'provider_discovery'; - const serverMetadata = await discoverOAuthEndpoints(integration.url); + const tokenEndpoint = + getMcpIntegrationOauthEndpoints(integration)?.tokenEndpoint ?? + (await discoverOAuthEndpoints(integration.url)).token_endpoint; const redirectUri = new URL(CALLBACK_PATH, webUrl).toString(); failureStage = 'token_exchange'; const tokens = await exchangeCodeForTokens( - serverMetadata.token_endpoint, + tokenEndpoint, code, oauthState.codeVerifier, clientInfo, 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 25f353856..a0d53adb3 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 @@ -9,7 +9,9 @@ const { getClientInformationMock, getMcpIntegrationAuthorizationParametersMock, getMcpIntegrationMock, + getMcpIntegrationOauthEndpointsMock, getMcpIntegrationOauthScopeModeMock, + getMcpIntegrationOauthScopeSeparatorMock, getMcpIntegrationOauthScopesMock, getPreferredTokenEndpointAuthMethodMock, isDeploymentScopedMcpIntegrationMock, @@ -30,7 +32,9 @@ const { getClientInformationMock: vi.fn(), getMcpIntegrationAuthorizationParametersMock: vi.fn(), getMcpIntegrationMock: vi.fn(), + getMcpIntegrationOauthEndpointsMock: vi.fn(), getMcpIntegrationOauthScopeModeMock: vi.fn(), + getMcpIntegrationOauthScopeSeparatorMock: vi.fn(), getMcpIntegrationOauthScopesMock: vi.fn(), getPreferredTokenEndpointAuthMethodMock: vi.fn(), isDeploymentScopedMcpIntegrationMock: vi.fn(), @@ -83,7 +87,10 @@ vi.mock('@roomote/sdk/server', () => ({ vi.mock('@roomote/types', () => ({ getMcpIntegrationAuthorizationParameters: getMcpIntegrationAuthorizationParametersMock, + getMcpIntegrationOauthEndpoints: getMcpIntegrationOauthEndpointsMock, getMcpIntegrationOauthScopeMode: getMcpIntegrationOauthScopeModeMock, + getMcpIntegrationOauthScopeSeparator: + getMcpIntegrationOauthScopeSeparatorMock, getMcpIntegrationOauthScopes: getMcpIntegrationOauthScopesMock, getMcpIntegration: getMcpIntegrationMock, isDeploymentScopedMcpIntegration: isDeploymentScopedMcpIntegrationMock, @@ -139,6 +146,8 @@ describe('GET /api/mcp-oauth/initiate/[connectionId]', () => { getPreferredTokenEndpointAuthMethodMock.mockReturnValue('none'); getMcpIntegrationOauthScopesMock.mockReturnValue(undefined); getMcpIntegrationOauthScopeModeMock.mockReturnValue(undefined); + getMcpIntegrationOauthEndpointsMock.mockReturnValue(undefined); + getMcpIntegrationOauthScopeSeparatorMock.mockReturnValue(' '); getMcpIntegrationAuthorizationParametersMock.mockReturnValue([]); generateCodeVerifierMock.mockReturnValue('verifier-value'); generateCodeChallengeMock.mockResolvedValue('challenge-value'); @@ -169,6 +178,68 @@ describe('GET /api/mcp-oauth/initiate/[connectionId]', () => { }); }); + it('uses the Linear API OAuth flow instead of Linear MCP OAuth', async () => { + getMcpIntegrationOauthEndpointsMock.mockReturnValue({ + authorizationEndpoint: 'https://linear.app/oauth/authorize', + tokenEndpoint: 'https://api.linear.app/oauth/token', + }); + getMcpIntegrationOauthScopesMock.mockReturnValue(['read', 'write']); + getMcpIntegrationOauthScopeSeparatorMock.mockReturnValue(','); + getMcpIntegrationAuthorizationParametersMock.mockReturnValue([ + { name: 'actor', value: 'app' }, + ]); + getClientInformationMock.mockResolvedValue({ + client_id: 'linear-client', + client_secret: 'linear-secret', + token_endpoint_auth_method: 'client_secret_post', + }); + + const response = await GET(buildRequest(), { + params: Promise.resolve({ connectionId: CONNECTION_ID }), + }); + + 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('actor')).toBe('app'); + expect(authUrl.searchParams.get('redirect_uri')).toBe(PUBLIC_CALLBACK); + expect(discoverOAuthEndpointsMock).not.toHaveBeenCalled(); + expect(discoverOAuthProtectedResourceMetadataMock).not.toHaveBeenCalled(); + expect(registerOAuthClientMock).not.toHaveBeenCalled(); + }); + + it('stores the configured Linear OAuth client for the callback', async () => { + getMcpIntegrationOauthEndpointsMock.mockReturnValue({ + authorizationEndpoint: 'https://linear.app/oauth/authorize', + tokenEndpoint: 'https://api.linear.app/oauth/token', + }); + getMcpIntegrationOauthScopesMock.mockReturnValue(['read', 'write']); + getMcpIntegrationOauthScopeSeparatorMock.mockReturnValue(','); + getClientInformationMock.mockResolvedValue(undefined); + resolveStaticOauthClientInformationMock.mockReturnValue({ + client_id: 'linear-client', + client_secret: 'linear-secret', + token_endpoint_auth_method: 'client_secret_post', + }); + + const response = await GET(buildRequest(), { + params: Promise.resolve({ connectionId: CONNECTION_ID }), + }); + + expect(response.status).toBe(307); + expect(storeClientInformationMock).toHaveBeenCalledWith( + CONNECTION_ID, + { + client_id: 'linear-client', + client_secret: 'linear-secret', + token_endpoint_auth_method: 'client_secret_post', + }, + PUBLIC_CALLBACK, + ); + expect(registerOAuthClientMock).not.toHaveBeenCalled(); + }); + it('re-registers when stored client was registered against a different callback', async () => { getClientInformationMock.mockResolvedValue(undefined); diff --git a/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/route.ts b/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/route.ts index 5cf522d50..60271b41a 100644 --- a/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/route.ts +++ b/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/route.ts @@ -16,11 +16,14 @@ import { import type { OAuthClientMetadata, OAuthClientInformation, + OAuthServerMetadata, } from '@roomote/types'; import { type McpConnectionRole, getMcpIntegrationAuthorizationParameters, + getMcpIntegrationOauthEndpoints, getMcpIntegrationOauthScopeMode, + getMcpIntegrationOauthScopeSeparator, getMcpIntegrationOauthScopes, getMcpIntegration, type McpIntegration, @@ -99,7 +102,9 @@ function getRequestedScope( connectionRole, ); if (explicitScopes?.length) { - return explicitScopes.join(' '); + return explicitScopes.join( + getMcpIntegrationOauthScopeSeparator(integration), + ); } if (!scopeList?.length) { @@ -115,7 +120,31 @@ function getRequestedScope( new Set(requestedScopes.map((scope) => scope.trim()).filter(Boolean)), ); - return uniqueScopes.length > 0 ? uniqueScopes.join(' ') : undefined; + return uniqueScopes.length > 0 + ? uniqueScopes.join(getMcpIntegrationOauthScopeSeparator(integration)) + : undefined; +} + +function getOAuthServerMetadataOverride( + integration: McpIntegration, +): OAuthServerMetadata | undefined { + const endpoints = getMcpIntegrationOauthEndpoints(integration); + if (!endpoints) { + return undefined; + } + + return { + issuer: new URL(endpoints.authorizationEndpoint).origin, + authorization_endpoint: endpoints.authorizationEndpoint, + token_endpoint: endpoints.tokenEndpoint, + scopes_supported: getMcpIntegrationOauthScopes(integration), + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + token_endpoint_auth_methods_supported: [ + 'client_secret_post', + 'client_secret_basic', + ], + }; } function getStaticClientInformation( @@ -185,10 +214,13 @@ export async function GET( ); } - const [serverMetadata, protectedResourceMetadata] = await Promise.all([ - discoverOAuthEndpoints(integration.url), - discoverOAuthProtectedResourceMetadata(integration.url), - ]); + const serverMetadataOverride = getOAuthServerMetadataOverride(integration); + const [serverMetadata, protectedResourceMetadata] = serverMetadataOverride + ? [serverMetadataOverride, undefined] + : await Promise.all([ + discoverOAuthEndpoints(integration.url), + discoverOAuthProtectedResourceMetadata(integration.url), + ]); const redirectUri = new URL('/api/mcp-oauth/callback', webUrl).toString(); const requestedScope = getRequestedScope( protectedResourceMetadata?.scopes_supported ?? diff --git a/apps/web/src/lib/server/mcp-static-oauth.test.ts b/apps/web/src/lib/server/mcp-static-oauth.test.ts new file mode 100644 index 000000000..fc4a59eb8 --- /dev/null +++ b/apps/web/src/lib/server/mcp-static-oauth.test.ts @@ -0,0 +1,38 @@ +import { getMcpIntegration } from '@roomote/types'; + +import { + getStaticOauthEnvPartnerKey, + resolveStaticOauthClientInformation, +} from './mcp-static-oauth'; + +describe('Linear static OAuth configuration', () => { + const linear = getMcpIntegration('linear'); + + it('pairs and resolves the configured client credentials', () => { + expect(getStaticOauthEnvPartnerKey('R_LINEAR_CLIENT_ID')).toBe( + 'R_LINEAR_CLIENT_SECRET', + ); + expect( + resolveStaticOauthClientInformation( + { + R_LINEAR_CLIENT_ID: 'linear-client', + R_LINEAR_CLIENT_SECRET: 'linear-secret', + }, + linear!, + ), + ).toEqual({ + client_id: 'linear-client', + client_secret: 'linear-secret', + token_endpoint_auth_method: 'client_secret_post', + }); + }); + + it('rejects partial credential configuration', () => { + expect( + resolveStaticOauthClientInformation( + { R_LINEAR_CLIENT_ID: 'linear-client' }, + linear!, + ), + ).toBeUndefined(); + }); +}); diff --git a/apps/web/src/lib/server/mcp-static-oauth.ts b/apps/web/src/lib/server/mcp-static-oauth.ts index 80711cfc1..8eda15445 100644 --- a/apps/web/src/lib/server/mcp-static-oauth.ts +++ b/apps/web/src/lib/server/mcp-static-oauth.ts @@ -1,4 +1,4 @@ -import type { McpIntegration } from '@roomote/types'; +import { MCP_INTEGRATIONS, type McpIntegration } from '@roomote/types'; type StaticOauthClientEnv = NonNullable; type StaticOauthPairResolution = @@ -13,9 +13,28 @@ type StaticOauthPairResolution = const STATIC_OAUTH_FALLBACKS: Partial> = {}; -const STATIC_OAUTH_ENV_KEYS = new Set(); - -const STATIC_OAUTH_ENV_PARTNERS: Record = {}; +const STATIC_OAUTH_ENV_PAIRS = MCP_INTEGRATIONS.flatMap((integration) => { + const clientIdEnv = integration.oauthClientEnv?.clientIdEnv; + const clientSecretEnv = integration.oauthClientEnv?.clientSecretEnv; + + return clientIdEnv && clientSecretEnv + ? [[clientIdEnv, clientSecretEnv] as const] + : []; +}); + +const STATIC_OAUTH_ENV_KEYS = new Set( + STATIC_OAUTH_ENV_PAIRS.flatMap(([clientIdEnv, clientSecretEnv]) => [ + clientIdEnv, + clientSecretEnv, + ]), +); + +const STATIC_OAUTH_ENV_PARTNERS = Object.fromEntries( + STATIC_OAUTH_ENV_PAIRS.flatMap(([clientIdEnv, clientSecretEnv]) => [ + [clientIdEnv, clientSecretEnv], + [clientSecretEnv, clientIdEnv], + ]), +); export function getStaticOauthEnvPartnerKey(key: string): string | undefined { return STATIC_OAUTH_ENV_PARTNERS[key]; diff --git a/apps/web/src/trpc/commands/mcp-connections/index.ts b/apps/web/src/trpc/commands/mcp-connections/index.ts index 67b4681b5..8a2a09d62 100644 --- a/apps/web/src/trpc/commands/mcp-connections/index.ts +++ b/apps/web/src/trpc/commands/mcp-connections/index.ts @@ -1249,6 +1249,13 @@ export async function connectMcpCommand( ); } + const missingOauthEnvNames = getMissingStaticOauthEnvNames(integration); + if (missingOauthEnvNames.length > 0) { + throw new Error( + `${integration.name} isn't available on this Roomote deployment yet. Ask the team managing this Roomote deployment to finish the required OAuth setup before connecting it.`, + ); + } + if ( input.redirectTo && (!input.redirectTo.startsWith('/') || input.redirectTo.startsWith('//')) diff --git a/packages/sdk/src/server/lib/mcp/data.test.ts b/packages/sdk/src/server/lib/mcp/data.test.ts index 262e5d59d..b1da46131 100644 --- a/packages/sdk/src/server/lib/mcp/data.test.ts +++ b/packages/sdk/src/server/lib/mcp/data.test.ts @@ -1,5 +1,7 @@ -const { findFirstMock } = vi.hoisted(() => ({ +const { findFirstMock, updateMock, updateWhereMock } = vi.hoisted(() => ({ findFirstMock: vi.fn(), + updateMock: vi.fn(), + updateWhereMock: vi.fn(), })); vi.mock('@roomote/db/server', () => ({ @@ -9,6 +11,7 @@ vi.mock('@roomote/db/server', () => ({ findFirst: findFirstMock, }, }, + update: updateMock, }, mcpConnections: { id: 'mcp_connections.id' }, eq: vi.fn((column: string, value: string) => ({ column, value })), @@ -20,11 +23,19 @@ vi.mock('@roomote/db/encryption', () => ({ decryptText: vi.fn((value: string) => value), })); -import { getClientInformation } from './data'; +import { getClientInformation, getValidAccessToken } from './data'; describe('getClientInformation', () => { beforeEach(() => { vi.clearAllMocks(); + updateWhereMock.mockResolvedValue(undefined); + updateMock.mockReturnValue({ + set: vi.fn(() => ({ where: updateWhereMock })), + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); }); it('returns stored oauth_client credentials when redirect URIs match', async () => { @@ -87,4 +98,42 @@ describe('getClientInformation', () => { expect.objectContaining({ client_id: 'loopback-client' }), ); }); + + it('refreshes Linear API tokens through the Linear API token endpoint', async () => { + findFirstMock.mockResolvedValue({ + id: 'conn-1', + mcpId: 'linear', + accessToken: 'expired-access-token', + refreshToken: 'refresh-token', + tokenExpiresAt: new Date(0), + scopes: ['read', 'write'], + authConfig: { + type: 'oauth_client', + client_id: 'linear-client', + client_secret: 'enc:linear-secret', + registered_redirect_uri: + 'https://customer.example/api/mcp-oauth/callback', + token_endpoint_auth_method: 'client_secret_post', + }, + }); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + access_token: 'fresh-access-token', + refresh_token: 'fresh-refresh-token', + expires_in: 86_400, + scope: 'read,write', + }), + }); + vi.stubGlobal('fetch', fetchMock); + + await expect( + getValidAccessToken('conn-1', 'https://mcp.linear.app/mcp'), + ).resolves.toBe('fresh-access-token'); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.linear.app/oauth/token', + expect.objectContaining({ method: 'POST' }), + ); + }); }); diff --git a/packages/sdk/src/server/lib/mcp/data.ts b/packages/sdk/src/server/lib/mcp/data.ts index 43fda2a79..9a87fb9eb 100644 --- a/packages/sdk/src/server/lib/mcp/data.ts +++ b/packages/sdk/src/server/lib/mcp/data.ts @@ -19,6 +19,10 @@ import type { McpConnectionOAuthConfig, McpConnectionRole, } from '@roomote/types'; +import { + getMcpIntegrationOauthEndpoints, + MCP_INTEGRATIONS, +} from '@roomote/types'; // Minimum base64 length for an AES-256-GCM encrypted value (salt+iv+tag = 64 bytes → 88 base64 chars) const MIN_ENCRYPTED_LENGTH = 88; @@ -192,7 +196,7 @@ export async function storeTokens( accessToken: tokens.access_token, refreshToken: tokens.refresh_token || null, tokenExpiresAt, - scopes: tokens.scope ? tokens.scope.split(' ') : [], + scopes: tokens.scope ? tokens.scope.split(/[\s,]+/).filter(Boolean) : [], authStatus: 'authenticated', enabled: true, updatedAt: new Date(), @@ -373,20 +377,6 @@ export async function getClientInformation( }; } - if ( - connection.mcpId === 'linear' && - typeof process.env.R_LINEAR_CLIENT_ID === 'string' && - process.env.R_LINEAR_CLIENT_ID && - typeof process.env.R_LINEAR_CLIENT_SECRET === 'string' && - process.env.R_LINEAR_CLIENT_SECRET - ) { - return { - client_id: process.env.R_LINEAR_CLIENT_ID, - client_secret: process.env.R_LINEAR_CLIENT_SECRET, - token_endpoint_auth_method: 'client_secret_post', - }; - } - return undefined; } @@ -430,9 +420,14 @@ export async function getValidAccessToken( try { const clientInfo = await getClientInformation(connectionId); if (clientInfo) { - const serverMetadata = await discoverOAuthEndpoints(mcpUrl); + const integration = MCP_INTEGRATIONS.find( + (candidate) => candidate.url === mcpUrl, + ); + const tokenEndpoint = + getMcpIntegrationOauthEndpoints(integration)?.tokenEndpoint ?? + (await discoverOAuthEndpoints(mcpUrl)).token_endpoint; const newTokens = await refreshOAuthToken( - serverMetadata.token_endpoint, + tokenEndpoint, clientInfo, storedTokens.refresh_token, ); diff --git a/packages/types/src/mcp-oauth.ts b/packages/types/src/mcp-oauth.ts index bcc277b6c..8ea0d4134 100644 --- a/packages/types/src/mcp-oauth.ts +++ b/packages/types/src/mcp-oauth.ts @@ -221,6 +221,11 @@ export type McpIntegrationOAuthClientEnv = { tokenEndpointAuthMethod?: OAuthTokenEndpointAuthMethod; }; +export type McpIntegrationOAuthEndpoints = { + authorizationEndpoint: string; + tokenEndpoint: string; +}; + export type McpIntegrationAuthorizationParameter = { name: string; value: string; @@ -243,7 +248,9 @@ export type McpIntegration = { connectionScope?: 'user' | 'deployment'; authorizationParameters?: McpIntegrationAuthorizationParameter[]; oauthClientEnv?: McpIntegrationOAuthClientEnv; + oauthEndpoints?: McpIntegrationOAuthEndpoints; oauthScopes?: string[]; + oauthScopeSeparator?: ' ' | ','; oauthScopeMode?: McpIntegrationOauthScopeMode; connectionMode?: McpIntegrationConnectionMode; serverMode?: McpIntegrationServerMode; @@ -291,6 +298,16 @@ export const MCP_INTEGRATIONS: McpIntegration[] = [ connectionScope: 'deployment', connectionMode: 'oauth', serverMode: 'upstream_proxy', + oauthClientEnv: { + clientIdEnv: 'R_LINEAR_CLIENT_ID', + clientSecretEnv: 'R_LINEAR_CLIENT_SECRET', + tokenEndpointAuthMethod: 'client_secret_post', + }, + oauthEndpoints: { + authorizationEndpoint: 'https://linear.app/oauth/authorize', + tokenEndpoint: 'https://api.linear.app/oauth/token', + }, + oauthScopeSeparator: ',', }, { id: 'sentry', @@ -622,6 +639,36 @@ export function getMcpIntegrationOauthScopes( return integration.oauthScopes; } +export function getMcpIntegrationOauthEndpoints( + integrationOrId: McpIntegration | string | undefined, +): McpIntegrationOAuthEndpoints | undefined { + if (!integrationOrId) { + return undefined; + } + + const integration = + typeof integrationOrId === 'string' + ? getMcpIntegration(integrationOrId) + : integrationOrId; + + return integration?.oauthEndpoints; +} + +export function getMcpIntegrationOauthScopeSeparator( + integrationOrId: McpIntegration | string | undefined, +): ' ' | ',' { + if (!integrationOrId) { + return ' '; + } + + const integration = + typeof integrationOrId === 'string' + ? getMcpIntegration(integrationOrId) + : integrationOrId; + + return integration?.oauthScopeSeparator ?? ' '; +} + export function getMcpIntegrationOauthScopeMode( integrationOrId: McpIntegration | string | undefined, _role: McpConnectionRole = 'default',