Skip to main content
Glama
iceener

Linear Streamable MCP Server

by iceener
comments.test.ts19.8 kB
/** * Tests for comment tools (list, add). * Verifies: comment listing, adding comments, batch operations, output shapes. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { listCommentsTool, addCommentsTool, } from '../../src/shared/tools/linear/comments.js'; import { createMockLinearClient, resetMockCalls, type MockLinearClient, } from '../mocks/linear-client.js'; import type { ToolContext } from '../../src/shared/tools/types.js'; // ───────────────────────────────────────────────────────────────────────────── // Test Setup // ───────────────────────────────────────────────────────────────────────────── let mockClient: MockLinearClient; const baseContext: ToolContext = { sessionId: 'test-session', providerToken: 'test-token', authStrategy: 'bearer', }; vi.mock('../../src/services/linear/client.js', () => ({ getLinearClient: vi.fn(() => Promise.resolve(mockClient)), })); beforeEach(() => { mockClient = createMockLinearClient(); resetMockCalls(mockClient); }); // ───────────────────────────────────────────────────────────────────────────── // List Comments Tests // ───────────────────────────────────────────────────────────────────────────── describe('list_comments tool', () => { describe('metadata', () => { it('has correct name and title', () => { expect(listCommentsTool.name).toBe('list_comments'); expect(listCommentsTool.title).toBe('List Comments'); }); it('has readOnlyHint annotation', () => { expect(listCommentsTool.annotations?.readOnlyHint).toBe(true); expect(listCommentsTool.annotations?.destructiveHint).toBe(false); }); }); describe('input validation', () => { it('requires issueId parameter', () => { const result = listCommentsTool.inputSchema.safeParse({}); expect(result.success).toBe(false); }); it('accepts valid issueId', () => { const result = listCommentsTool.inputSchema.safeParse({ issueId: 'issue-001' }); expect(result.success).toBe(true); }); it('accepts optional limit', () => { const result = listCommentsTool.inputSchema.safeParse({ issueId: 'issue-001', limit: 10, }); expect(result.success).toBe(true); }); it('accepts optional cursor', () => { const result = listCommentsTool.inputSchema.safeParse({ issueId: 'issue-001', cursor: 'test-cursor', }); expect(result.success).toBe(true); }); }); describe('handler behavior', () => { it('returns comments for specified issue', async () => { const result = await listCommentsTool.handler({ issueId: 'issue-001' }, baseContext); expect(result.isError).toBeFalsy(); const structured = result.structuredContent as Record<string, unknown>; expect(structured.items).toBeDefined(); expect(Array.isArray(structured.items)).toBe(true); }); it('respects limit parameter', async () => { const result = await listCommentsTool.handler( { issueId: 'issue-001', limit: 5 }, baseContext, ); expect(result.isError).toBeFalsy(); const structured = result.structuredContent as Record<string, unknown>; expect(structured.limit).toBe(5); }); it('supports pagination with cursor', async () => { const result = await listCommentsTool.handler( { issueId: 'issue-001', cursor: 'test-cursor' }, baseContext, ); expect(result.isError).toBeFalsy(); const structured = result.structuredContent as Record<string, unknown>; expect(structured.cursor).toBe('test-cursor'); }); }); describe('output shape', () => { it('matches ListCommentsOutputSchema', async () => { const result = await listCommentsTool.handler({ issueId: 'issue-001' }, baseContext); const structured = result.structuredContent as Record<string, unknown>; const items = structured.items as Array<Record<string, unknown>>; for (const item of items) { expect(item.id).toBeDefined(); expect(item.createdAt).toBeDefined(); expect(typeof item.id).toBe('string'); } }); it('includes comment metadata (body, user, dates)', async () => { const result = await listCommentsTool.handler({ issueId: 'issue-001' }, baseContext); const structured = result.structuredContent as Record<string, unknown>; const items = structured.items as Array<Record<string, unknown>>; expect(items.length).toBeGreaterThan(0); const firstComment = items[0]; // Body content expect(firstComment.body).toBeDefined(); expect(typeof firstComment.body).toBe('string'); // User info expect(firstComment.user).toBeDefined(); // Timestamps expect(firstComment.createdAt).toBeDefined(); expect(typeof firstComment.createdAt).toBe('string'); }); it('includes pagination info', async () => { const result = await listCommentsTool.handler({ issueId: 'issue-001' }, baseContext); const structured = result.structuredContent as Record<string, unknown>; expect('nextCursor' in structured || 'cursor' in structured).toBe(true); }); }); describe('common workflows', () => { it('reads discussion history on an issue', async () => { const result = await listCommentsTool.handler({ issueId: 'issue-001' }, baseContext); expect(result.isError).toBeFalsy(); // Verify comments are actually returned const structured = result.structuredContent as Record<string, unknown>; const items = structured.items as Array<Record<string, unknown>>; expect(items.length).toBeGreaterThan(0); // Verify text output mentions comment count const textContent = result.content[0].text; expect(textContent).toContain('Comments'); expect(textContent).toMatch(/\d+/); // Contains count }); }); }); // ───────────────────────────────────────────────────────────────────────────── // Add Comments Tests // ───────────────────────────────────────────────────────────────────────────── describe('add_comments tool', () => { describe('metadata', () => { it('has correct name and title', () => { expect(addCommentsTool.name).toBe('add_comments'); expect(addCommentsTool.title).toBe('Add Comments (Batch)'); }); it('has non-destructive annotations', () => { expect(addCommentsTool.annotations?.readOnlyHint).toBe(false); expect(addCommentsTool.annotations?.destructiveHint).toBe(false); }); }); describe('input validation', () => { it('requires at least one item', () => { const result = addCommentsTool.inputSchema.safeParse({ items: [] }); expect(result.success).toBe(false); }); it('requires issueId for each comment', () => { const result = addCommentsTool.inputSchema.safeParse({ items: [{ body: 'Test comment' }], }); expect(result.success).toBe(false); }); it('requires body for each comment', () => { const result = addCommentsTool.inputSchema.safeParse({ items: [{ issueId: 'issue-001' }], }); expect(result.success).toBe(false); }); it('accepts empty body (Linear SDK allows it)', () => { // The schema doesn't enforce min(1) on body - Linear API handles this const result = addCommentsTool.inputSchema.safeParse({ items: [{ issueId: 'issue-001', body: '' }], }); expect(result.success).toBe(true); }); it('accepts valid comment', () => { const result = addCommentsTool.inputSchema.safeParse({ items: [{ issueId: 'issue-001', body: 'This is a test comment' }], }); expect(result.success).toBe(true); }); it('accepts multiple comments', () => { const result = addCommentsTool.inputSchema.safeParse({ items: [ { issueId: 'issue-001', body: 'Comment 1' }, { issueId: 'issue-002', body: 'Comment 2' }, ], }); expect(result.success).toBe(true); }); it('ignores unknown keys like dry_run (passthrough schema)', () => { // add_comments schema doesn't use strict() so extra keys are ignored const result = addCommentsTool.inputSchema.safeParse({ items: [{ issueId: 'issue-001', body: 'Test' }], dry_run: true, }); expect(result.success).toBe(true); }); it('accepts parallel option', () => { const result = addCommentsTool.inputSchema.safeParse({ items: [{ issueId: 'issue-001', body: 'Test' }], parallel: true, }); expect(result.success).toBe(true); }); }); describe('handler behavior', () => { it('adds a single comment', async () => { const result = await addCommentsTool.handler( { items: [{ issueId: 'issue-001', body: 'Great progress on this!' }], }, baseContext, ); expect(result.isError).toBeFalsy(); const structured = result.structuredContent as Record<string, unknown>; const summary = structured.summary as { ok: number; failed: number }; expect(summary.ok).toBe(1); expect(mockClient.createComment).toHaveBeenCalledTimes(1); }); it('passes comment body to API', async () => { const result = await addCommentsTool.handler( { items: [{ issueId: 'issue-001', body: 'Status update: deployed to staging' }], }, baseContext, ); expect(result.isError).toBeFalsy(); expect(mockClient.createComment).toHaveBeenCalledWith( expect.objectContaining({ issueId: 'issue-001', body: 'Status update: deployed to staging', }), ); }); it('batch adds multiple comments', async () => { const result = await addCommentsTool.handler( { items: [ { issueId: 'issue-001', body: 'Comment A' }, { issueId: 'issue-002', body: 'Comment B' }, { issueId: 'issue-001', body: 'Comment C' }, ], }, baseContext, ); expect(result.isError).toBeFalsy(); const structured = result.structuredContent as Record<string, unknown>; const summary = structured.summary as { ok: number; failed: number }; expect(summary.ok).toBe(3); expect(mockClient.createComment).toHaveBeenCalledTimes(3); }); it('creates comment without dry_run option (not supported)', async () => { // add_comments doesn't support dry_run const result = await addCommentsTool.handler( { items: [{ issueId: 'issue-001', body: 'Real comment' }], }, baseContext, ); expect(result.isError).toBeFalsy(); // Verify createComment WAS called expect(mockClient.createComment).toHaveBeenCalled(); }); it('returns comment IDs', async () => { const result = await addCommentsTool.handler( { items: [{ issueId: 'issue-001', body: 'Test' }], }, baseContext, ); const structured = result.structuredContent as Record<string, unknown>; const results = structured.results as Array<Record<string, unknown>>; expect(results[0].ok).toBe(true); expect(results[0].id).toBeDefined(); }); }); describe('output shape', () => { it('matches AddCommentsOutputSchema', async () => { const result = await addCommentsTool.handler( { items: [{ issueId: 'issue-001', body: 'Test' }], }, baseContext, ); const structured = result.structuredContent as Record<string, unknown>; expect(structured.results).toBeDefined(); expect(structured.summary).toBeDefined(); const results = structured.results as Array<Record<string, unknown>>; expect(Array.isArray(results)).toBe(true); const summary = structured.summary as Record<string, unknown>; expect(typeof summary.ok).toBe('number'); expect(typeof summary.failed).toBe('number'); }); }); describe('common workflows', () => { it('adds status update to issue', async () => { const statusUpdate = 'Deployed to production. Monitoring for issues.'; const result = await addCommentsTool.handler( { items: [ { issueId: 'issue-001', body: statusUpdate, }, ], }, baseContext, ); expect(result.isError).toBeFalsy(); // Verify the exact body was sent expect(mockClient.createComment).toHaveBeenCalledWith( expect.objectContaining({ issueId: 'issue-001', body: statusUpdate, }), ); }); it('mentions teammate in comment', async () => { const result = await addCommentsTool.handler( { items: [ { issueId: 'issue-001', body: '@jane Could you review the changes?', }, ], }, baseContext, ); expect(result.isError).toBeFalsy(); // Body should preserve @ mentions expect(mockClient.createComment).toHaveBeenCalledWith( expect.objectContaining({ body: expect.stringContaining('@jane'), }), ); }); it('adds follow-up comments to multiple issues', async () => { const result = await addCommentsTool.handler( { items: [ { issueId: 'issue-001', body: 'Fixed in PR #123' }, { issueId: 'issue-002', body: 'Fixed in PR #123' }, { issueId: 'issue-003', body: 'Fixed in PR #123' }, ], }, baseContext, ); expect(result.isError).toBeFalsy(); const structured = result.structuredContent as Record<string, unknown>; const summary = structured.summary as { ok: number; failed: number }; expect(summary.ok).toBe(3); }); }); }); // ───────────────────────────────────────────────────────────────────────────── // Workflow Integration Tests // ───────────────────────────────────────────────────────────────────────────── describe('comments workflow integration', () => { it('list comments, then add new comment', async () => { // Step 1: Check existing comments const listResult = await listCommentsTool.handler( { issueId: 'issue-001' }, baseContext, ); expect(listResult.isError).toBeFalsy(); // Step 2: Add new comment const addResult = await addCommentsTool.handler( { items: [{ issueId: 'issue-001', body: 'Following up on discussion' }], }, baseContext, ); expect(addResult.isError).toBeFalsy(); }); }); // ───────────────────────────────────────────────────────────────────────────── // update_comments Tool Tests // ───────────────────────────────────────────────────────────────────────────── import { updateCommentsTool } from '../../src/shared/tools/linear/comments.js'; describe('update_comments tool', () => { describe('metadata', () => { it('has correct name and title', () => { expect(updateCommentsTool.name).toBe('update_comments'); expect(updateCommentsTool.title).toBe('Update Comments (Batch)'); }); it('is not read-only or destructive', () => { expect(updateCommentsTool.annotations?.readOnlyHint).toBe(false); expect(updateCommentsTool.annotations?.destructiveHint).toBe(false); }); it('description mentions no delete', () => { expect(updateCommentsTool.description).toContain('Cannot delete'); }); }); describe('input validation', () => { it('requires at least one comment', () => { const result = updateCommentsTool.inputSchema.safeParse({ items: [] }); expect(result.success).toBe(false); }); it('requires id for each comment', () => { const result = updateCommentsTool.inputSchema.safeParse({ items: [{ body: 'Updated body' }], }); expect(result.success).toBe(false); }); it('requires body for each comment', () => { const result = updateCommentsTool.inputSchema.safeParse({ items: [{ id: 'comment-001' }], }); expect(result.success).toBe(false); }); it('rejects empty body', () => { const result = updateCommentsTool.inputSchema.safeParse({ items: [{ id: 'comment-001', body: '' }], }); expect(result.success).toBe(false); }); it('accepts valid update', () => { const result = updateCommentsTool.inputSchema.safeParse({ items: [{ id: 'comment-001', body: 'Updated content' }], }); expect(result.success).toBe(true); }); }); describe('handler behavior', () => { it('updates single comment', async () => { const result = await updateCommentsTool.handler( { items: [{ id: 'comment-001', body: 'Updated comment body' }], }, baseContext, ); expect(result.isError).toBeFalsy(); expect(mockClient.updateComment).toHaveBeenCalledWith('comment-001', { body: 'Updated comment body' }); const structured = result.structuredContent as Record<string, unknown>; const summary = structured.summary as { ok: number; failed: number }; expect(summary.ok).toBe(1); }); it('batch updates multiple comments', async () => { const result = await updateCommentsTool.handler( { items: [ { id: 'comment-001', body: 'Updated 1' }, { id: 'comment-002', body: 'Updated 2' }, ], }, baseContext, ); expect(result.isError).toBeFalsy(); expect(mockClient.updateComment).toHaveBeenCalledTimes(2); const structured = result.structuredContent as Record<string, unknown>; const summary = structured.summary as { ok: number; failed: number }; expect(summary.ok).toBe(2); }); it('suggests verifying with list_comments', async () => { const result = await updateCommentsTool.handler( { items: [{ id: 'comment-001', body: 'Updated' }], }, baseContext, ); expect(result.isError).toBeFalsy(); expect(result.content[0].text).toContain('list_comments'); }); }); });

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