Skip to main content
Glama
iceener

Linear Streamable MCP Server

by iceener
resolvers.ts9.03 kB
/** * Resolvers for human-readable names → Linear IDs * Allows using "High" instead of 2, "Done" instead of UUID, etc. */ import type { LinearClient } from '@linear/sdk'; // ───────────────────────────────────────────────────────────────────────────── // Priority Resolution // ───────────────────────────────────────────────────────────────────────────── const PRIORITY_MAP: Record<string, number> = { none: 0, urgent: 1, high: 2, medium: 3, normal: 3, low: 4, }; const PRIORITY_NAMES: Record<number, string> = { 0: 'None', 1: 'Urgent', 2: 'High', 3: 'Medium', 4: 'Low', }; export type PriorityInput = number | string; export interface ResolveResult<T> { success: true; value: T; } export interface ResolveError { success: false; error: string; suggestions?: string[]; } export type ResolverResult<T> = ResolveResult<T> | ResolveError; /** * Resolve priority from number or string * Accepts: 0-4, "None", "Urgent", "High", "Medium", "Normal", "Low" */ export function resolvePriority(input: PriorityInput): ResolverResult<number> { if (typeof input === 'number') { if (input >= 0 && input <= 4) { return { success: true, value: input }; } return { success: false, error: `Invalid priority number: ${input}. Must be 0-4.`, suggestions: ['0=None, 1=Urgent, 2=High, 3=Medium, 4=Low'], }; } const normalized = input.toLowerCase().trim(); const value = PRIORITY_MAP[normalized]; if (value !== undefined) { return { success: true, value }; } return { success: false, error: `Unknown priority: "${input}"`, suggestions: ['Valid values: None, Urgent, High, Medium, Normal, Low (or 0-4)'], }; } /** * Format priority number as human-readable string */ export function formatPriorityName(priority: number): string { return PRIORITY_NAMES[priority] ?? `P${priority}`; } // ───────────────────────────────────────────────────────────────────────────── // State Resolution // ───────────────────────────────────────────────────────────────────────────── export type StateType = 'backlog' | 'unstarted' | 'started' | 'completed' | 'canceled'; const VALID_STATE_TYPES: StateType[] = ['backlog', 'unstarted', 'started', 'completed', 'canceled']; export interface StateInfo { id: string; name: string; type: StateType; } /** * Resolve state by name or type within a team */ export async function resolveState( client: LinearClient, teamId: string, input: { stateName?: string; stateType?: StateType }, ): Promise<ResolverResult<string>> { if (!input.stateName && !input.stateType) { return { success: false, error: 'Must provide stateName or stateType' }; } // Validate stateType if provided if (input.stateType && !VALID_STATE_TYPES.includes(input.stateType)) { return { success: false, error: `Invalid stateType: "${input.stateType}"`, suggestions: [`Valid types: ${VALID_STATE_TYPES.join(', ')}`], }; } try { const team = await client.team(teamId); const states = await team.states(); const stateList = states.nodes as StateInfo[]; // Try to match by name first (exact, case-insensitive) if (input.stateName) { const normalized = input.stateName.toLowerCase().trim(); const match = stateList.find((s) => s.name.toLowerCase() === normalized); if (match) { return { success: true, value: match.id }; } // Fuzzy match suggestions const similar = stateList .filter((s) => s.name.toLowerCase().includes(normalized) || normalized.includes(s.name.toLowerCase())) .map((s) => s.name); return { success: false, error: `State "${input.stateName}" not found in team`, suggestions: similar.length > 0 ? [`Did you mean: ${similar.join(', ')}?`] : [`Available states: ${stateList.map((s) => s.name).join(', ')}`], }; } // Match by type if (input.stateType) { const matches = stateList.filter((s) => s.type === input.stateType); if (matches.length === 1) { return { success: true, value: matches[0].id }; } if (matches.length > 1) { // Multiple states of same type - return first one but warn // This is common (e.g., multiple "completed" states like "Done", "Merged") return { success: true, value: matches[0].id }; } return { success: false, error: `No state with type "${input.stateType}" found in team`, suggestions: [`Available types in this team: ${[...new Set(stateList.map((s) => s.type))].join(', ')}`], }; } return { success: false, error: 'Unexpected resolver state' }; } catch (e) { return { success: false, error: `Failed to fetch team states: ${(e as Error).message}`, }; } } /** * Get team ID from an issue (for update operations) */ export async function getIssueTeamId(client: LinearClient, issueId: string): Promise<string | null> { try { const issue = await client.issue(issueId); const team = await issue.team; return team?.id ?? null; } catch { return null; } } // ───────────────────────────────────────────────────────────────────────────── // Label Resolution // ───────────────────────────────────────────────────────────────────────────── export interface LabelInfo { id: string; name: string; } /** * Resolve label names to IDs within a team */ export async function resolveLabels( client: LinearClient, teamId: string, labelNames: string[], ): Promise<ResolverResult<string[]>> { if (labelNames.length === 0) { return { success: true, value: [] }; } try { const team = await client.team(teamId); const labels = await team.labels(); const labelList = labels.nodes as LabelInfo[]; const resolved: string[] = []; const notFound: string[] = []; for (const name of labelNames) { const normalized = name.toLowerCase().trim(); const match = labelList.find((l) => l.name.toLowerCase() === normalized); if (match) { resolved.push(match.id); } else { notFound.push(name); } } if (notFound.length > 0) { return { success: false, error: `Labels not found: ${notFound.join(', ')}`, suggestions: [`Available labels: ${labelList.map((l) => l.name).join(', ')}`], }; } return { success: true, value: resolved }; } catch (e) { return { success: false, error: `Failed to fetch team labels: ${(e as Error).message}`, }; } } // ───────────────────────────────────────────────────────────────────────────── // Project Resolution // ───────────────────────────────────────────────────────────────────────────── /** * Resolve project by name */ export async function resolveProject( client: LinearClient, projectName: string, ): Promise<ResolverResult<string>> { try { const projects = await client.projects({ first: 100 }); const normalized = projectName.toLowerCase().trim(); const exactMatch = projects.nodes.find( (p) => p.name.toLowerCase() === normalized, ); if (exactMatch) { return { success: true, value: exactMatch.id }; } // Partial match suggestions const similar = projects.nodes .filter((p) => p.name.toLowerCase().includes(normalized)) .map((p) => p.name) .slice(0, 5); return { success: false, error: `Project "${projectName}" not found`, suggestions: similar.length > 0 ? [`Similar projects: ${similar.join(', ')}`] : ['Use workspace_metadata to list available projects'], }; } catch (e) { return { success: false, error: `Failed to fetch projects: ${(e as Error).message}`, }; } }

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

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