Skip to main content
Glama

Linear Streamable MCP Server

by iceener
account.tool.ts14.6 kB
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { config } from '../config/env.ts'; import { toolsMetadata } from '../config/metadata.ts'; import { AccountInputSchema } from '../schemas/inputs.ts'; import { AccountOutputSchema } from '../schemas/outputs.ts'; import { getLinearClient } from '../services/linear-client.ts'; import { previewLinesFromItems, summarizeList } from '../utils/messages.ts'; type TeamLike = { id: string; key?: string; name: string; description?: string; defaultIssueEstimate?: number; cyclesEnabled?: boolean; issueEstimationAllowZero?: boolean; issueEstimationExtended?: boolean; issueEstimationType?: string; states: () => Promise<{ nodes: Array<{ id: string; name: string; type?: string }>; }>; labels: (args: { first: number }) => Promise<{ nodes: Array<{ id: string; name: string; color?: string; description?: string; }>; }>; projects: (args: { first: number }) => Promise<{ nodes: Array<{ id: string; name: string; state?: string; lead?: { id?: string }; targetDate?: string; createdAt?: Date | string; }>; }>; }; export const accountTool = { name: toolsMetadata.workspace_metadata.name, title: toolsMetadata.workspace_metadata.title, description: toolsMetadata.workspace_metadata.description, inputSchema: AccountInputSchema.shape, handler: async (args: unknown): Promise<CallToolResult> => { const parsed = AccountInputSchema.safeParse(args); if (!parsed.success) { return { isError: true, content: [{ type: 'text', text: parsed.error.message }], }; } const include = parsed.data.include ?? [ 'profile', 'teams', 'workflow_states', 'labels', 'projects', ]; const teamIdsFilter = new Set(parsed.data.teamIds ?? []); const client = getLinearClient(); const result: Record<string, unknown> = {}; if (include.includes('profile')) { const viewer = await client.viewer; result.viewer = { id: viewer.id, name: viewer.name ?? undefined, email: viewer.email ?? undefined, displayName: viewer.displayName ?? undefined, avatarUrl: viewer.avatarUrl ?? undefined, timezone: viewer.timezone ?? undefined, createdAt: viewer.createdAt?.toString(), }; } let teams: TeamLike[] = []; let statesByTeamComputed: Record< string, Array<{ id: string; name: string; type?: string }> > = {}; let labelsByTeamComputed: Record< string, Array<{ id: string; name: string; color?: string; description?: string }> > = {}; let projectsLocalCount = 0; if ( include.includes('teams') || include.includes('workflow_states') || include.includes('labels') || include.includes('projects') ) { if (teamIdsFilter.size) { const ids = Array.from(teamIdsFilter); const fetched: TeamLike[] = []; for (const id of ids) { try { const t = (await client.team(id)) as unknown as TeamLike; fetched.push(t); } catch (_error) { // Non-fatal: continue collecting other teams // Consider exposing partial errors in structured content in the future } } teams = fetched; } else { const teamConn = (await client.teams({ first: 100 })) as unknown as { nodes: TeamLike[]; }; teams = teamConn.nodes as TeamLike[]; } if (include.includes('teams')) { result.teams = teams.map((t) => ({ id: t.id, key: t.key ?? undefined, name: t.name, description: t.description ?? undefined, defaultIssueEstimate: t.defaultIssueEstimate ?? undefined, cyclesEnabled: (t as Record<string, unknown>)?.cyclesEnabled as | boolean | undefined, issueEstimationAllowZero: (t as Record<string, unknown>) ?.issueEstimationAllowZero as boolean | undefined, issueEstimationExtended: (t as Record<string, unknown>) ?.issueEstimationExtended as boolean | undefined, issueEstimationType: (t as Record<string, unknown>)?.issueEstimationType as | string | undefined, })); } } if (include.includes('workflow_states')) { const statesByTeam: Record< string, Array<{ id: string; name: string; type?: string }> > = {}; for (const team of teams) { const states = await team.states(); statesByTeam[team.id] = states.nodes.map( (s: { id: string; name: string; type?: string }) => ({ id: s.id, name: s.name, type: s.type, }), ); } statesByTeamComputed = statesByTeam; result.workflowStatesByTeam = statesByTeam; } if (include.includes('labels')) { const labelLimit = parsed.data.label_limit ?? 50; const labelsByTeam: Record< string, Array<{ id: string; name: string; color?: string; description?: string; }> > = {}; for (const team of teams) { const labels = await team.labels({ first: labelLimit }); labelsByTeam[team.id] = labels.nodes.map( (l: { id: string; name: string; color?: string; description?: string }) => ({ id: l.id, name: l.name, color: l.color ?? undefined, description: l.description ?? undefined, }), ); } labelsByTeamComputed = labelsByTeam; result.labelsByTeam = labelsByTeam; } if (include.includes('favorites')) { try { const favConn = (await client.favorites({ first: 100 })) as unknown as { nodes: Array<{ id: string; type?: string; url?: string; projectId?: string; issueId?: string; labelId?: string; userId?: string; predefinedViewTeamId?: string; customViewId?: string; folderName?: string; }>; }; result.favorites = favConn.nodes.map((f) => ({ id: f.id, type: (f as unknown as { type?: string }).type, url: (f as unknown as { url?: string }).url, projectId: (f as unknown as { projectId?: string }).projectId, issueId: (f as unknown as { issueId?: string }).issueId, labelId: (f as unknown as { labelId?: string }).labelId, userId: (f as unknown as { userId?: string }).userId, teamId: (f as unknown as { predefinedViewTeamId?: string }) .predefinedViewTeamId, customViewId: (f as unknown as { customViewId?: string }).customViewId, folderName: (f as unknown as { folderName?: string }).folderName, })); } catch (_error) { // ignore favorites errors; not essential for bootstrap } } if (include.includes('projects')) { const limit = parsed.data.project_limit ?? 10; const projects: Array<Record<string, unknown>> = []; for (const team of teams) { const conn = await team.projects({ first: limit }); for (const p of conn.nodes) { projects.push({ id: p.id, name: p.name, state: p.state, leadId: p.lead?.id ?? undefined, teamId: team.id, targetDate: p.targetDate ?? undefined, createdAt: p.createdAt?.toString(), }); } } result.projects = projects; projectsLocalCount = projects.length; } const summary = { teamCount: teams.length, stateCount: Object.values(statesByTeamComputed).reduce( (acc, arr) => acc + (Array.isArray(arr) ? arr.length : 0), 0, ), labelCount: Object.values(labelsByTeamComputed).reduce( (acc, arr) => acc + (Array.isArray(arr) ? arr.length : 0), 0, ), projectCount: projectsLocalCount, }; result.summary = summary; const structured = AccountOutputSchema.parse(result); const parts: Array<{ type: 'text'; text: string }> = []; const viewerBit = structured.viewer ? `${ structured.viewer.displayName ?? structured.viewer.name ?? structured.viewer.id }` : `not requested (include 'profile' to fetch viewer)`; const viewerIdBit = structured.viewer?.id ? ` (viewer.id: ${structured.viewer.id})` : ''; const teamPreview: string[] = Array.isArray(structured.teams) ? previewLinesFromItems( structured.teams as unknown as Record<string, unknown>[], (t) => { const id = String((t.id as string) ?? ''); const key = (t.key as string | undefined) ?? undefined; const name = (t.name as string | undefined) ?? undefined; return `${key ? `${key} — ` : ''}${name ?? id} (${id})`; }, ) : []; const summaryLines: string[] = []; summaryLines.push( summarizeList({ subject: 'Teams', count: teams.length as number, previewLines: teamPreview, nextSteps: ['Use team ids to list issues or workflow states (list_issues).'], }), ); // Workflow states summary (first team preview) const statesByTeam = structured.workflowStatesByTeam as | Record<string, Array<{ id: string; name: string; type?: string }>> | undefined; if (statesByTeam && Object.keys(statesByTeam).length > 0) { const teamIds = Object.keys(statesByTeam); const totalStates = Object.values(statesByTeam).reduce( (acc, arr) => acc + (Array.isArray(arr) ? arr.length : 0), 0, ); const firstTeamId = teamIds[0] as string | undefined; const firstTeam = (structured.teams ?? []).find((t) => t.id === firstTeamId); const firstTeamLabel = firstTeam ? `${firstTeam.key ? `${firstTeam.key} — ` : ''}${firstTeam.name} (${ firstTeam.id })` : (firstTeamId ?? ''); const firstStates = firstTeamId ? (statesByTeam[firstTeamId] ?? []) : []; const findByType = (t: string) => firstStates.find( (s: { type?: string; name: string }) => (s.type ?? '').toLowerCase() === t, ); const started = findByType('started'); const completed = findByType('completed') || firstStates.find((s: { name: string }) => s.name.toLowerCase() === 'done'); const backlog = findByType('backlog'); const canceled = findByType('canceled'); const keyBits: string[] = []; if (started) { keyBits.push(`Started: ${started.name} (${started.id})`); } if (completed) { keyBits.push(`Done: ${completed.name} (${completed.id})`); } if (backlog) { keyBits.push(`Backlog: ${backlog.name} (${backlog.id})`); } if (canceled) { keyBits.push(`Canceled: ${canceled.name} (${canceled.id})`); } const statePreview = keyBits.join(', '); summaryLines.push( `Workflow states: ${totalStates} across ${teamIds.length} team(s). ${ statePreview ? `Key (${firstTeamLabel}): ${statePreview}.` : '' } Use workflowStatesByTeam[teamId] for the full list and to resolve stateId.`, ); } // Labels summary (first team preview) const labelsByTeam = structured.labelsByTeam as | Record< string, Array<{ id: string; name: string; color?: string; description?: string; }> > | undefined; if (labelsByTeam && Object.keys(labelsByTeam).length > 0) { const teamIds = Object.keys(labelsByTeam); const totalLabels = Object.values(labelsByTeam).reduce( (acc, arr) => acc + (Array.isArray(arr) ? arr.length : 0), 0, ); const firstTeamId = teamIds[0] as string | undefined; const firstTeam = teams.find((t) => t.id === firstTeamId); const firstTeamLabel = firstTeam ? `${firstTeam.key ? `${firstTeam.key} — ` : ''}${firstTeam.name} (${ firstTeam.id })` : (firstTeamId ?? ''); const firstLabels = firstTeamId ? (labelsByTeam[firstTeamId] ?? []) : []; const sample = firstLabels .slice(0, 10) .map((l) => l.name) .join(', '); summaryLines.push( `Labels: ${totalLabels} across ${teamIds.length} team(s). ${ sample ? `Key (${firstTeamLabel}): ${sample}.` : '' } Use labelsByTeam[teamId] to resolve labelIds for create/update operations.`, ); } if (Array.isArray(structured.projects)) { const projectPreview = previewLinesFromItems( structured.projects as unknown as Record<string, unknown>[], (p) => { const id = String((p.id as string) ?? ''); const name = (p.name as string | undefined) ?? id; const state = (p.state as string | undefined) ?? ''; const teamId = (p.teamId as string | undefined) ?? undefined; const target = (p.targetDate as string | undefined) ?? undefined; const parts: string[] = [`state ${state}`]; if (teamId) { parts.push(`team ${teamId}`); } if (target) { parts.push(`target ${target}`); } return `${name} (${id}) — ${parts.join('; ')}`; }, ); summaryLines.push( summarizeList({ subject: 'Projects', count: structured.projects.length, previewLines: projectPreview, nextSteps: [ 'Use ids with get_project or list_issues(projectId) to discover issues; use update_projects to modify.', ], }), ); } if (Array.isArray((structured as unknown as Record<string, unknown>).favorites)) { summaryLines.push( `Favorites: ${ ((structured as unknown as Record<string, unknown>).favorites as unknown[]) .length }.`, ); } parts.push({ type: 'text', text: `Loaded workspace bootstrap for ${viewerBit}${viewerIdBit}. ${summaryLines.join( ' ', )}`, }); if (config.LINEAR_MCP_INCLUDE_JSON_IN_CONTENT) { parts.push({ type: 'text', text: JSON.stringify(structured) }); } return { content: parts, structuredContent: structured }; }, };

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