Skip to main content
Glama

Linear Streamable MCP Server

by iceener
projects.tool.ts9.32 kB
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { config } from '../config/env.ts'; import { toolsMetadata } from '../config/metadata.ts'; import { getCurrentAbortSignal } from '../core/context.ts'; import { CreateProjectsInputSchema, GetProjectInputSchema, ListProjectsInputSchema, UpdateProjectsInputSchema, } from '../schemas/inputs.ts'; import { CreateProjectsOutputSchema, ListProjectsOutputSchema, UpdateProjectsOutputSchema, } from '../schemas/outputs.ts'; import { getLinearClient } from '../services/linear-client.ts'; import { makeConcurrencyGate } from '../utils/limits.ts'; import { logger } from '../utils/logger.ts'; import { mapProjectNodeToListItem } from '../utils/mappers.ts'; import { previewLinesFromItems, summarizeBatch, summarizeList, } from '../utils/messages.ts'; export const listProjectsTool = { name: toolsMetadata.list_projects.name, title: toolsMetadata.list_projects.title, description: toolsMetadata.list_projects.description, inputSchema: ListProjectsInputSchema.shape, handler: async (args: unknown): Promise<CallToolResult> => { const parsed = ListProjectsInputSchema.safeParse(args); if (!parsed.success) { return { isError: true, content: [{ type: 'text', text: parsed.error.message }], }; } const client = getLinearClient(); const first = parsed.data.limit ?? 20; const after = parsed.data.cursor; const filter = (parsed.data.filter as Record<string, unknown> | undefined) ?? undefined; const conn = await client.projects({ first, after, filter: filter as Record<string, unknown> | undefined, includeArchived: parsed.data.includeArchived, }); const items = conn.nodes.map((p) => mapProjectNodeToListItem(p)); const structured = ListProjectsOutputSchema.parse({ items, cursor: parsed.data.cursor, nextCursor: conn.pageInfo?.endCursor ?? undefined, 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: structured.nextCursor, previewLines: preview, nextSteps: [ 'For a single project, call list_projects with filter.id.eq and limit=1; filter by state/team/lead to narrow; pass cursor for next page.', ], }); 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 }; }, }; export const getProjectTool = { name: toolsMetadata.get_project.name, title: toolsMetadata.get_project.title, description: toolsMetadata.get_project.description, inputSchema: GetProjectInputSchema.shape, handler: async (_args: unknown): Promise<CallToolResult> => { // Deprecated: advise clients to use list_projects with filter.id.eq and limit=1 return { isError: true, content: [ { type: 'text', text: "'get_project' is deprecated. Use 'list_projects' with filter.id.eq and limit=1 to fetch a single project.", }, ], }; }, }; export const createProjectsTool = { name: toolsMetadata.create_projects.name, title: toolsMetadata.create_projects.title, description: toolsMetadata.create_projects.description, inputSchema: CreateProjectsInputSchema.shape, handler: async (args: unknown): Promise<CallToolResult> => { const parsed = CreateProjectsInputSchema.safeParse(args); if (!parsed.success) { return { isError: true, content: [{ type: 'text', text: parsed.error.message }], }; } const client = getLinearClient(); const gate = makeConcurrencyGate(config.CONCURRENCY_LIMIT); const abort = getCurrentAbortSignal(); const results: { index: number; ok: boolean; id?: string; error?: string; code?: string; }[] = []; for (let i = 0; i < parsed.data.items.length; i++) { const it = parsed.data.items[i]; try { if (abort?.aborted) { throw new Error('Operation aborted'); } const call = () => client.createProject({ name: it.name, description: it.description, leadId: it.leadId, targetDate: it.targetDate, teamIds: it.teamId ? [it.teamId] : [], }); const payload = parsed.data.items.length > 1 ? await gate(call) : await call(); results.push({ index: i, ok: payload.success ?? true, id: (payload.project as { id?: string } | null | undefined)?.id, }); } catch (error) { await logger.error('create_projects', { message: 'Failed to create project', index: i, error: (error as Error).message, }); results.push({ index: i, ok: false, error: (error as Error).message, code: 'LINEAR_CREATE_ERROR', }); } } const summary = { ok: results.filter((r) => r.ok).length, failed: results.filter((r) => !r.ok).length, }; const structured = CreateProjectsOutputSchema.parse({ results, summary }); 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: parsed.data.items.length, okIdentifiers: okIds, failures, nextSteps: ['Use get_project 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 }; }, }; export const updateProjectsTool = { name: toolsMetadata.update_projects.name, title: toolsMetadata.update_projects.title, description: toolsMetadata.update_projects.description, inputSchema: UpdateProjectsInputSchema.shape, handler: async (args: unknown): Promise<CallToolResult> => { const parsed = UpdateProjectsInputSchema.safeParse(args); if (!parsed.success) { return { isError: true, content: [{ type: 'text', text: parsed.error.message }], }; } const client = getLinearClient(); const gate = makeConcurrencyGate(config.CONCURRENCY_LIMIT); const abort = getCurrentAbortSignal(); const results: { index: number; ok: boolean; id?: string; error?: string; code?: string; }[] = []; for (let i = 0; i < parsed.data.items.length; i++) { const it = parsed.data.items[i]; try { if (abort?.aborted) { throw new Error('Operation aborted'); } const call = () => client.updateProject(it.id, { name: it.name, description: it.description, leadId: it.leadId, targetDate: it.targetDate, // ProjectUpdateInput: omit unsupported fields; handle archive via dedicated API if needed }); const payload = parsed.data.items.length > 1 ? await gate(call) : await call(); results.push({ index: i, ok: payload.success ?? true, id: it.id }); } catch (error) { await logger.error('update_projects', { message: 'Failed to update project', id: it.id, error: (error as Error).message, }); results.push({ index: i, ok: false, id: it.id, error: (error as Error).message, code: 'LINEAR_UPDATE_ERROR', }); } } const summary = { ok: results.filter((r) => r.ok).length, failed: results.filter((r) => !r.ok).length, }; const structured = UpdateProjectsOutputSchema.parse({ results, summary }); 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: parsed.data.items.length, okIdentifiers: okIds, failures, nextSteps: ['Call get_project 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 }; }, };

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