Skip to main content
Glama
iceener

Linear Streamable MCP Server

by iceener
linear-client.ts27.2 kB
/** * Mock Linear Client for testing. * Provides configurable responses for all Linear SDK methods used by tools. */ import { vi } from 'vitest'; import type { LinearClient } from '@linear/sdk'; // ───────────────────────────────────────────────────────────────────────────── // Types // ───────────────────────────────────────────────────────────────────────────── export interface MockViewer { id: string; name?: string; email?: string; displayName?: string; avatarUrl?: string; timezone?: string; createdAt?: Date; } export interface MockTeam { id: string; key?: string; name: string; description?: string; defaultIssueEstimate?: number; cyclesEnabled?: boolean; issueEstimationAllowZero?: boolean; issueEstimationExtended?: boolean; issueEstimationType?: string; states: () => Promise<{ nodes: MockWorkflowState[] }>; labels: (args: { first: number }) => Promise<{ nodes: MockLabel[] }>; projects: (args: { first: number }) => Promise<{ nodes: MockProject[] }>; cycles: (args?: { first?: number; after?: string; includeArchived?: boolean; orderBy?: unknown }) => Promise<{ nodes: MockCycle[]; pageInfo: MockPageInfo }>; } export interface MockWorkflowState { id: string; name: string; type?: string; } export interface MockLabel { id: string; name: string; color?: string; description?: string; } export interface MockProject { id: string; name: string; state?: string; lead?: { id?: string }; leadId?: string; teamId?: string; targetDate?: string; createdAt?: Date | string; } export interface MockIssue { id: string; identifier?: string; title: string; description?: string; priority?: number; estimate?: number; createdAt: Date; updatedAt: Date; archivedAt?: Date; dueDate?: string; url?: string; branchName?: string; state: Promise<{ id: string; name: string; type?: string }>; project: Promise<{ id: string; name?: string } | null>; assignee: Promise<{ id: string; name?: string } | null>; labels: () => Promise<{ nodes: MockLabel[] }>; attachments: () => Promise<{ nodes: unknown[] }>; comments: (args?: { first?: number; after?: string }) => Promise<{ nodes: MockComment[]; pageInfo: MockPageInfo }>; team?: { id: string } | (() => Promise<{ id: string }>); } export interface MockUser { id: string; name?: string; email?: string; displayName?: string; avatarUrl?: string; } export interface MockComment { id: string; body?: string; url?: string; createdAt: Date; updatedAt?: Date; user?: { id: string; name?: string }; } export interface MockCycle { id: string; name?: string; number?: number; startsAt?: Date; endsAt?: Date; completedAt?: Date; team: { id: string }; } export interface MockPageInfo { hasNextPage: boolean; endCursor?: string; } export interface MockConnection<T> { nodes: T[]; pageInfo: MockPageInfo; } // ───────────────────────────────────────────────────────────────────────────── // Default Mock Data // ───────────────────────────────────────────────────────────────────────────── export const defaultMockViewer: MockViewer = { id: 'user-001', name: 'Test User', email: 'test@example.com', displayName: 'Test User', avatarUrl: 'https://example.com/avatar.png', timezone: 'Europe/Warsaw', createdAt: new Date('2024-01-01T00:00:00Z'), }; export const defaultMockStates: MockWorkflowState[] = [ { id: 'state-backlog', name: 'Backlog', type: 'backlog' }, { id: 'state-todo', name: 'Todo', type: 'unstarted' }, { id: 'state-inprogress', name: 'In Progress', type: 'started' }, { id: 'state-done', name: 'Done', type: 'completed' }, { id: 'state-canceled', name: 'Canceled', type: 'canceled' }, ]; export const defaultMockLabels: MockLabel[] = [ { id: 'label-bug', name: 'Bug', color: '#ff0000' }, { id: 'label-feature', name: 'Feature', color: '#00ff00' }, { id: 'label-docs', name: 'Documentation', color: '#0000ff' }, ]; export const defaultMockProjects: MockProject[] = [ { id: 'project-001', name: 'Q1 Release', state: 'started', lead: { id: 'user-001' }, leadId: 'user-001', teamId: 'team-eng', targetDate: '2025-03-31', createdAt: new Date('2024-12-01T00:00:00Z'), }, { id: 'project-002', name: 'Infrastructure', state: 'planned', leadId: 'user-002', createdAt: new Date('2024-11-01T00:00:00Z'), }, ]; export const defaultMockTeams: MockTeam[] = [ { id: 'team-eng', key: 'ENG', name: 'Engineering', description: 'Core engineering team', defaultIssueEstimate: 2, cyclesEnabled: true, issueEstimationAllowZero: false, issueEstimationType: 'fibonacci', states: () => Promise.resolve({ nodes: defaultMockStates }), labels: () => Promise.resolve({ nodes: defaultMockLabels }), projects: () => Promise.resolve({ nodes: defaultMockProjects }), cycles: (args) => { const limit = args?.first ?? defaultMockCycles.length; const cyclesForTeam = defaultMockCycles.filter((c) => c.team.id === 'team-eng'); return Promise.resolve({ nodes: cyclesForTeam.slice(0, limit), pageInfo: { hasNextPage: cyclesForTeam.length > limit, endCursor: cyclesForTeam.length > limit ? 'cycle-cursor' : undefined, }, }); }, }, { id: 'team-design', key: 'DES', name: 'Design', cyclesEnabled: false, states: () => Promise.resolve({ nodes: defaultMockStates }), labels: () => Promise.resolve({ nodes: [] }), projects: () => Promise.resolve({ nodes: [] }), cycles: () => Promise.resolve({ nodes: [], pageInfo: { hasNextPage: false } }), }, ]; export const defaultMockComments: MockComment[] = [ { id: 'comment-001', body: 'This looks good, approved!', url: 'https://linear.app/team/issue/ENG-123/comment-001', createdAt: new Date('2024-12-15T10:00:00Z'), updatedAt: new Date('2024-12-15T10:00:00Z'), user: { id: 'user-002', name: 'Jane Doe' }, }, { id: 'comment-002', body: 'Deployed to staging for testing', createdAt: new Date('2024-12-15T14:00:00Z'), user: { id: 'user-001', name: 'Test User' }, }, ]; export const defaultMockIssues: MockIssue[] = [ { id: 'issue-001', identifier: 'ENG-123', title: 'Fix authentication bug', description: 'Users are being logged out unexpectedly', priority: 1, estimate: 3, createdAt: new Date('2024-12-10T10:00:00Z'), updatedAt: new Date('2024-12-15T14:30:00Z'), url: 'https://linear.app/team/issue/ENG-123', branchName: 'fix/auth-bug', state: Promise.resolve({ id: 'state-inprogress', name: 'In Progress', type: 'started' }), project: Promise.resolve({ id: 'project-001', name: 'Q1 Release' }), assignee: Promise.resolve({ id: 'user-001', name: 'Test User' }), labels: () => Promise.resolve({ nodes: [{ id: 'label-bug', name: 'Bug' }] }), attachments: () => Promise.resolve({ nodes: [] }), comments: (args) => Promise.resolve({ nodes: defaultMockComments.slice(0, args?.first ?? defaultMockComments.length), pageInfo: { hasNextPage: false }, }), team: { id: 'team-eng' }, }, { id: 'issue-002', identifier: 'ENG-124', title: 'Add dark mode support', description: 'Implement dark mode toggle in settings', priority: 2, createdAt: new Date('2024-12-11T09:00:00Z'), updatedAt: new Date('2024-12-14T11:00:00Z'), url: 'https://linear.app/team/issue/ENG-124', state: Promise.resolve({ id: 'state-todo', name: 'Todo', type: 'unstarted' }), project: Promise.resolve({ id: 'project-001', name: 'Q1 Release' }), assignee: Promise.resolve(null), labels: () => Promise.resolve({ nodes: [{ id: 'label-feature', name: 'Feature' }] }), attachments: () => Promise.resolve({ nodes: [] }), comments: () => Promise.resolve({ nodes: [], pageInfo: { hasNextPage: false } }), team: { id: 'team-eng' }, }, { id: 'issue-003', identifier: 'ENG-125', title: 'Update API documentation', priority: 3, createdAt: new Date('2024-12-12T08:00:00Z'), updatedAt: new Date('2024-12-12T08:00:00Z'), state: Promise.resolve({ id: 'state-backlog', name: 'Backlog', type: 'backlog' }), project: Promise.resolve(null), assignee: Promise.resolve(null), labels: () => Promise.resolve({ nodes: [{ id: 'label-docs', name: 'Documentation' }] }), attachments: () => Promise.resolve({ nodes: [] }), comments: () => Promise.resolve({ nodes: [], pageInfo: { hasNextPage: false } }), team: { id: 'team-eng' }, }, { id: 'issue-004', identifier: 'ENG-126', title: 'Fix login page styling', priority: 2, createdAt: new Date('2024-12-01T08:00:00Z'), updatedAt: new Date('2024-12-10T16:00:00Z'), state: Promise.resolve({ id: 'state-done', name: 'Done', type: 'completed' }), project: Promise.resolve({ id: 'project-001', name: 'Q1 Release' }), assignee: Promise.resolve({ id: 'user-002', name: 'Jane Doe' }), labels: () => Promise.resolve({ nodes: [{ id: 'label-bug', name: 'Bug' }] }), attachments: () => Promise.resolve({ nodes: [] }), comments: () => Promise.resolve({ nodes: [], pageInfo: { hasNextPage: false } }), team: { id: 'team-eng' }, }, { id: 'issue-005', identifier: 'ENG-127', title: 'Add user profile page', priority: 3, createdAt: new Date('2024-11-20T08:00:00Z'), updatedAt: new Date('2024-12-05T12:00:00Z'), state: Promise.resolve({ id: 'state-cancelled', name: 'Cancelled', type: 'canceled' }), project: Promise.resolve({ id: 'project-002', name: 'Infrastructure' }), assignee: Promise.resolve({ id: 'user-001', name: 'Test User' }), labels: () => Promise.resolve({ nodes: [{ id: 'label-feature', name: 'Feature' }] }), attachments: () => Promise.resolve({ nodes: [] }), comments: () => Promise.resolve({ nodes: [], pageInfo: { hasNextPage: false } }), team: { id: 'team-design' }, }, ]; export const defaultMockUsers: MockUser[] = [ { id: 'user-001', name: 'Test User', email: 'test@example.com', displayName: 'Test User' }, { id: 'user-002', name: 'Jane Doe', email: 'jane@example.com', displayName: 'Jane' }, { id: 'user-003', name: 'Bob Smith', email: 'bob@example.com', displayName: 'Bob' }, ]; export const defaultMockCycles: MockCycle[] = [ { id: 'cycle-001', name: 'Sprint 1', number: 1, startsAt: new Date('2024-12-09T00:00:00Z'), endsAt: new Date('2024-12-22T23:59:59Z'), team: { id: 'team-eng' }, }, { id: 'cycle-002', name: 'Sprint 2', number: 2, startsAt: new Date('2024-12-23T00:00:00Z'), endsAt: new Date('2025-01-05T23:59:59Z'), team: { id: 'team-eng' }, }, ]; // ───────────────────────────────────────────────────────────────────────────── // Mock Client Factory // ───────────────────────────────────────────────────────────────────────────── export interface MockLinearClientConfig { viewer?: MockViewer; teams?: MockTeam[]; issues?: MockIssue[]; users?: MockUser[]; cycles?: MockCycle[]; projects?: MockProject[]; comments?: MockComment[]; favorites?: unknown[]; } export interface MockLinearClient { viewer: Promise<MockViewer>; teams: (args?: { first?: number }) => Promise<MockConnection<MockTeam>>; team: (id: string) => Promise<MockTeam | null>; issues: (args?: Record<string, unknown>) => Promise<MockConnection<MockIssue>>; issue: (id: string) => Promise<MockIssue | null>; users: (args?: { first?: number }) => Promise<MockConnection<MockUser>>; favorites: (args?: { first?: number }) => Promise<MockConnection<unknown>>; projects: (args?: { first?: number; after?: string; filter?: Record<string, unknown> }) => Promise<MockConnection<MockProject>>; cycles: (args?: { first?: number; after?: string; filter?: Record<string, unknown> }) => Promise<MockConnection<MockCycle>>; comments: (issueId: string, args?: { first?: number; after?: string }) => Promise<MockConnection<MockComment>>; createIssue: (input: Record<string, unknown>) => Promise<{ success: boolean; issue?: { id: string; identifier: string } }>; updateIssue: (id: string, input: Record<string, unknown>) => Promise<{ success: boolean; issue?: { id: string; identifier: string } }>; createProject: (input: Record<string, unknown>) => Promise<{ success: boolean; project?: { id: string; name: string } }>; updateProject: (id: string, input: Record<string, unknown>) => Promise<{ success: boolean; project?: { id: string; name: string } }>; createComment: (input: Record<string, unknown>) => Promise<{ success: boolean; comment?: { id: string } }>; updateComment: (id: string, input: Record<string, unknown>) => Promise<{ success: boolean; comment?: { id: string } }>; /** Raw GraphQL client for rawRequest calls */ client: { rawRequest: (query: string, variables?: Record<string, unknown>) => Promise<{ data: unknown }>; }; // Internal config for test assertions _config: MockLinearClientConfig; _calls: { issues: Array<Record<string, unknown>>; createIssue: Array<Record<string, unknown>>; updateIssue: Array<{ id: string; input: Record<string, unknown> }>; rawRequest: Array<{ query: string; variables?: Record<string, unknown> }>; }; } export function createMockLinearClient(config: MockLinearClientConfig = {}): MockLinearClient { const { viewer = defaultMockViewer, teams = defaultMockTeams, issues = defaultMockIssues, users = defaultMockUsers, cycles = defaultMockCycles, projects = defaultMockProjects, comments = [], favorites = [], } = config; const calls = { issues: [] as Array<Record<string, unknown>>, createIssue: [] as Array<Record<string, unknown>>, updateIssue: [] as Array<{ id: string; input: Record<string, unknown> }>, rawRequest: [] as Array<{ query: string; variables?: Record<string, unknown> }>, }; return { viewer: Promise.resolve(viewer), teams: vi.fn(async (args?: { first?: number }) => ({ nodes: teams.slice(0, args?.first ?? teams.length), pageInfo: { hasNextPage: false }, })), team: vi.fn(async (id: string) => { return teams.find((t) => t.id === id) ?? null; }), issues: vi.fn(async (args?: Record<string, unknown>) => { calls.issues.push(args ?? {}); const limit = (args?.first as number) ?? 25; return { nodes: issues.slice(0, limit), pageInfo: { hasNextPage: issues.length > limit, endCursor: 'cursor-next' }, }; }), issue: vi.fn(async (id: string) => { // Support both UUID and identifier lookup return issues.find((i) => i.id === id || i.identifier === id) ?? null; }), users: vi.fn(async (args?: { first?: number }) => ({ nodes: users.slice(0, args?.first ?? users.length), pageInfo: { hasNextPage: false }, })), favorites: vi.fn(async () => ({ nodes: favorites, pageInfo: { hasNextPage: false }, })), projects: vi.fn(async (args?: { first?: number; after?: string; filter?: Record<string, unknown> }) => { const limit = args?.first ?? projects.length; return { nodes: projects.slice(0, limit), pageInfo: { hasNextPage: projects.length > limit, endCursor: projects.length > limit ? 'project-cursor' : undefined }, }; }), cycles: vi.fn(async (args?: { first?: number; after?: string; filter?: Record<string, unknown> }) => { const limit = args?.first ?? cycles.length; const filtered = args?.filter?.team?.id?.eq ? cycles.filter((c) => c.team.id === args.filter.team.id.eq) : cycles; return { nodes: filtered.slice(0, limit), pageInfo: { hasNextPage: filtered.length > limit, endCursor: filtered.length > limit ? 'cycle-cursor' : undefined }, }; }), comments: vi.fn(async (issueId: string, args?: { first?: number; after?: string }) => { const limit = args?.first ?? comments.length; return { nodes: comments.slice(0, limit), pageInfo: { hasNextPage: comments.length > limit, endCursor: comments.length > limit ? 'comment-cursor' : undefined }, }; }), createIssue: vi.fn(async (input: Record<string, unknown>) => { calls.createIssue.push(input); const newId = `issue-new-${Date.now()}`; const teamKey = teams.find((t) => t.id === input.teamId)?.key ?? 'XXX'; return { success: true, issue: { id: newId, identifier: `${teamKey}-999` }, }; }), updateIssue: vi.fn(async (id: string, input: Record<string, unknown>) => { calls.updateIssue.push({ id, input }); const existing = issues.find((i) => i.id === id || i.identifier === id); return { success: !!existing, issue: existing ? { id: existing.id, identifier: existing.identifier ?? id } : undefined, }; }), createProject: vi.fn(async (input: Record<string, unknown>) => ({ success: true, project: { id: `project-new-${Date.now()}`, name: input.name as string }, })), updateProject: vi.fn(async (id: string, input: Record<string, unknown>) => ({ success: true, project: { id, name: (input.name as string) ?? 'Updated Project' }, })), createComment: vi.fn(async (input: Record<string, unknown>) => ({ success: true, comment: { id: `comment-new-${Date.now()}` }, })), updateComment: vi.fn(async (id: string, input: Record<string, unknown>) => ({ success: true, comment: { id }, })), // Raw GraphQL client for rawRequest calls (used by list-issues, list-my-issues, etc.) client: { rawRequest: vi.fn(async (query: string, variables?: Record<string, unknown>) => { calls.rawRequest.push({ query, variables }); const filter = variables?.filter as Record<string, unknown> | undefined; // Helper to apply filters to issues const applyFilters = async (issuesToFilter: MockIssue[]): Promise<MockIssue[]> => { if (!filter) return issuesToFilter; const results: MockIssue[] = []; for (const issue of issuesToFilter) { const stateData = await issue.state; const projectData = await issue.project; const assigneeData = await issue.assignee; let matches = true; // State type filter if (filter.state && typeof filter.state === 'object') { const stateFilter = filter.state as Record<string, unknown>; if (stateFilter.type && typeof stateFilter.type === 'object') { const typeFilter = stateFilter.type as Record<string, unknown>; if (typeFilter.eq && stateData?.type !== typeFilter.eq) { matches = false; } if (typeFilter.neq && stateData?.type === typeFilter.neq) { matches = false; } if (typeFilter.in && Array.isArray(typeFilter.in)) { if (!typeFilter.in.includes(stateData?.type)) { matches = false; } } } } // Team filter if (filter.team && typeof filter.team === 'object') { const teamFilter = filter.team as Record<string, unknown>; if (teamFilter.id && typeof teamFilter.id === 'object') { const idFilter = teamFilter.id as Record<string, unknown>; const issueTeamId = typeof issue.team === 'function' ? (await issue.team()).id : issue.team?.id; if (idFilter.eq && issueTeamId !== idFilter.eq) { matches = false; } } } // Project filter if (filter.project && typeof filter.project === 'object') { const projectFilter = filter.project as Record<string, unknown>; if (projectFilter.id && typeof projectFilter.id === 'object') { const idFilter = projectFilter.id as Record<string, unknown>; if (idFilter.eq && projectData?.id !== idFilter.eq) { matches = false; } } } // Assignee filter if (filter.assignee && typeof filter.assignee === 'object') { const assigneeFilter = filter.assignee as Record<string, unknown>; if (assigneeFilter.id && typeof assigneeFilter.id === 'object') { const idFilter = assigneeFilter.id as Record<string, unknown>; if (idFilter.eq && assigneeData?.id !== idFilter.eq) { matches = false; } } } // Title keyword filter (OR logic) if (filter.or && Array.isArray(filter.or)) { const orMatches = filter.or.some((orFilter: Record<string, unknown>) => { if (orFilter.title && typeof orFilter.title === 'object') { const titleFilter = orFilter.title as Record<string, unknown>; if (titleFilter.containsIgnoreCase) { return issue.title .toLowerCase() .includes((titleFilter.containsIgnoreCase as string).toLowerCase()); } } return false; }); if (!orMatches) { matches = false; } } // Date filters if (filter.updatedAt && typeof filter.updatedAt === 'object') { const dateFilter = filter.updatedAt as Record<string, unknown>; if (dateFilter.gte) { const minDate = new Date(dateFilter.gte as string); if (issue.updatedAt < minDate) { matches = false; } } if (dateFilter.lte) { const maxDate = new Date(dateFilter.lte as string); if (issue.updatedAt > maxDate) { matches = false; } } } if (matches) { results.push(issue); } } return results; }; // Helper to format issue nodes for GraphQL response const formatIssueNodes = async (issuesToFormat: MockIssue[]) => { return await Promise.all( issuesToFormat.map(async (issue) => { const stateData = await issue.state; const projectData = await issue.project; const assigneeData = await issue.assignee; return { id: issue.id, identifier: issue.identifier, title: issue.title, description: issue.description, priority: issue.priority, estimate: issue.estimate, state: stateData, project: projectData, assignee: assigneeData, createdAt: issue.createdAt.toISOString(), updatedAt: issue.updatedAt.toISOString(), archivedAt: issue.archivedAt?.toISOString() ?? null, dueDate: issue.dueDate ?? null, url: issue.url ?? null, labels: { nodes: [] }, }; }), ); }; // Detect query type and return appropriate mock data if (query.includes('assignedIssues(')) { // list_my_issues query (viewer.assignedIssues) const limit = (variables?.first as number) ?? 20; const filtered = await applyFilters(issues); const limited = filtered.slice(0, limit); const issueNodes = await formatIssueNodes(limited); return { data: { viewer: { assignedIssues: { nodes: issueNodes, pageInfo: { endCursor: filtered.length > limit ? 'cursor-next' : null, }, }, }, }, }; } if (query.includes('issues(')) { // list_issues query const limit = (variables?.first as number) ?? 25; const filtered = await applyFilters(issues); const limited = filtered.slice(0, limit); const issueNodes = await formatIssueNodes(limited); return { data: { issues: { nodes: issueNodes, pageInfo: { hasNextPage: filtered.length > limit, endCursor: filtered.length > limit ? 'cursor-next' : null, }, }, }, }; } // Default empty response for unknown queries return { data: {} }; }), }, _config: config, _calls: calls, }; } /** * Reset all mock function calls. * Call this in beforeEach to ensure clean state. */ export function resetMockCalls(client: MockLinearClient): void { client._calls.issues = []; client._calls.createIssue = []; client._calls.updateIssue = []; client._calls.rawRequest = []; // Reset vi.fn() call history (client.teams as ReturnType<typeof vi.fn>).mockClear(); (client.team as ReturnType<typeof vi.fn>).mockClear(); (client.issues as ReturnType<typeof vi.fn>).mockClear(); (client.issue as ReturnType<typeof vi.fn>).mockClear(); (client.users as ReturnType<typeof vi.fn>).mockClear(); (client.favorites as ReturnType<typeof vi.fn>).mockClear(); (client.projects as ReturnType<typeof vi.fn>).mockClear(); (client.cycles as ReturnType<typeof vi.fn>).mockClear(); (client.comments as ReturnType<typeof vi.fn>).mockClear(); (client.createIssue as ReturnType<typeof vi.fn>).mockClear(); (client.updateIssue as ReturnType<typeof vi.fn>).mockClear(); (client.createProject as ReturnType<typeof vi.fn>).mockClear(); (client.updateProject as ReturnType<typeof vi.fn>).mockClear(); (client.createComment as ReturnType<typeof vi.fn>).mockClear(); (client.client.rawRequest as ReturnType<typeof vi.fn>).mockClear(); }

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