Skip to main content
Glama
credentials.ts8 kB
import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { TokenResponse, WorkspaceInfo, refreshAccessToken, revokeToken } from './oauth.js'; const CONFIG_DIR = path.join(os.homedir(), '.linear-mcp'); const CREDENTIALS_FILE = path.join(CONFIG_DIR, 'credentials.json'); // Refresh tokens 1 hour before expiry const REFRESH_BUFFER_MS = 60 * 60 * 1000; export interface StoredWorkspace { id: string; name: string; urlKey: string; accessToken: string; refreshToken?: string; expiresAt: number; scope: string; } export interface CredentialsStore { activeWorkspace: string | null; workspaces: Record<string, StoredWorkspace>; } /** * Ensure the config directory exists */ function ensureConfigDir(): void { if (!fs.existsSync(CONFIG_DIR)) { fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); } } /** * Load credentials from disk */ export function loadCredentials(): CredentialsStore { try { if (fs.existsSync(CREDENTIALS_FILE)) { const data = fs.readFileSync(CREDENTIALS_FILE, 'utf-8'); return JSON.parse(data); } } catch (error) { console.error('Failed to load credentials:', error); } return { activeWorkspace: null, workspaces: {}, }; } /** * Save credentials to disk */ export function saveCredentials(credentials: CredentialsStore): void { ensureConfigDir(); // Write with restricted permissions (owner read/write only) fs.writeFileSync( CREDENTIALS_FILE, JSON.stringify(credentials, null, 2), { mode: 0o600 } ); } /** * Store a workspace's credentials */ export function storeWorkspace( workspace: WorkspaceInfo, tokens: TokenResponse ): void { const credentials = loadCredentials(); credentials.workspaces[workspace.urlKey] = { id: workspace.id, name: workspace.name, urlKey: workspace.urlKey, accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, expiresAt: tokens.expiresAt, scope: tokens.scope, }; // If this is the first workspace, make it active if (!credentials.activeWorkspace) { credentials.activeWorkspace = workspace.urlKey; } saveCredentials(credentials); } /** * Remove a workspace's credentials */ export async function removeWorkspace(urlKey: string): Promise<boolean> { const credentials = loadCredentials(); const workspace = credentials.workspaces[urlKey]; if (!workspace) { return false; } // Try to revoke the token try { if (workspace.refreshToken) { await revokeToken(workspace.refreshToken, 'refresh_token'); } else { await revokeToken(workspace.accessToken, 'access_token'); } } catch (error) { // Continue even if revocation fails console.error('Failed to revoke token:', error); } delete credentials.workspaces[urlKey]; // If this was the active workspace, pick a new one if (credentials.activeWorkspace === urlKey) { const remainingKeys = Object.keys(credentials.workspaces); credentials.activeWorkspace = remainingKeys.length > 0 ? remainingKeys[0] : null; } saveCredentials(credentials); return true; } /** * Set the active workspace */ export function setActiveWorkspace(urlKey: string): boolean { const credentials = loadCredentials(); if (!credentials.workspaces[urlKey]) { return false; } credentials.activeWorkspace = urlKey; saveCredentials(credentials); return true; } /** * Get the active workspace */ export function getActiveWorkspace(): StoredWorkspace | null { const credentials = loadCredentials(); if (!credentials.activeWorkspace) { return null; } return credentials.workspaces[credentials.activeWorkspace] || null; } /** * List all stored workspaces */ export function listWorkspaces(): StoredWorkspace[] { const credentials = loadCredentials(); return Object.values(credentials.workspaces); } /** * Get workspace by urlKey */ export function getWorkspace(urlKey: string): StoredWorkspace | null { const credentials = loadCredentials(); return credentials.workspaces[urlKey] || null; } /** * Check if a token needs refreshing */ export function needsRefresh(workspace: StoredWorkspace): boolean { if (!workspace.refreshToken) { return false; // Can't refresh without a refresh token } return Date.now() >= workspace.expiresAt - REFRESH_BUFFER_MS; } /** * Refresh a workspace's access token */ export async function refreshWorkspaceToken(urlKey: string): Promise<StoredWorkspace | null> { const credentials = loadCredentials(); const workspace = credentials.workspaces[urlKey]; if (!workspace || !workspace.refreshToken) { return null; } try { const newTokens = await refreshAccessToken({ refreshToken: workspace.refreshToken, }); workspace.accessToken = newTokens.accessToken; workspace.refreshToken = newTokens.refreshToken || workspace.refreshToken; workspace.expiresAt = newTokens.expiresAt; workspace.scope = newTokens.scope; saveCredentials(credentials); return workspace; } catch (error) { console.error(`Failed to refresh token for ${urlKey}:`, error); return null; } } /** * Get a valid access token for the active workspace * Automatically refreshes if needed * * Priority: * 1. OAuth credentials (if available) * 2. LINEAR_API_KEY env var (fallback) */ export async function getAccessToken(workspaceUrlKey?: string): Promise<string | null> { // Check for workspace override via env var const envWorkspace = process.env.LINEAR_WORKSPACE; const credentials = loadCredentials(); const targetKey = workspaceUrlKey || envWorkspace || credentials.activeWorkspace; // Try OAuth credentials first if (targetKey) { let workspace = credentials.workspaces[targetKey]; if (workspace) { // Check if token needs refreshing if (needsRefresh(workspace)) { const refreshed = await refreshWorkspaceToken(targetKey); if (refreshed) { workspace = refreshed; } else { // Refresh failed, token might still be valid for a bit console.error('Token refresh failed, using existing token'); } } return workspace.accessToken; } } // Fall back to env var if no OAuth credentials if (process.env.LINEAR_API_KEY) { return process.env.LINEAR_API_KEY; } return null; } /** * Get the currently active workspace key */ export function getActiveWorkspaceKey(): string | null { const envWorkspace = process.env.LINEAR_WORKSPACE; if (envWorkspace) { return envWorkspace; } const credentials = loadCredentials(); return credentials.activeWorkspace; } /** * Check authentication status */ export interface AuthStatus { authenticated: boolean; method: 'env' | 'oauth' | 'none'; activeWorkspace: string | null; workspaces: Array<{ urlKey: string; name: string; isActive: boolean; expiresAt: Date | null; needsRefresh: boolean; }>; } export function getAuthStatus(): AuthStatus { const credentials = loadCredentials(); const workspaces = Object.values(credentials.workspaces).map((ws) => ({ urlKey: ws.urlKey, name: ws.name, isActive: ws.urlKey === credentials.activeWorkspace, expiresAt: ws.expiresAt ? new Date(ws.expiresAt) : null, needsRefresh: needsRefresh(ws), })); // OAuth takes priority over env var if (workspaces.length > 0) { return { authenticated: true, method: 'oauth', activeWorkspace: credentials.activeWorkspace, workspaces, }; } // Fall back to env var if (process.env.LINEAR_API_KEY) { return { authenticated: true, method: 'env', activeWorkspace: null, workspaces: [], }; } return { authenticated: false, method: 'none', activeWorkspace: null, workspaces: [], }; } /** * Clear all credentials */ export function clearAllCredentials(): void { saveCredentials({ activeWorkspace: null, workspaces: {}, }); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/bleugreen/linear-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server