Linear MCP Server

  • src
import { LinearClient } from '@linear/sdk'; import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; /** * Solution Attempts: * * 1. OAuth Flow with Browser (Initial Attempt) * - Used browser redirect and local server for OAuth flow * - Issues: Browser extensions interfering, CORS issues * - Status: Failed - Browser extensions and CORS blocking requests * * 2. Personal Access Token (Current Attempt) * - Using PAT for initial integration tests * - Simpler approach without browser interaction * - Status: Working - Successfully authenticates and makes API calls * * 3. Direct OAuth Token Exchange (Current Attempt) * - Using form-urlencoded content type as required by Linear * - Status: In Progress - Testing token exchange */ export interface OAuthConfig { type: 'oauth'; clientId: string; clientSecret: string; redirectUri: string; } export interface PersonalAccessTokenConfig { type: 'pat'; accessToken: string; } export type AuthConfig = OAuthConfig | PersonalAccessTokenConfig; export interface TokenData { accessToken: string; refreshToken: string; expiresAt: number; } export class LinearAuth { private static readonly OAUTH_AUTH_URL = 'https://linear.app/oauth'; private static readonly OAUTH_TOKEN_URL = 'https://api.linear.app'; private config?: AuthConfig; private tokenData?: TokenData; private linearClient?: LinearClient; constructor() {} public getAuthorizationUrl(): string { if (!this.config || this.config.type !== 'oauth') { throw new McpError( ErrorCode.InvalidRequest, 'OAuth config not initialized' ); } const params = new URLSearchParams({ client_id: this.config.clientId, redirect_uri: this.config.redirectUri, response_type: 'code', scope: 'read,write,issues:create,offline_access', actor: 'application', // Enable OAuth Actor Authorization state: this.generateState(), access_type: 'offline', }); return `${LinearAuth.OAUTH_AUTH_URL}/authorize?${params.toString()}`; } public async handleCallback(code: string): Promise<void> { if (!this.config || this.config.type !== 'oauth') { throw new McpError( ErrorCode.InvalidRequest, 'OAuth config not initialized' ); } try { const params = new URLSearchParams({ grant_type: 'authorization_code', client_id: this.config.clientId, client_secret: this.config.clientSecret, redirect_uri: this.config.redirectUri, code, access_type: 'offline' }); const response = await fetch(`${LinearAuth.OAUTH_TOKEN_URL}/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' }, body: params.toString() }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Token request failed: ${response.statusText}. Response: ${errorText}`); } const data = await response.json(); this.tokenData = { accessToken: data.access_token, refreshToken: data.refresh_token, expiresAt: Date.now() + data.expires_in * 1000, }; this.linearClient = new LinearClient({ accessToken: this.tokenData.accessToken, }); } catch (error) { throw new McpError( ErrorCode.InternalError, `OAuth token exchange failed: ${error instanceof Error ? error.message : 'Unknown error'}` ); } } public async refreshAccessToken(): Promise<void> { if (!this.config || this.config.type !== 'oauth' || !this.tokenData?.refreshToken) { throw new McpError( ErrorCode.InvalidRequest, 'OAuth not initialized or no refresh token available' ); } try { const params = new URLSearchParams({ grant_type: 'refresh_token', client_id: this.config.clientId, client_secret: this.config.clientSecret, refresh_token: this.tokenData.refreshToken }); const response = await fetch(`${LinearAuth.OAUTH_TOKEN_URL}/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' }, body: params.toString() }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Token refresh failed: ${response.statusText}. Response: ${errorText}`); } const data = await response.json(); this.tokenData = { accessToken: data.access_token, refreshToken: data.refresh_token, expiresAt: Date.now() + data.expires_in * 1000, }; this.linearClient = new LinearClient({ accessToken: this.tokenData.accessToken, }); } catch (error) { throw new McpError( ErrorCode.InternalError, `Token refresh failed: ${error instanceof Error ? error.message : 'Unknown error'}` ); } } public initialize(config: AuthConfig): void { if (config.type === 'pat') { // Personal Access Token flow this.tokenData = { accessToken: config.accessToken, refreshToken: '', // Not needed for PAT expiresAt: Number.MAX_SAFE_INTEGER, // PATs don't expire }; this.linearClient = new LinearClient({ accessToken: config.accessToken, }); } else { // OAuth flow if (!config.clientId || !config.clientSecret || !config.redirectUri) { throw new McpError( ErrorCode.InvalidParams, 'Missing required OAuth parameters: clientId, clientSecret, redirectUri' ); } this.config = config; } } public getClient(): LinearClient { if (!this.linearClient) { throw new McpError( ErrorCode.InvalidRequest, 'Linear client not initialized' ); } return this.linearClient; } public isAuthenticated(): boolean { return !!this.linearClient && !!this.tokenData; } public needsTokenRefresh(): boolean { if (!this.tokenData || !this.config || this.config.type === 'pat') return false; return Date.now() >= this.tokenData.expiresAt - 300000; // Refresh 5 minutes before expiry } // For testing purposes public setTokenData(tokenData: TokenData): void { this.tokenData = tokenData; this.linearClient = new LinearClient({ accessToken: tokenData.accessToken, }); } private generateState(): string { return Math.random().toString(36).substring(2, 15); } }