Skip to main content
Glama

Linear Streamable MCP Server

by iceener
worker.ts25.8 kB
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { Router } from 'itty-router'; import { type ZodTypeAny, z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { deleteCode, deleteTransaction, getLinearTokensByRsAccessToken, getRecordByRsRefreshToken, getTransaction, getTxnIdByCode, saveCode, saveTransaction, setAuthStoreEnv, storeRsTokenMapping, updateLinearTokensByRsRefreshToken, } from './auth/store.ts'; import { loadConfig } from './config/env.ts'; import { serverMetadata } from './config/metadata.ts'; import { runWithRequestContext } from './core/context.ts'; import { registerTools } from './tools/index.ts'; // Minimal MCP constants function getProtocolVersion(): string { const cfg = loadConfig(); return cfg.MCP_PROTOCOL_VERSION || '2025-06-18'; } const MCP_ENDPOINT_PATH = '/mcp'; // --- PKCE helper --- async function sha256B64Url(input: string): Promise<string> { const data = new TextEncoder().encode(input); const digest = await crypto.subtle.digest('SHA-256', data); const bytes = new Uint8Array(digest); let binary = ''; for (const byte of bytes) { binary += String.fromCharCode(byte); } const base64 = btoa(binary); return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); } type ToolRecord = { name: string; title?: string; description?: string; inputSchema: Record<string, unknown>; handler: (args: Record<string, unknown>) => Promise<{ content?: Array<unknown>; structuredContent?: unknown; isError?: boolean; }>; }; // Build an adapter that captures registrations from existing tools const tools: Record<string, ToolRecord> = {}; type RegisterSchema = { description?: string; inputSchema: Record<string, unknown>; annotations?: { title?: string }; }; type RegisterHandler = (args: unknown) => Promise<unknown>; type MinimalServer = { registerTool: ( name: string, schema: RegisterSchema, handler: RegisterHandler, ) => void; }; const adapter: MinimalServer = { registerTool( name: string, schema: RegisterSchema, handler: (args: Record<string, unknown>) => Promise<unknown>, ) { function toJsonSchema(input: unknown): Record<string, unknown> { try { // Already JSON schema-ish if ( input && typeof input === 'object' && ('$schema' in (input as Record<string, unknown>) || 'type' in (input as Record<string, unknown>)) ) { return input as Record<string, unknown>; } // Zod object or any Zod type const isZodType = typeof input === 'object' && input !== null && '_def' in (input as Record<string, unknown>); if (isZodType) { const json = zodToJsonSchema(input as ZodTypeAny, { $refStrategy: 'none', }); return json as unknown as Record<string, unknown>; } // Zod shape (Record<string, ZodTypeAny>) if (input && typeof input === 'object') { const values = Object.values(input as Record<string, unknown>); const looksLikeShape = values.length > 0 && values.every((v) => { return ( v && typeof v === 'object' && '_def' in (v as Record<string, unknown>) ); }); if (looksLikeShape) { const obj = z.object(input as Record<string, ZodTypeAny>); const json = zodToJsonSchema(obj, { $refStrategy: 'none' }); return json as unknown as Record<string, unknown>; } } } catch {} // Fallback: return as-is return (input ?? {}) as Record<string, unknown>; } const wrappedHandler: ToolRecord['handler'] = async (args) => { const result = await handler(args); return result as { content?: Array<unknown>; structuredContent?: unknown; isError?: boolean; }; }; tools[name] = { name, title: schema.annotations?.title, description: schema.description, inputSchema: toJsonSchema(schema.inputSchema), handler: wrappedHandler, }; }, }; // Register all tools into the adapter registry registerTools(adapter as unknown as McpServer); // Small helpers function _json(body: unknown, init?: ResponseInit): Response { return new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json; charset=utf-8' }, ...init, }); } function _b64urlEncodeString(input: string): string { const base64 = btoa(input); return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); } function _b64urlDecodeString(value: string): string | null { try { const padded = value.replace(/-/g, '+').replace(/_/g, '/'); return atob(padded); } catch { return null; } } type JsonRpcId = string | number; type JsonRpcRequest = { jsonrpc: '2.0'; id?: JsonRpcId; method: string; params?: Record<string, unknown>; }; function ok(id: JsonRpcId, result: unknown): Response { return new Response(JSON.stringify({ jsonrpc: '2.0', id, result }), { headers: { 'content-type': 'application/json; charset=utf-8' }, }); } function error(id: JsonRpcId | undefined, code: number, message: string): Response { return new Response( JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } }), { headers: { 'content-type': 'application/json; charset=utf-8' }, }, ); } function withCors(resp: Response): Response { const headers = new Headers(resp.headers); headers.set('Access-Control-Allow-Origin', '*'); headers.set('Access-Control-Allow-Methods', 'POST, GET, OPTIONS'); headers.set( 'Access-Control-Allow-Headers', 'Content-Type, Accept, Authorization, MCP-Protocol-Version, Mcp-Session-Id', ); headers.set('Access-Control-Max-Age', '86400'); return new Response(resp.body, { status: resp.status, headers }); } function isAllowedRedirectUri(uri: string): boolean { try { const cfg = loadConfig(); if (cfg.OAUTH_REDIRECT_ALLOW_ALL) { return true; } const allowListRaw = cfg.OAUTH_REDIRECT_ALLOWLIST || ''; const allowed = new Set( allowListRaw .split(',') .map((v) => v.trim()) .filter(Boolean) .concat([cfg.OAUTH_REDIRECT_URI]), ); const url = new URL(uri); if (cfg.NODE_ENV === 'development') { const loopbackHosts = new Set(['localhost', '127.0.0.1', '::1']); if (loopbackHosts.has(url.hostname)) { return true; } } return ( allowed.has(`${url.protocol}//${url.host}${url.pathname}`) || allowed.has(uri) ); } catch { return false; } } const router = Router(); router.options(MCP_ENDPOINT_PATH, async () => withCors(new Response(null, { status: 204 })), ); router.post(MCP_ENDPOINT_PATH, async (request: Request) => { // Capture request headers (lowercased) for downstream Linear client usage const headerRecord: Record<string, string> = {}; request.headers.forEach((value, key) => { headerRecord[String(key).toLowerCase()] = String(value); }); // Session correlation for challenges const incomingSid = request.headers.get('Mcp-Session-Id'); const sid = incomingSid?.trim() ? incomingSid : crypto.randomUUID(); // Helper to send a WWW-Authenticate challenge with sid const challenge = (origin: string): Response => { const resourceMd = `${origin}/.well-known/oauth-protected-resource?sid=${encodeURIComponent( sid, )}`; const resp = new Response( JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message: 'Unauthorized' }, id: null, }), { status: 401 }, ); resp.headers.set( 'WWW-Authenticate', `Bearer realm="MCP", authorization_uri="${resourceMd}"`, ); resp.headers.set('Mcp-Session-Id', sid); return withCors(resp); }; // RS-only handling and rewrite logic const cfg = loadConfig(); const authHeaderIn = headerRecord.authorization; const apiKeyHeader = headerRecord['x-api-key'] || headerRecord['x-auth-token']; // Missing Authorization and API key → challenge when auth enabled if (cfg.AUTH_ENABLED && !authHeaderIn && !apiKeyHeader) { const origin = new URL(request.url).origin; return challenge(origin); } let rsMapped = false; let bearer: string | undefined; if (authHeaderIn) { const m = authHeaderIn.match(/^\s*Bearer\s+(.+)$/i); bearer = m?.[1]; if (bearer) { try { const mapped = await getLinearTokensByRsAccessToken(bearer); if (mapped?.access_token) { headerRecord.authorization = `Bearer ${mapped.access_token}`; rsMapped = true; } } catch {} } } // In RS-only mode, unknown bearer → challenge (unless Linear bearer fallback allowed) if ( cfg.AUTH_ENABLED && cfg.AUTH_REQUIRE_RS && bearer && !rsMapped && !cfg.AUTH_ALLOW_LINEAR_BEARER ) { const origin = new URL(request.url).origin; return challenge(origin); } return runWithRequestContext({ authHeaders: headerRecord }, async () => { const raw = await request.text(); const payload = (raw ? JSON.parse(raw) : {}) as JsonRpcRequest; if (payload?.jsonrpc !== '2.0' || typeof payload.method !== 'string') { return withCors(new Response('Bad Request', { status: 400 })); } const { id, method, params } = payload; if (!('id' in payload) || typeof id === 'undefined') { return withCors(new Response(null, { status: 202 })); } if (method === 'initialize') { return withCors( ok(id, { protocolVersion: getProtocolVersion(), capabilities: { tools: { listChanged: true } }, serverInfo: { name: serverMetadata.title, title: serverMetadata.title, version: loadConfig().MCP_VERSION, }, instructions: serverMetadata.instructions, }), ); } if (method === 'tools/list') { const list = Object.values(tools).map((t) => ({ name: t.name, title: t.title, description: t.description, inputSchema: t.inputSchema, })); return withCors(ok(id, { tools: list })); } if (method === 'resources/list') { return withCors(ok(id, { resources: [] })); } if (method === 'prompts/list') { return withCors(ok(id, { prompts: [] })); } if (method === 'tools/call') { const nameValue = (params as Record<string, unknown> | undefined)?.name; const name = typeof nameValue === 'string' ? nameValue : undefined; const argsValue = (params as Record<string, unknown> | undefined)?.arguments; const args = typeof argsValue === 'object' && argsValue !== null && !Array.isArray(argsValue) ? (argsValue as Record<string, unknown>) : ({} as Record<string, unknown>); if (!name || !tools[name]) { return withCors(error(id, -32602, `Unknown tool: ${String(name)}`)); } try { const tool = tools[name]; const result = await tool?.handler(args); return withCors(ok(id, result)); } catch (e) { return withCors( ok(id, { isError: true, content: [{ type: 'text', text: `Tool failed: ${(e as Error).message}` }], }), ); } } return withCors(error(id, -32601, `Method not found: ${method}`)); }); }); router.get(MCP_ENDPOINT_PATH, async () => withCors(new Response('Method Not Allowed', { status: 405 })), ); router.get('/health', async () => withCors( new Response(JSON.stringify({ status: 'ok' }), { headers: { 'content-type': 'application/json; charset=utf-8' }, }), ), ); // Authorization Server discovery (RFC8414) and Protected Resource discovery (RFC9728) router.get('/.well-known/oauth-authorization-server', async (request: Request) => { const base = new URL(request.url).origin; return withCors( new Response( JSON.stringify({ issuer: base, authorization_endpoint: `${base}/authorize`, token_endpoint: `${base}/token`, registration_endpoint: `${base}/register`, response_types_supported: ['code'], grant_types_supported: ['authorization_code', 'refresh_token'], code_challenge_methods_supported: ['S256'], token_endpoint_auth_methods_supported: ['none'], scopes_supported: ['mcp'], }), { headers: { 'content-type': 'application/json; charset=utf-8' } }, ), ); }); router.get('/.well-known/oauth-protected-resource', async (request: Request) => { const here = new URL(request.url); const base = here.origin; const sid = here.searchParams.get('sid') ?? undefined; const resourceBase = `${base}${MCP_ENDPOINT_PATH}`; const resourceUrl = (() => { try { if (!sid) { return resourceBase; } const u = new URL(resourceBase); u.searchParams.set('sid', sid); return u.toString(); } catch { return resourceBase; } })(); return withCors( new Response( JSON.stringify({ authorization_servers: [`${base}/.well-known/oauth-authorization-server`], resource: resourceUrl, }), { headers: { 'content-type': 'application/json; charset=utf-8' } }, ), ); }); // Minimal /authorize and /token to satisfy dev OAuth flows router.get('/authorize', async (request: Request) => { const url = new URL(request.url); const state = url.searchParams.get('state') ?? undefined; const codeChallenge = url.searchParams.get('code_challenge'); const codeChallengeMethod = url.searchParams.get('code_challenge_method'); const redirectUri = url.searchParams.get('redirect_uri'); const scope = url.searchParams.get('scope') ?? undefined; if (!redirectUri) { return withCors(new Response('invalid_request: redirect_uri', { status: 400 })); } if (!codeChallenge || codeChallengeMethod !== 'S256') { return withCors(new Response('invalid_request: pkce', { status: 400 })); } const txnId = crypto.randomUUID(); await saveTransaction(txnId, { codeChallenge, state, scope, createdAt: Date.now(), }); // If upstream OAuth is configured, redirect to upstream (e.g., Linear/GitHub) const cfg = loadConfig(); if (cfg.OAUTH_AUTHORIZATION_URL && cfg.OAUTH_CLIENT_ID) { const here = new URL(request.url); const base = here.origin; const cb = new URL('/linear/callback', base); const authUrl = new URL(cfg.OAUTH_AUTHORIZATION_URL); authUrl.searchParams.set('response_type', 'code'); authUrl.searchParams.set('client_id', cfg.OAUTH_CLIENT_ID); authUrl.searchParams.set('redirect_uri', cb.toString()); const oauthScopes = (cfg.OAUTH_SCOPES || '') .split(/[\s,]+/) .map((s) => s.trim()) .filter(Boolean) .join(' '); const scopeToUse = oauthScopes || scope || ''; if (scopeToUse) { authUrl.searchParams.set('scope', scopeToUse); } // base64url encode composite state: txn + original client state + client redirect const composite = btoa(JSON.stringify({ tid: txnId, cs: state, cr: redirectUri })) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/g, ''); authUrl.searchParams.set('state', composite || txnId); return withCors(Response.redirect(authUrl.toString(), 302)); } const code = crypto.randomUUID(); await saveCode(code, txnId); const target = isAllowedRedirectUri(redirectUri) ? redirectUri : cfg.OAUTH_REDIRECT_URI; const redirect = new URL(target); redirect.searchParams.set('code', code); if (state) { redirect.searchParams.set('state', state); } return withCors(Response.redirect(redirect.toString(), 302)); }); // Dynamic Client Registration (minimal, public client) router.post('/register', async (request: Request) => { const base = new URL(request.url).origin; const now = Math.floor(Date.now() / 1000); const client_id = crypto.randomUUID(); const ct = request.headers.get('content-type') || ''; let body: Record<string, unknown> = {}; try { if (ct.includes('application/json')) { body = (await request.json()) as Record<string, unknown>; } else if (ct.includes('application/x-www-form-urlencoded')) { const form = new URLSearchParams(await request.text()); body = Object.fromEntries(form.entries()); } } catch {} const redirect_urisRaw = (body.redirect_uris as unknown) ?? []; const redirect_uris = Array.isArray(redirect_urisRaw) ? redirect_urisRaw.filter((u) => typeof u === 'string') : typeof redirect_urisRaw === 'string' ? [redirect_urisRaw] : []; const _token_endpoint_auth_method = (body.token_endpoint_auth_method as string) || 'none'; const grant_typesRaw = (body.grant_types as unknown) ?? undefined; const grant_types = Array.isArray(grant_typesRaw) ? (grant_typesRaw as unknown[]).filter((v) => typeof v === 'string') : ['authorization_code', 'refresh_token']; const response_typesRaw = (body.response_types as unknown) ?? undefined; const response_types = Array.isArray(response_typesRaw) ? (response_typesRaw as unknown[]).filter((v) => typeof v === 'string') : ['code']; const client_name = typeof body.client_name === 'string' ? (body.client_name as string) : undefined; return withCors( new Response( JSON.stringify({ client_id, client_id_issued_at: now, client_secret_expires_at: 0, token_endpoint_auth_method: 'none', registration_client_uri: `${base}/register/${client_id}`, registration_access_token: crypto.randomUUID(), redirect_uris, grant_types, response_types, ...(client_name ? { client_name } : {}), }), { headers: { 'content-type': 'application/json; charset=utf-8' } }, ), ); }); // Upstream callback: exchange code → upstream tokens, then issue AS code back to client router.get('/linear/callback', async (request: Request) => { try { const here = new URL(request.url); const code = here.searchParams.get('code'); const state = here.searchParams.get('state'); if (!code || !state) { return withCors(new Response('invalid_callback', { status: 400 })); } let decoded: { tid?: string; cs?: string; cr?: string } = {}; try { const padded = state.replace(/-/g, '+').replace(/_/g, '/'); decoded = JSON.parse(atob(padded)) as typeof decoded; } catch {} const txnId = decoded.tid || state; const txn = await getTransaction(txnId); if (!txn) { return withCors(new Response('unknown_txn', { status: 400 })); } // Exchange with upstream provider if configured const cfg = loadConfig(); const tokenUrl = cfg.OAUTH_TOKEN_URL || 'https://api.linear.app/oauth/token'; const cbBase = here.origin; const callbackRedirect = new URL('/linear/callback', cbBase).toString(); const form = new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: callbackRedirect, client_id: cfg.OAUTH_CLIENT_ID || '', client_secret: cfg.OAUTH_CLIENT_SECRET || '', }); const resp = await fetch(tokenUrl, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: form.toString(), }); if (!resp.ok) { const t = await resp.text().catch(() => ''); return withCors( new Response(`linear_token_error: ${resp.status} ${t}`.trim(), { status: 500, }), ); } const data = (await resp.json()) as { access_token?: string; refresh_token?: string; expires_in?: number | string; scope?: string | string[]; token_type?: string; }; const access_token = String(data.access_token || ''); if (!access_token) { return withCors(new Response('linear_no_token', { status: 500 })); } const scopes = Array.isArray(data.scope) ? data.scope : String(data.scope || '') .split(/[\s,]+/) .filter(Boolean); (txn as unknown as { linear?: unknown }).linear = { access_token, refresh_token: data.refresh_token, expires_at: Date.now() + Number(data.expires_in ?? 3600) * 1000, scopes, }; const asCode = crypto.randomUUID(); await Promise.all([saveCode(asCode, txnId), saveTransaction(txnId, txn)]); const cfg2 = loadConfig(); const clientRedirect = decoded.cr || cfg2.OAUTH_REDIRECT_URI; const safe = isAllowedRedirectUri(clientRedirect) ? clientRedirect : cfg2.OAUTH_REDIRECT_URI; if (!safe || String(safe).trim() === '') { return withCors(new Response('redirect_not_allowed', { status: 400 })); } const redirect = new URL(safe); redirect.searchParams.set('code', asCode); if (decoded.cs) { redirect.searchParams.set('state', decoded.cs); } return withCors(Response.redirect(redirect.toString(), 302)); } catch { return withCors(new Response('linear_callback_error', { status: 500 })); } }); router.post('/token', async (request: Request) => { const contentType = request.headers.get('content-type') || ''; const params = contentType.includes('application/x-www-form-urlencoded') ? new URLSearchParams(await request.text()) : new URLSearchParams( (await request.json().catch(() => ({}))) as Record<string, string>, ); const grant = params.get('grant_type'); if (grant === 'refresh_token') { const rsRefresh = params.get('refresh_token') || ''; const rec = await getRecordByRsRefreshToken(rsRefresh); if (!rec) { return withCors( new Response(JSON.stringify({ error: 'invalid_grant' }), { status: 400, }), ); } const newAccess = crypto.randomUUID(); const updated = await updateLinearTokensByRsRefreshToken( rsRefresh, rec.linear, newAccess, ); return withCors( new Response( JSON.stringify({ access_token: newAccess, refresh_token: rsRefresh, token_type: 'bearer', expires_in: 3600, scope: (updated?.linear.scopes || []).join(' '), }), { headers: { 'content-type': 'application/json; charset=utf-8' } }, ), ); } if (grant !== 'authorization_code') { return withCors( new Response(JSON.stringify({ error: 'unsupported_grant_type' }), { status: 400, }), ); } const code = params.get('code') || ''; const codeVerifier = params.get('code_verifier') || ''; const txnId = await getTxnIdByCode(code); if (!txnId) { return withCors( new Response(JSON.stringify({ error: 'invalid_grant' }), { status: 400 }), ); } const txn = await getTransaction(txnId); if (!txn) { return withCors( new Response(JSON.stringify({ error: 'invalid_grant' }), { status: 400 }), ); } const expected = txn.codeChallenge; const actual = await (async () => { // Only accept PKCE S256; incoming value is S256(code_verifier) return sha256B64Url(codeVerifier); })(); if (expected !== actual) { return withCors( new Response(JSON.stringify({ error: 'invalid_grant' }), { status: 400 }), ); } // If upstream tokens are attached to txn, map RS tokens; else dev-only tokens const rsAccess = crypto.randomUUID(); const rsRefresh = crypto.randomUUID(); const linearTokens = ( txn as unknown as { linear?: { access_token: string; refresh_token?: string; expires_at?: number; scopes?: string[]; }; } ).linear as | { access_token: string; refresh_token?: string; expires_at?: number; scopes?: string[]; } | undefined; if (linearTokens?.access_token) { await storeRsTokenMapping(rsAccess, linearTokens, rsRefresh); } await Promise.all([deleteTransaction(txnId), deleteCode(code)]); return withCors( new Response( JSON.stringify({ access_token: rsAccess, refresh_token: rsRefresh, token_type: 'bearer', expires_in: 3600, scope: (linearTokens?.scopes || []).join(' ') || txn.scope || (loadConfig().OAUTH_SCOPES || '').trim(), }), { headers: { 'content-type': 'application/json; charset=utf-8' } }, ), ); }); router.all('*', () => withCors(new Response('Not Found', { status: 404 }))); export default { fetch(request: Request, env?: Record<string, unknown>): Promise<Response> | Response { if (env) { const g = globalThis as unknown as { process?: { env?: Record<string, unknown> }; }; const existingEnv = (g.process?.env ?? {}) as Record<string, unknown>; g.process = g.process || {}; g.process.env = { ...existingEnv, ...(env as Record<string, unknown>) }; setAuthStoreEnv(env); } const url = new URL(request.url); // If clients are configured with the base URL without "/mcp", forward POSTs to the MCP endpoint if (url.pathname === '/' && request.method.toUpperCase() === 'POST') { const forwarded = new Request( new URL(MCP_ENDPOINT_PATH, url).toString(), request, ); return router.handle(forwarded); } if (url.pathname === '/') { return withCors( new Response( JSON.stringify({ message: 'Linear MCP Worker', endpoint: MCP_ENDPOINT_PATH, protocolVersion: getProtocolVersion(), }), { headers: { 'content-type': 'application/json; charset=utf-8' } }, ), ); } return router.handle(request); }, };

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