Skip to main content
Glama

Linear Streamable MCP Server

by iceener
tokens.ts5.15 kB
// Avoid Node-only imports in Worker: prefer Web Crypto; fallback to Math.random. import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname } from 'node:path'; import { config } from '../config/env.ts'; export type LinearUserTokens = { access_token: string; refresh_token?: string; expires_at?: number; // epoch ms scopes?: string[]; }; export type RsTokenRecord = { rs_access_token: string; rs_refresh_token: string; created_at: number; // epoch ms linear: LinearUserTokens; }; const rsAccessToRecord = new Map<string, RsTokenRecord>(); const rsRefreshToRecord = new Map<string, RsTokenRecord>(); function persistPath(): string | null { return config.RS_TOKENS_FILE || null; } type PersistShape = { records: Array<{ rs_access_token: string; rs_refresh_token: string; created_at: number; linear: LinearUserTokens; }>; }; function loadPersisted(): void { const p = persistPath(); if (!p) { return; } try { if (!existsSync(p)) { return; } const raw = readFileSync(p, 'utf8'); const data = JSON.parse(raw) as PersistShape; if (!data || !Array.isArray(data.records)) { return; } for (const rec of data.records) { const record: RsTokenRecord = { rs_access_token: rec.rs_access_token, rs_refresh_token: rec.rs_refresh_token, created_at: rec.created_at, linear: rec.linear, }; rsAccessToRecord.set(record.rs_access_token, record); rsRefreshToRecord.set(record.rs_refresh_token, record); } } catch {} } function savePersisted(): void { const p = persistPath(); if (!p) { return; } try { const dir = dirname(p); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } const records = Array.from(rsAccessToRecord.values()).map((r) => ({ rs_access_token: r.rs_access_token, rs_refresh_token: r.rs_refresh_token, created_at: r.created_at, linear: r.linear, })); const obj: PersistShape = { records }; writeFileSync(p, JSON.stringify(obj, null, 2), 'utf8'); } catch {} } loadPersisted(); function webCryptoRandomBase64Url(bytes: number): string | null { try { const g = ( globalThis as unknown as { crypto?: { getRandomValues?: (arr: Uint8Array) => void }; } ).crypto; if (!g || typeof g.getRandomValues !== 'function') { return null; } const arr = new Uint8Array(bytes); g.getRandomValues(arr); let binary = ''; for (const byte of arr) { binary += String.fromCharCode(byte); } const base64 = typeof btoa === 'function' ? btoa(binary) : null; if (!base64) { return null; } return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); } catch { return null; } } export function generateOpaqueToken(bytes: number = 32): string { const web = webCryptoRandomBase64Url(bytes); if (web) { return web; } // Last resort (non-crypto) fallback let rand = ''; for (let i = 0; i < bytes; i++) { rand += String.fromCharCode(Math.floor(Math.random() * 256)); } const base64 = typeof btoa === 'function' ? btoa(rand) : rand; return String(base64).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); } export function storeRsTokenMapping( rsAccessToken: string, linearTokens: LinearUserTokens, rsRefreshToken?: string, ): RsTokenRecord { if (rsRefreshToken) { const existing = rsRefreshToRecord.get(rsRefreshToken); if (existing) { rsAccessToRecord.delete(existing.rs_access_token); existing.rs_access_token = rsAccessToken; existing.linear = { ...linearTokens }; rsAccessToRecord.set(rsAccessToken, existing); savePersisted(); return existing; } } const record: RsTokenRecord = { rs_access_token: rsAccessToken, rs_refresh_token: rsRefreshToken ?? generateOpaqueToken(), created_at: Date.now(), linear: { ...linearTokens }, }; rsAccessToRecord.set(record.rs_access_token, record); rsRefreshToRecord.set(record.rs_refresh_token, record); savePersisted(); return record; } export function getLinearTokensByRsToken(rsToken?: string): LinearUserTokens | null { if (!rsToken) { return null; } const rec = rsAccessToRecord.get(rsToken); return rec ? rec.linear : null; } export function getRecordByRsRefreshToken( rsRefreshToken?: string, ): RsTokenRecord | null { if (!rsRefreshToken) { return null; } return rsRefreshToRecord.get(rsRefreshToken) ?? null; } export function updateLinearTokensByRsRefreshToken( rsRefreshToken: string, newLinear: LinearUserTokens, maybeNewRsAccessToken?: string, ): RsTokenRecord | null { const rec = rsRefreshToRecord.get(rsRefreshToken); if (!rec) { return null; } if (maybeNewRsAccessToken) { rsAccessToRecord.delete(rec.rs_access_token); rec.rs_access_token = maybeNewRsAccessToken; rec.created_at = Date.now(); } rec.linear = { ...newLinear }; rsAccessToRecord.set(rec.rs_access_token, rec); rsRefreshToRecord.set(rsRefreshToken, rec); savePersisted(); return rec; }

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/iceener/linear-streamable-mcp-server'

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