Skip to main content
Glama

Linear Streamable MCP Server

by iceener
store.ts9.12 kB
// Worker-safe token and transaction store with optional KV and encryption // Falls back to in-memory maps when KV is unavailable export type LinearUserTokens = { access_token: string; refresh_token?: string; expires_at?: number; scopes?: string[]; }; type Txn = { codeChallenge: string; state?: string; scope?: string; createdAt: number; linear?: LinearUserTokens; }; let ENV: Record<string, unknown> | undefined; export function setAuthStoreEnv(env: Record<string, unknown>): void { ENV = env; } // In-memory fallback (dev) const memTxns = new Map<string, Txn>(); const memCodes = new Map<string, string>(); const memRsByAccess = new Map< string, { rs_access_token: string; rs_refresh_token: string; linear: LinearUserTokens; } >(); const memRsByRefresh = new Map< string, { rs_access_token: string; rs_refresh_token: string; linear: LinearUserTokens; } >(); // Cloudflare Workers KV type declaration (for type-checking without runtime import) // eslint-disable-next-line @typescript-eslint/no-unused-vars type KVNamespace = { get(key: string): Promise<string | null>; put( key: string, value: string, options?: { expiration?: number; expirationTtl?: number }, ): Promise<void>; delete(key: string): Promise<void>; }; function getKV(): KVNamespace | undefined { const ns = (ENV as unknown as { TOKENS?: KVNamespace })?.TOKENS; return ns; } function ttl(seconds: number): number { return Math.floor(Date.now() / 1000) + seconds; } // Basic JSON helpers function toJson(value: unknown): string { return JSON.stringify(value); } function fromJson<T>(value: string | null): T | null { if (!value) { return null; } try { return JSON.parse(value) as T; } catch { return null; } } // --- Optional application-layer encryption (AES-GCM via TOKENS_ENC_KEY) --- function b64urlEncode(bytes: Uint8Array): string { let s = ''; for (const b of bytes) { s += String.fromCharCode(b); } const b64 = btoa(s); return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); } function b64urlDecode(data: string): Uint8Array { const padded = data.replace(/-/g, '+').replace(/_/g, '/'); const bin = atob(padded); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) { out[i] = bin.charCodeAt(i); } return out; } async function getCryptoKey(): Promise<CryptoKey | undefined> { try { const secret = (ENV as unknown as { TOKENS_ENC_KEY?: string })?.TOKENS_ENC_KEY || ((globalThis as unknown as { process?: { env?: Record<string, unknown> } }) ?.process?.env?.TOKENS_ENC_KEY as string | undefined); if (!secret) { return undefined; } const raw = b64urlDecode(String(secret)); return await crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, [ 'encrypt', 'decrypt', ]); } catch { return undefined; } } async function encryptString(plain: string): Promise<string> { const key = await getCryptoKey(); if (!key) { return plain; // no-op without configured key } const iv = crypto.getRandomValues(new Uint8Array(12)); const enc = new TextEncoder().encode(plain); const ctBuf = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, enc); const ct = b64urlEncode(new Uint8Array(ctBuf)); const ivb64 = b64urlEncode(iv); return JSON.stringify({ alg: 'A256GCM', iv: ivb64, ct }); } async function decryptString(stored: string): Promise<string> { try { const obj = JSON.parse(stored) as { alg?: string; iv?: string; ct?: string; }; if (!obj || obj.alg !== 'A256GCM' || !obj.iv || !obj.ct) { return stored; } const key = await getCryptoKey(); if (!key) { return stored; } const iv = b64urlDecode(obj.iv); const ct = b64urlDecode(obj.ct); const ptBuf = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct); return new TextDecoder().decode(ptBuf); } catch { return stored; } } async function kvPutJson( key: string, value: unknown, options?: { expiration?: number; expirationTtl?: number }, ) { const kv = getKV(); const raw = await encryptString(toJson(value)); await kv?.put(key, raw, options); } async function kvGetJson<T>(key: string): Promise<T | null> { const kv = getKV(); if (!kv) { return null; } const raw = await kv.get(key); if (raw == null) { return null; } const plain = await decryptString(raw); return fromJson<T>(plain); } async function kvPutString( key: string, value: string, options?: { expiration?: number; expirationTtl?: number }, ) { await kvPutJson(key, { v: value }, options); } async function kvGetString(key: string): Promise<string | null> { const obj = await kvGetJson<{ v: string }>(key); return obj?.v ?? null; } // Transactions (PKCE) export async function saveTransaction( txnId: string, txn: Txn, ttlSeconds = 600, ): Promise<void> { const kv = getKV(); if (kv) { await kvPutJson(`txn:${txnId}`, txn, { expiration: ttl(ttlSeconds) }); return; } memTxns.set(txnId, txn); } export async function getTransaction(txnId: string): Promise<Txn | null> { const kv = getKV(); if (kv) { return await kvGetJson<Txn>(`txn:${txnId}`); } return memTxns.get(txnId) ?? null; } export async function deleteTransaction(txnId: string): Promise<void> { const kv = getKV(); if (kv) { await kv.delete(`txn:${txnId}`); return; } memTxns.delete(txnId); } // Auth codes → txnId mapping export async function saveCode( code: string, txnId: string, ttlSeconds = 600, ): Promise<void> { const kv = getKV(); if (kv) { await kvPutString(`code:${code}`, txnId, { expiration: ttl(ttlSeconds) }); return; } memCodes.set(code, txnId); } export async function getTxnIdByCode(code: string): Promise<string | null> { const kv = getKV(); if (kv) { return (await kvGetString(`code:${code}`)) || null; } return memCodes.get(code) ?? null; } export async function deleteCode(code: string): Promise<void> { const kv = getKV(); if (kv) { await kv.delete(`code:${code}`); return; } memCodes.delete(code); } // RS ↔ Linear token mapping export async function storeRsTokenMapping( rsAccessToken: string, linearTokens: LinearUserTokens, rsRefreshToken?: string, ): Promise<void> { const kv = getKV(); const rec = { rs_access_token: rsAccessToken, rs_refresh_token: rsRefreshToken || crypto.randomUUID(), linear: linearTokens, }; if (kv) { await Promise.all([ kvPutJson(`rs:access:${rec.rs_access_token}`, rec), kvPutJson(`rs:refresh:${rec.rs_refresh_token}`, rec), ]); return; } memRsByAccess.set(rec.rs_access_token, rec); memRsByRefresh.set(rec.rs_refresh_token, rec); } export async function getRecordByRsRefreshToken(rsRefreshToken?: string): Promise<{ rs_access_token: string; rs_refresh_token: string; linear: LinearUserTokens; } | null> { if (!rsRefreshToken) { return null; } const kv = getKV(); if (kv) { const rec = await kvGetJson<{ rs_access_token: string; rs_refresh_token: string; linear: LinearUserTokens; }>(`rs:refresh:${rsRefreshToken}`); return rec; } return memRsByRefresh.get(rsRefreshToken) ?? null; } export async function updateLinearTokensByRsRefreshToken( rsRefreshToken: string, newLinear: LinearUserTokens, maybeNewRsAccessToken?: string, ): Promise<{ rs_access_token: string; rs_refresh_token: string; linear: LinearUserTokens; } | null> { const kv = getKV(); if (kv) { const existing = await kvGetJson<{ rs_access_token: string; rs_refresh_token: string; linear: LinearUserTokens; }>(`rs:refresh:${rsRefreshToken}`); if (!existing) { return null; } const next = { rs_access_token: maybeNewRsAccessToken || existing.rs_access_token, rs_refresh_token: rsRefreshToken, linear: newLinear, }; await Promise.all([ kv.delete(`rs:access:${existing.rs_access_token}`), kvPutJson(`rs:access:${next.rs_access_token}`, next), kvPutJson(`rs:refresh:${rsRefreshToken}`, next), ]); return next; } const existing = memRsByRefresh.get(rsRefreshToken); if (!existing) { return null; } if (maybeNewRsAccessToken) { memRsByAccess.delete(existing.rs_access_token); existing.rs_access_token = maybeNewRsAccessToken; } existing.linear = { ...newLinear }; memRsByAccess.set(existing.rs_access_token, existing); memRsByRefresh.set(rsRefreshToken, existing); return existing; } export async function getLinearTokensByRsAccessToken( rsAccessToken?: string, ): Promise<LinearUserTokens | null> { if (!rsAccessToken) { return null; } const kv = getKV(); if (kv) { const rec = await kvGetJson<{ rs_access_token: string; rs_refresh_token: string; linear: LinearUserTokens; }>(`rs:access:${rsAccessToken}`); return rec?.linear ?? null; } const mem = memRsByAccess.get(rsAccessToken); return mem?.linear ?? null; }

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