Skip to main content
Glama
iceener

Linear Streamable MCP Server

by iceener
projects.ts13.4 kB
/** * Projects tools - list, create, and update projects. */ import { z } from 'zod'; import { toolsMetadata } from '../../../config/metadata.js'; import { config } from '../../../config/env.js'; import { CreateProjectsOutputSchema, ListProjectsOutputSchema, UpdateProjectsOutputSchema, } from '../../../schemas/outputs.js'; import { getLinearClient } from '../../../services/linear/client.js'; import { makeConcurrencyGate, withRetry, delay } from '../../../utils/limits.js'; import { logger } from '../../../utils/logger.js'; import { mapProjectNodeToListItem } from '../../../utils/mappers.js'; import { summarizeBatch, summarizeList, previewLinesFromItems } from '../../../utils/messages.js'; import { defineTool, type ToolContext, type ToolResult } from '../types.js'; // List Projects const ListProjectsInputSchema = z.object({ limit: z .number() .int() .min(1) .max(100) .optional() .describe('Max results. Default: 20.'), cursor: z.string().optional().describe('Pagination cursor from previous response.'), filter: z .record(z.any()) .optional() .describe( 'GraphQL-style ProjectFilter. Structure: { field: { comparator: value } }. ' + "Examples: { id: { eq: 'PROJECT_UUID' } } for single project, " + "{ state: { eq: 'started' } }, " + "{ team: { id: { eq: 'TEAM_UUID' } } }, " + "{ lead: { id: { eq: 'USER_UUID' } } }, " + "{ targetDate: { lt: '2025-01-01', gt: '2024-01-01' } }.", ), includeArchived: z.boolean().optional().describe('Include archived projects. Default: false.'), }); export const listProjectsTool = defineTool({ name: toolsMetadata.list_projects.name, title: toolsMetadata.list_projects.title, description: toolsMetadata.list_projects.description, inputSchema: ListProjectsInputSchema, annotations: { readOnlyHint: true, destructiveHint: false, }, handler: async (args, context: ToolContext): Promise<ToolResult> => { const client = await getLinearClient(context); const first = args.limit ?? 20; const after = args.cursor; const filter = args.filter as Record<string, unknown> | undefined; const conn = await client.projects({ first, after, filter: filter as Record<string, unknown> | undefined, includeArchived: args.includeArchived, }); const items = conn.nodes.map((p) => mapProjectNodeToListItem(p)); const pageInfo = conn.pageInfo; const hasMore = pageInfo?.hasNextPage ?? false; const nextCursor = hasMore ? pageInfo?.endCursor ?? undefined : undefined; // Build query echo const query = { filter: args.filter ? (filter as Record<string, unknown>) : undefined, includeArchived: args.includeArchived, limit: first, }; // Build pagination const pagination = { hasMore, nextCursor, itemsReturned: items.length, limit: first, }; // Build meta const meta = { nextSteps: [ ...(hasMore ? [`Call again with cursor="${nextCursor}" for more.`] : []), 'Use update_projects to modify state or details.', 'Use list_issues with projectId to see project issues.', ], relatedTools: ['update_projects', 'list_issues', 'create_projects'], }; const structured = ListProjectsOutputSchema.parse({ query, items, pagination, meta, // Legacy cursor: args.cursor, nextCursor, limit: first, }); const preview = previewLinesFromItems( items as unknown as Record<string, unknown>[], (p) => `${String((p.name as string) ?? '')} (${p.id}) — state ${String((p.state as string) ?? '')}`, ); const message = summarizeList({ subject: 'Projects', count: items.length, limit: first, nextCursor, previewLines: preview, nextSteps: meta.nextSteps, }); const parts: Array<{ type: 'text'; text: string }> = [{ type: 'text', text: message }]; if (config.LINEAR_MCP_INCLUDE_JSON_IN_CONTENT) { parts.push({ type: 'text', text: JSON.stringify(structured) }); } return { content: parts, structuredContent: structured }; }, }); // Create Projects const CreateProjectsInputSchema = z.object({ items: z .array( z.object({ name: z.string().describe('Project name. Required.'), description: z.string().optional().describe('Markdown description.'), teamId: z.string().optional().describe('Team UUID to associate.'), leadId: z.string().optional().describe('Lead user UUID.'), targetDate: z.string().optional().describe('Target date (YYYY-MM-DD).'), }), ) .min(1) .max(50) .describe('Projects to create. Use update_projects to change state after creation.'), }); export const createProjectsTool = defineTool({ name: toolsMetadata.create_projects.name, title: toolsMetadata.create_projects.title, description: toolsMetadata.create_projects.description, inputSchema: CreateProjectsInputSchema, annotations: { readOnlyHint: false, destructiveHint: false, }, handler: async (args, context: ToolContext): Promise<ToolResult> => { const client = await getLinearClient(context); const gate = makeConcurrencyGate(config.CONCURRENCY_LIMIT); const results: { index: number; ok: boolean; id?: string; error?: string; code?: string; }[] = []; for (let i = 0; i < args.items.length; i++) { const it = args.items[i]; try { if (context.signal?.aborted) { throw new Error('Operation aborted'); } // Add small delay between requests to avoid rate limits if (i > 0) { await delay(100); } const call = () => client.createProject({ name: it.name, description: it.description, leadId: it.leadId, targetDate: it.targetDate, teamIds: it.teamId ? [it.teamId] : [], }); const payload = await withRetry( () => (args.items.length > 1 ? gate(call) : call()), { maxRetries: 3, baseDelayMs: 500 }, ); results.push({ input: { name: it.name, teamId: it.teamId }, success: payload.success ?? true, id: (payload.project as { id?: string } | null | undefined)?.id, // Legacy index: i, ok: payload.success ?? true, }); } catch (error) { await logger.error('create_projects', { message: 'Failed to create project', index: i, error: (error as Error).message, }); results.push({ input: { name: it.name, teamId: it.teamId }, success: false, error: { code: 'LINEAR_CREATE_ERROR', message: (error as Error).message, suggestions: ['Verify teamId with workspace_metadata.'], }, // Legacy index: i, ok: false, }); } } const succeeded = results.filter((r) => r.success).length; const failed = results.filter((r) => !r.success).length; const summary = { total: args.items.length, succeeded, failed, ok: succeeded, }; const meta = { nextSteps: ['Use list_projects to verify.', 'Use update_projects to modify.'], relatedTools: ['list_projects', 'update_projects', 'list_issues'], }; const structured = CreateProjectsOutputSchema.parse({ results, summary, meta }); const okIds = results .filter((r) => r.ok) .map((r) => r.id ?? `item[${String(r.index)}]`) as string[]; const failures = results .filter((r) => !r.ok) .map((r) => ({ index: r.index, id: undefined, error: r.error ?? '', code: undefined, })); const text = summarizeBatch({ action: 'Created projects', ok: summary.ok, total: args.items.length, okIdentifiers: okIds, failures, nextSteps: ['Use list_projects to verify; update_projects to modify.'], }); const parts: Array<{ type: 'text'; text: string }> = [{ type: 'text', text }]; if (config.LINEAR_MCP_INCLUDE_JSON_IN_CONTENT) { parts.push({ type: 'text', text: JSON.stringify(structured) }); } return { content: parts, structuredContent: structured }; }, }); // Update Projects const UpdateProjectsInputSchema = z.object({ items: z .array( z.object({ id: z.string().describe('Project UUID. Required.'), name: z.string().optional().describe('New project name.'), description: z.string().optional().describe('New markdown description.'), leadId: z.string().optional().describe('New lead user UUID.'), targetDate: z.string().optional().describe('New target date (YYYY-MM-DD).'), state: z.string().optional().describe("New state: 'planned', 'started', 'paused', 'completed', 'canceled'."), archived: z.boolean().optional().describe('Set true to archive, false to unarchive.'), }), ) .min(1) .max(50) .describe('Projects to update.'), }); export const updateProjectsTool = defineTool({ name: toolsMetadata.update_projects.name, title: toolsMetadata.update_projects.title, description: toolsMetadata.update_projects.description, inputSchema: UpdateProjectsInputSchema, annotations: { readOnlyHint: false, destructiveHint: false, }, handler: async (args, context: ToolContext): Promise<ToolResult> => { const client = await getLinearClient(context); const gate = makeConcurrencyGate(config.CONCURRENCY_LIMIT); const results: { index: number; ok: boolean; id?: string; error?: string; code?: string; }[] = []; for (let i = 0; i < args.items.length; i++) { const it = args.items[i]; try { if (context.signal?.aborted) { throw new Error('Operation aborted'); } // Add small delay between requests to avoid rate limits if (i > 0) { await delay(100); } const updatePayload: Record<string, unknown> = {}; if (it.name) updatePayload.name = it.name; if (it.description) updatePayload.description = it.description; if (it.leadId) updatePayload.leadId = it.leadId; if (it.targetDate) updatePayload.targetDate = it.targetDate; if (it.state) updatePayload.state = it.state; const call = () => client.updateProject(it.id, updatePayload); const result = await withRetry( () => (args.items.length > 1 ? gate(call) : call()), { maxRetries: 3, baseDelayMs: 500 }, ); // Handle archive/unarchive if (typeof it.archived === 'boolean') { try { if (it.archived) { await client.archiveProject(it.id); } else { await client.unarchiveProject(it.id); } } catch { // Ignore archive errors to preserve other updates } } results.push({ input: { id: it.id, name: it.name, state: it.state }, success: result.success ?? true, id: it.id, // Legacy index: i, ok: result.success ?? true, }); } catch (error) { await logger.error('update_projects', { message: 'Failed to update project', id: it.id, error: (error as Error).message, }); results.push({ input: { id: it.id }, success: false, id: it.id, error: { code: 'LINEAR_UPDATE_ERROR', message: (error as Error).message, suggestions: ['Verify project ID with list_projects.'], }, // Legacy index: i, ok: false, }); } } const succeeded = results.filter((r) => r.success).length; const failed = results.filter((r) => !r.success).length; const summary = { total: args.items.length, succeeded, failed, ok: succeeded, }; const meta = { nextSteps: ['Use list_projects to verify changes.'], relatedTools: ['list_projects', 'list_issues'], }; const structured = UpdateProjectsOutputSchema.parse({ results, summary, meta }); const okIds = results .filter((r) => r.ok) .map((r) => r.id ?? `item[${String(r.index)}]`) as string[]; const failures = results .filter((r) => !r.ok) .map((r) => ({ index: r.index, id: r.id, error: r.error ?? '', code: undefined, })); const text = summarizeBatch({ action: 'Updated projects', ok: summary.ok, total: args.items.length, okIdentifiers: okIds, failures, nextSteps: ['Call list_projects to verify changes.'], }); const parts: Array<{ type: 'text'; text: string }> = [{ type: 'text', text }]; 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