Skip to main content
Glama
iceener

Linear Streamable MCP Server

by iceener
update-issues.ts18.9 kB
/** * Update Issues tool - batch update issues in Linear. */ import { z } from 'zod'; import { toolsMetadata } from '../../../config/metadata.js'; import { config } from '../../../config/env.js'; import { UpdateIssuesOutputSchema } 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 { summarizeBatch } from '../../../utils/messages.js'; import { resolveAssignee } from '../../../utils/user-resolver.js'; import { resolvePriority, resolveState, resolveLabels, resolveProject, getIssueTeamId } from '../../../utils/resolvers.js'; import { defineTool, type ToolContext, type ToolResult } from '../types.js'; import { createTeamSettingsCache, validateEstimate, validatePriority, captureIssueSnapshot, computeFieldChanges, formatDiffLine, } from './shared/index.js'; const IssueUpdateItem = z.object({ id: z.string().describe('Issue UUID or identifier (e.g. ENG-123). Required.'), title: z.string().optional().describe('New title.'), description: z.string().optional().describe('New markdown description.'), // State - UUID or human-readable stateId: z .string() .optional() .describe('Workflow state UUID. Or use stateName/stateType for name-based lookup.'), stateName: z .string() .optional() .describe('State name from issue\'s team. Use workspace_metadata to see available names.'), stateType: z .enum(['backlog', 'unstarted', 'started', 'completed', 'canceled']) .optional() .describe('State type. Finds first matching state.'), // Labels - UUIDs or names (use workspace_metadata to see available labels) labelIds: z.array(z.string()).optional().describe('Replace all labels with these UUIDs.'), labelNames: z .array(z.string()) .optional() .describe('Replace all labels with these names from your workspace.'), addLabelIds: z.array(z.string()).optional().describe('Add these label UUIDs (incremental).'), addLabelNames: z.array(z.string()).optional().describe('Add these label names (incremental).'), removeLabelIds: z.array(z.string()).optional().describe('Remove these label UUIDs (incremental).'), removeLabelNames: z.array(z.string()).optional().describe('Remove these label names (incremental).'), // Assignee - UUID, name, or email (use workspace_metadata to list users) assigneeId: z.string().optional().describe('New assignee user UUID.'), assigneeName: z .string() .optional() .describe('User name (fuzzy match). Partial names work.'), assigneeEmail: z .string() .optional() .describe('User email to assign (exact match, case-insensitive).'), // Project - UUID or name projectId: z.string().optional().describe('New project UUID.'), projectName: z.string().optional().describe('Project name. Resolved to projectId.'), // Priority - number or string priority: z .union([ z.number().int().min(0).max(4), z.enum(['None', 'Urgent', 'High', 'Medium', 'Normal', 'Low', 'none', 'urgent', 'high', 'medium', 'normal', 'low']), ]) .optional() .describe('Priority: 0-4 or "None"/"Urgent"/"High"/"Medium"/"Low".'), estimate: z.number().optional().describe('New estimate / story points.'), allowZeroEstimate: z .boolean() .optional() .describe('If true and estimate=0, sends 0. Otherwise zero is omitted.'), dueDate: z.string().optional().describe('New due date (YYYY-MM-DD) or empty string to clear.'), parentId: z.string().optional().describe('New parent issue UUID.'), archived: z.boolean().optional().describe('Set true to archive, false to unarchive.'), }); const InputSchema = z.object({ items: z.array(IssueUpdateItem).min(1).max(50).describe('Issues to update. Batch up to 50.'), parallel: z.boolean().optional().describe('Run in parallel. Default: sequential.'), dry_run: z.boolean().optional().describe('If true, validate but do not update.'), }); export const updateIssuesTool = defineTool({ name: toolsMetadata.update_issues.name, title: toolsMetadata.update_issues.title, description: toolsMetadata.update_issues.description, inputSchema: InputSchema, annotations: { readOnlyHint: false, destructiveHint: false, }, handler: async (args, context: ToolContext): Promise<ToolResult> => { // Handle dry_run mode if (args.dry_run) { const validated = args.items.map((it, index) => ({ index, ok: true, id: it.id, validated: true, })); return { content: [ { type: 'text', text: `Dry run: ${args.items.length} update(s) validated successfully. No changes made.`, }, ], structuredContent: { results: validated, summary: { ok: args.items.length, failed: 0 }, dry_run: true, }, }; } const client = await getLinearClient(context); const gate = makeConcurrencyGate(config.CONCURRENCY_LIMIT); const { items } = args; const results: { index: number; ok: boolean; id?: string; error?: string; code?: string; }[] = []; const teamAllowZeroCache = createTeamSettingsCache(); const diffLines: string[] = []; for (let i = 0; i < items.length; i++) { const it = items[i] as (typeof items)[number]; try { // Capture BEFORE snapshot using shared utility const beforeSnapshot = await gate(() => captureIssueSnapshot(client, it.id)); const payloadInput: Record<string, unknown> = {}; if (typeof it.title === 'string' && it.title.trim() !== '') { payloadInput.title = it.title; } if (typeof it.description === 'string' && it.description.trim() !== '') { payloadInput.description = it.description; } // Get team ID for resolution (needed for state/labels) const teamId = await getIssueTeamId(client, it.id); // Resolve state from ID, name, or type if (it.stateId) { payloadInput.stateId = it.stateId; } else if (it.stateName || it.stateType) { if (!teamId) { results.push({ index: i, ok: false, error: 'Cannot resolve state: failed to get issue team', code: 'TEAM_RESOLUTION_FAILED', }); continue; } const stateResult = await resolveState(client, teamId, { stateName: it.stateName, stateType: it.stateType, }); if (!stateResult.success) { results.push({ index: i, ok: false, error: stateResult.error, code: 'STATE_RESOLUTION_FAILED', }); continue; } payloadInput.stateId = stateResult.value; } // Resolve labels from IDs or names if (Array.isArray(it.labelIds) && it.labelIds.length > 0) { payloadInput.labelIds = it.labelIds; } else if (Array.isArray(it.labelNames) && it.labelNames.length > 0) { if (!teamId) { results.push({ index: i, ok: false, error: 'Cannot resolve labels: failed to get issue team', code: 'TEAM_RESOLUTION_FAILED' }); continue; } const labelsResult = await resolveLabels(client, teamId, it.labelNames); if (!labelsResult.success) { results.push({ index: i, ok: false, error: labelsResult.error, code: 'LABEL_RESOLUTION_FAILED' }); continue; } payloadInput.labelIds = labelsResult.value; } // Resolve addLabelNames if (Array.isArray(it.addLabelIds) && it.addLabelIds.length > 0) { payloadInput.addedLabelIds = it.addLabelIds; } else if (Array.isArray(it.addLabelNames) && it.addLabelNames.length > 0) { if (!teamId) { results.push({ index: i, ok: false, error: 'Cannot resolve labels: failed to get issue team', code: 'TEAM_RESOLUTION_FAILED' }); continue; } const addResult = await resolveLabels(client, teamId, it.addLabelNames); if (!addResult.success) { results.push({ index: i, ok: false, error: addResult.error, code: 'LABEL_RESOLUTION_FAILED' }); continue; } payloadInput.addedLabelIds = addResult.value; } // Resolve removeLabelNames if (Array.isArray(it.removeLabelIds) && it.removeLabelIds.length > 0) { payloadInput.removedLabelIds = it.removeLabelIds; } else if (Array.isArray(it.removeLabelNames) && it.removeLabelNames.length > 0) { if (!teamId) { results.push({ index: i, ok: false, error: 'Cannot resolve labels: failed to get issue team', code: 'TEAM_RESOLUTION_FAILED' }); continue; } const removeResult = await resolveLabels(client, teamId, it.removeLabelNames); if (!removeResult.success) { results.push({ index: i, ok: false, error: removeResult.error, code: 'LABEL_RESOLUTION_FAILED' }); continue; } payloadInput.removedLabelIds = removeResult.value; } // Resolve assignee from ID, name, or email if (it.assigneeId || it.assigneeName || it.assigneeEmail) { const assigneeResult = await resolveAssignee(client, { assigneeId: it.assigneeId, assigneeName: it.assigneeName, assigneeEmail: it.assigneeEmail, }); if (!assigneeResult.success && assigneeResult.error) { results.push({ index: i, ok: false, error: assigneeResult.error.message, code: assigneeResult.error.code, }); continue; } if (assigneeResult.user?.id) { payloadInput.assigneeId = assigneeResult.user.id; } } // Resolve project from ID or name if (it.projectId) { payloadInput.projectId = it.projectId; } else if (it.projectName) { const projectResult = await resolveProject(client, it.projectName); if (!projectResult.success) { results.push({ index: i, ok: false, error: projectResult.error, code: 'PROJECT_RESOLUTION_FAILED' }); continue; } payloadInput.projectId = projectResult.value; } // Resolve priority from number or string if (it.priority !== undefined) { const priorityResult = resolvePriority(it.priority); if (!priorityResult.success) { results.push({ index: i, ok: false, error: priorityResult.error, code: 'PRIORITY_INVALID' }); continue; } const validatedPriority = validatePriority(priorityResult.value); if (validatedPriority !== undefined) { payloadInput.priority = validatedPriority; } } // Use shared validation for estimate if (typeof it.estimate === 'number') { // Try to get team ID from the issue let teamId: string | undefined; try { const issue = await client.issue(it.id); teamId = (issue as unknown as { teamId?: string })?.teamId; } catch {} const estimate = await validateEstimate( it.estimate, teamId, teamAllowZeroCache, client, it.allowZeroEstimate, ); if (estimate !== undefined) { payloadInput.estimate = estimate; } } if (typeof it.dueDate === 'string' && it.dueDate.trim() !== '') { payloadInput.dueDate = it.dueDate; } if (typeof it.parentId === 'string' && it.parentId) { payloadInput.parentId = it.parentId; } 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 payload = await withRetry( () => args.parallel === true ? client.updateIssue(it.id, payloadInput) : gate(() => client.updateIssue(it.id, payloadInput)), { maxRetries: 3, baseDelayMs: 500 }, ); // Handle incremental label updates if (it.addLabelIds?.length || it.removeLabelIds?.length) { const issue = await gate(() => client.issue(it.id)); const current = new Set((await issue.labels()).nodes.map((l) => l.id)); it.addLabelIds?.forEach((id) => current.add(id)); it.removeLabelIds?.forEach((id) => current.delete(id)); await (args.parallel === true ? client.updateIssue(it.id, { labelIds: Array.from(current) }) : gate(() => client.updateIssue(it.id, { labelIds: Array.from(current) }))); } // Handle archive/unarchive if (typeof it.archived === 'boolean') { try { const targetArchived = it.archived === true; if (targetArchived) { const anyClient = client as unknown as { archiveIssue?: (id: string) => Promise<unknown>; }; if (typeof anyClient.archiveIssue === 'function') { await (args.parallel === true ? anyClient.archiveIssue?.(it.id) : gate(() => anyClient.archiveIssue?.(it.id) as Promise<unknown>)); } } else { const anyClient = client as unknown as { unarchiveIssue?: (id: string) => Promise<unknown>; }; if (typeof anyClient.unarchiveIssue === 'function') { await (args.parallel === true ? anyClient.unarchiveIssue?.(it.id) : gate(() => anyClient.unarchiveIssue?.(it.id) as Promise<unknown>)); } } } catch { // Ignore archive errors to preserve other updates } } // Build input echo (only include provided fields) const inputEcho: Record<string, unknown> = { id: it.id }; if (it.title) inputEcho.title = it.title; if (it.stateId) inputEcho.stateId = it.stateId; if (it.assigneeId) inputEcho.assigneeId = it.assigneeId; if (it.assigneeName) inputEcho.assigneeName = it.assigneeName; if (it.assigneeEmail) inputEcho.assigneeEmail = it.assigneeEmail; if (it.projectId) inputEcho.projectId = it.projectId; if (it.addLabelIds) inputEcho.addLabelIds = it.addLabelIds; if (it.removeLabelIds) inputEcho.removeLabelIds = it.removeLabelIds; results.push({ input: inputEcho, success: payload.success ?? true, id: it.id, // Legacy index: i, ok: payload.success ?? true, }); // Capture AFTER snapshot using shared utility const afterSnapshot = await gate(() => captureIssueSnapshot(client, it.id)); // Compute changes using shared utility if (afterSnapshot) { const requestedFields = new Set(Object.keys(it)); const changes = computeFieldChanges(beforeSnapshot, afterSnapshot, requestedFields); // Format diff using shared utility if (Object.keys(changes).length > 0) { const diffLine = formatDiffLine(afterSnapshot, changes); diffLines.push(diffLine); } else if (beforeSnapshot) { // No changes detected, just show header const idf = afterSnapshot.identifier ?? afterSnapshot.id; const title = afterSnapshot.url ? `[${idf} — ${afterSnapshot.title}](${afterSnapshot.url})` : `${idf} — ${afterSnapshot.title}`; diffLines.push(`- ${title} (id ${afterSnapshot.id})`); } } } catch (error) { await logger.error('update_issues', { message: 'Failed to update issue', 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 the issue ID exists with list_issues or get_issues.", "Check that stateId exists in workflowStatesByTeam.", "Use list_users to find valid assigneeId.", ], retryable: false, }, // Legacy index: i, ok: false, }); } } const succeeded = results.filter((r) => r.success).length; const failed = results.filter((r) => !r.success).length; const summary = { total: items.length, succeeded, failed, // Legacy ok: succeeded, }; // Build meta with next steps const metaNextSteps: string[] = [ 'Use list_issues or get_issues to verify changes.', ]; if (failed > 0) { metaNextSteps.push("Check error.suggestions for recovery hints."); } const meta = { nextSteps: metaNextSteps, relatedTools: ['list_issues', 'get_issues', 'add_comments'], }; const structured = UpdateIssuesOutputSchema.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 archivedRequested = items.some((x) => typeof x.archived === 'boolean'); // Build summary without next steps first const summaryLine = summarizeBatch({ action: 'Updated issues', ok: summary.ok, total: items.length, okIdentifiers: okIds, failures, }); // Compose: summary → diffs → tips (diffs should come before tips) const nextStep = archivedRequested ? 'Tip: Use list_issues with includeArchived: true to verify archived issues.' : 'Tip: Use list_issues to verify changes.'; const textParts = [summaryLine]; if (diffLines.length > 0) { textParts.push(diffLines.join('\n')); } textParts.push(nextStep); const text = textParts.join('\n\n'); 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