Skip to main content
Glama
iceener

Linear Streamable MCP Server

by iceener
workspace-metadata.ts12.7 kB
/** * Workspace Metadata tool - discover IDs, teams, workflow states, labels, projects. */ import { z } from 'zod'; import { toolsMetadata } from '../../../config/metadata.js'; import { AccountOutputSchema } from '../../../schemas/outputs.js'; import { getLinearClient } from '../../../services/linear/client.js'; import { previewLinesFromItems, summarizeList } from '../../../utils/messages.js'; import { defineTool, type ToolContext, type ToolResult } from '../types.js'; import { config } from '../../../config/env.js'; const InputSchema = z.object({ include: z .array( z.enum([ 'profile', 'teams', 'workflow_states', 'labels', 'projects', 'favorites', ]), ) .optional() .describe( "What to include. Defaults to ['profile','teams','workflow_states','labels','projects']. " + "'profile' returns viewer (id, name, email, timezone). " + "'teams' returns team list with cyclesEnabled flag. " + "'workflow_states' returns workflowStatesByTeam[teamId] with state id/name/type. " + "'labels' returns labelsByTeam[teamId]. " + "'projects' returns project list. " + "'favorites' returns user favorites.", ), teamIds: z .array(z.string()) .optional() .describe('Filter to specific team UUIDs. If omitted, fetches all teams.'), project_limit: z .number() .int() .min(1) .max(100) .optional() .describe('Max projects per team. Default: 10.'), label_limit: z .number() .int() .min(1) .max(200) .optional() .describe('Max labels per team. Default: 50.'), }); 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 workspaceMetadataTool = defineTool({ name: toolsMetadata.workspace_metadata.name, title: toolsMetadata.workspace_metadata.title, description: toolsMetadata.workspace_metadata.description, inputSchema: InputSchema, annotations: { readOnlyHint: true, destructiveHint: false, }, handler: async (args, context: ToolContext): Promise<ToolResult> => { const include = args.include ?? [ 'profile', 'teams', 'workflow_states', 'labels', 'projects', ]; const teamIdsFilter = new Set(args.teamIds ?? []); const client = await getLinearClient(context); 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 { // Non-fatal: continue collecting other teams } } 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.cyclesEnabled, issueEstimationAllowZero: t.issueEstimationAllowZero, issueEstimationExtended: t.issueEstimationExtended, issueEstimationType: t.issueEstimationType, })); } } 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: s.id, name: s.name, type: s.type, })); } statesByTeamComputed = statesByTeam; result.workflowStatesByTeam = statesByTeam; } if (include.includes('labels')) { const labelLimit = args.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: 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; }>; }; result.favorites = favConn.nodes.map((f) => ({ id: f.id, type: f.type, url: f.url, projectId: f.projectId, issueId: f.issueId, })); } catch { // ignore favorites errors; not essential } } if (include.includes('projects')) { const limit = args.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; // Build quickLookup for easy access const quickLookup: Record<string, unknown> = {}; if (result.viewer) { quickLookup.viewerId = result.viewer.id; quickLookup.viewerName = result.viewer.name; quickLookup.viewerEmail = result.viewer.email; } if (teams.length > 0) { quickLookup.teamIds = teams.map((t) => t.id); quickLookup.teamByKey = Object.fromEntries( teams.filter((t) => t.key).map((t) => [t.key, t.id]), ); quickLookup.teamByName = Object.fromEntries( teams.map((t) => [t.name, t.id]), ); } if (Object.keys(statesByTeamComputed).length > 0) { // Flatten all states into a single lookup by name const stateIdByName: Record<string, string> = {}; for (const states of Object.values(statesByTeamComputed)) { for (const s of states) { stateIdByName[s.name] = s.id; } } quickLookup.stateIdByName = stateIdByName; } if (Object.keys(labelsByTeamComputed).length > 0) { // Flatten all labels into a single lookup by name const labelIdByName: Record<string, string> = {}; for (const labels of Object.values(labelsByTeamComputed)) { for (const l of labels) { labelIdByName[l.name] = l.id; } } quickLookup.labelIdByName = labelIdByName; } if (result.projects && Array.isArray(result.projects)) { quickLookup.projectIdByName = Object.fromEntries( result.projects.map((p: { name: string; id: string }) => [p.name, p.id]), ); } result.quickLookup = quickLookup; // Build meta const meta = { nextSteps: [ 'Use quickLookup for fast ID resolution.', 'Use team IDs with list_issues to fetch issues.', 'Use stateIdByName to update issue states.', 'Use labelIdByName for label operations.', ], relatedTools: ['list_issues', 'create_issues', 'update_issues', 'list_projects'], }; result.meta = meta; 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 ?? ''); const key = t.key as string | undefined; const name = t.name as string | undefined; return `${key ? `${key} — ` : ''}${name ?? id} (${id})`; }, ) : []; const summaryLines: string[] = []; summaryLines.push( summarizeList({ subject: 'Teams', count: teams.length, previewLines: teamPreview, nextSteps: ['Use team ids to list issues or workflow states (list_issues).'], }), ); if (include.includes('workflow_states') && Object.keys(statesByTeamComputed).length > 0) { const statePreviewLines: string[] = []; for (const [teamId, states] of Object.entries(statesByTeamComputed)) { const team = teams.find((t) => t.id === teamId); const teamLabel = team?.key ?? team?.name ?? teamId; const statesList = states.map((s) => `${s.name} [${s.type}] → ${s.id}`).join(', '); statePreviewLines.push(`${teamLabel}: ${statesList}`); } summaryLines.push( summarizeList({ subject: 'Workflow States', count: summary.stateCount, previewLines: statePreviewLines, }), ); } if (include.includes('projects') && Array.isArray(structured.projects) && structured.projects.length > 0) { const projectPreviewLines = (structured.projects as Array<{ id: string; name: string; state?: string }>).map( (p) => `${p.name} [${p.state ?? 'unknown'}] → ${p.id}`, ); summaryLines.push( summarizeList({ subject: 'Projects', count: structured.projects.length, previewLines: projectPreviewLines, }), ); } 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 }; }, });

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