From d42b2763720e124c608026c13df7b10bb0b581ea Mon Sep 17 00:00:00 2001 From: Darin Kishore Date: Fri, 9 May 2025 03:49:59 -0400 Subject: [PATCH 1/8] feat: add document type definitions and interfaces --- src/types/linear/document.ts | 91 ++++++++++++++++++++++++++++++++++++ src/types/linear/index.ts | 10 ++-- 2 files changed, 96 insertions(+), 5 deletions(-) create mode 100644 src/types/linear/document.ts diff --git a/src/types/linear/document.ts b/src/types/linear/document.ts new file mode 100644 index 0000000..ea0a1e8 --- /dev/null +++ b/src/types/linear/document.ts @@ -0,0 +1,91 @@ +import { LinearBaseModel } from './base'; + +// Document-related argument interfaces +export interface GetDocumentsArgs { + nameFilter?: string; // Optional filter by document title + includeArchived?: boolean; // Whether to include archived documents + teamId?: string; // Filter documents by team ID + projectId?: string; // Filter documents by project ID + first?: number; // Pagination: number of items to return (default: 50, max: 100) + after?: string; // Pagination: cursor for fetching next page +} + +export interface GetDocumentArgs { + documentId: string; // The ID of the Linear document + documentSlug?: string; // The URL slug of the document (alternative to documentId) + includeFull?: boolean; // Whether to include the full document content (default: true) +} + +export interface CreateDocumentArgs { + teamId: string; // ID of the team this document belongs to + title: string; // Title of the document + content?: string; // Content of the document in markdown format + icon?: string; // Emoji icon for the document + projectId?: string; // ID of the project to associate this document with + isPublic?: boolean; // Whether the document should be accessible outside the organization +} + +export interface UpdateDocumentArgs { + documentId: string; // ID of the document to update + title?: string; // New title for the document + content?: string; // New content for the document in markdown format + icon?: string; // New emoji icon for the document + projectId?: string; // ID of the project to move this document to + teamId?: string; // ID of the team to move this document to + isArchived?: boolean; // Whether the document should be archived + isPublic?: boolean; // Whether the document should be accessible outside the organization +} + +export interface DeleteDocumentArgs { + documentId: string; // ID of the document to delete +} + +// Document entities and responses +export interface LinearDocument { + id: string; + title: string; + content?: string; // Document content in markdown format + contentPreview?: string; // Truncated preview of content + icon?: string; // Emoji icon + createdAt: string; // ISO date string + updatedAt: string; // ISO date string + archivedAt?: string; // ISO date string if archived + creator?: { + id: string; + name: string; + }; + lastUpdatedBy?: { + id: string; + name: string; + }; + project?: { + id: string; + name: string; + }; + team?: { + id: string; + name: string; + key: string; + }; + url: string; // URL to the document in Linear + slugId: string; // Unique slug for the document URL + isPublic?: boolean; // Whether document is publicly accessible +} + +export interface LinearDocumentsResponse { + documents: LinearDocument[]; + pageInfo: { + hasNextPage: boolean; + endCursor?: string; + }; + totalCount: number; +} + +export interface LinearDocumentResponse { + document: LinearDocument; +} + +export interface DeleteDocumentResponse { + success: boolean; + message?: string; +} \ No newline at end of file diff --git a/src/types/linear/index.ts b/src/types/linear/index.ts index d58f52d..b63b137 100644 --- a/src/types/linear/index.ts +++ b/src/types/linear/index.ts @@ -1,9 +1,9 @@ -// Re-export all types from their respective files export * from './base'; -export * from './issue'; export * from './comment'; -export * from './team'; -export * from './project'; export * from './cycle'; -export * from './search'; +export * from './document'; export * from './guards'; +export * from './issue'; +export * from './project'; +export * from './search'; +export * from './team'; \ No newline at end of file From e1cbee75321a70b6acf5fea1d343020c214f388a Mon Sep 17 00:00:00 2001 From: Darin Kishore Date: Fri, 9 May 2025 03:50:03 -0400 Subject: [PATCH 2/8] feat: add document type guards for validation --- src/types/linear/guards.ts | 65 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/src/types/linear/guards.ts b/src/types/linear/guards.ts index d0e9d89..890ac78 100644 --- a/src/types/linear/guards.ts +++ b/src/types/linear/guards.ts @@ -1,5 +1,6 @@ import { CreateCommentArgs } from './comment'; import { isCycleFilter } from './cycle'; +import { CreateDocumentArgs, DeleteDocumentArgs, GetDocumentArgs, GetDocumentsArgs, UpdateDocumentArgs } from './document'; import { CreateIssueArgs, DeleteIssueArgs, GetIssueArgs, UpdateIssueArgs } from './issue'; import { CreateProjectUpdateArgs, GetProjectsArgs, GetProjectUpdatesArgs, ProjectUpdateHealthType } from './project'; import { SearchIssuesArgs } from './search'; @@ -113,3 +114,67 @@ export const isCreateProjectUpdateArgs = (args: unknown): args is CreateProjectU Object.values(ProjectUpdateHealthType).includes((args as CreateProjectUpdateArgs).health as any)) && (typeof (args as CreateProjectUpdateArgs).isDiffHidden === 'undefined' || typeof (args as CreateProjectUpdateArgs).isDiffHidden === 'boolean'); + +// Document type guards +export const isGetDocumentsArgs = (args: unknown): args is GetDocumentsArgs => + typeof args === 'object' && + args !== null && + (typeof (args as GetDocumentsArgs).nameFilter === 'undefined' || + typeof (args as GetDocumentsArgs).nameFilter === 'string') && + (typeof (args as GetDocumentsArgs).includeArchived === 'undefined' || + typeof (args as GetDocumentsArgs).includeArchived === 'boolean') && + (typeof (args as GetDocumentsArgs).teamId === 'undefined' || + typeof (args as GetDocumentsArgs).teamId === 'string') && + (typeof (args as GetDocumentsArgs).projectId === 'undefined' || + typeof (args as GetDocumentsArgs).projectId === 'string') && + (typeof (args as GetDocumentsArgs).first === 'undefined' || + typeof (args as GetDocumentsArgs).first === 'number') && + (typeof (args as GetDocumentsArgs).after === 'undefined' || + typeof (args as GetDocumentsArgs).after === 'string'); + +export const isGetDocumentArgs = (args: unknown): args is GetDocumentArgs => + typeof args === 'object' && + args !== null && + typeof (args as GetDocumentArgs).documentId === 'string' && + (typeof (args as GetDocumentArgs).documentSlug === 'undefined' || + typeof (args as GetDocumentArgs).documentSlug === 'string') && + (typeof (args as GetDocumentArgs).includeFull === 'undefined' || + typeof (args as GetDocumentArgs).includeFull === 'boolean'); + +export const isCreateDocumentArgs = (args: unknown): args is CreateDocumentArgs => + typeof args === 'object' && + args !== null && + typeof (args as CreateDocumentArgs).teamId === 'string' && + typeof (args as CreateDocumentArgs).title === 'string' && + (typeof (args as CreateDocumentArgs).content === 'undefined' || + typeof (args as CreateDocumentArgs).content === 'string') && + (typeof (args as CreateDocumentArgs).icon === 'undefined' || + typeof (args as CreateDocumentArgs).icon === 'string') && + (typeof (args as CreateDocumentArgs).projectId === 'undefined' || + typeof (args as CreateDocumentArgs).projectId === 'string') && + (typeof (args as CreateDocumentArgs).isPublic === 'undefined' || + typeof (args as CreateDocumentArgs).isPublic === 'boolean'); + +export const isUpdateDocumentArgs = (args: unknown): args is UpdateDocumentArgs => + typeof args === 'object' && + args !== null && + typeof (args as UpdateDocumentArgs).documentId === 'string' && + (typeof (args as UpdateDocumentArgs).title === 'undefined' || + typeof (args as UpdateDocumentArgs).title === 'string') && + (typeof (args as UpdateDocumentArgs).content === 'undefined' || + typeof (args as UpdateDocumentArgs).content === 'string') && + (typeof (args as UpdateDocumentArgs).icon === 'undefined' || + typeof (args as UpdateDocumentArgs).icon === 'string') && + (typeof (args as UpdateDocumentArgs).projectId === 'undefined' || + typeof (args as UpdateDocumentArgs).projectId === 'string') && + (typeof (args as UpdateDocumentArgs).teamId === 'undefined' || + typeof (args as UpdateDocumentArgs).teamId === 'string') && + (typeof (args as UpdateDocumentArgs).isArchived === 'undefined' || + typeof (args as UpdateDocumentArgs).isArchived === 'boolean') && + (typeof (args as UpdateDocumentArgs).isPublic === 'undefined' || + typeof (args as UpdateDocumentArgs).isPublic === 'boolean'); + +export const isDeleteDocumentArgs = (args: unknown): args is DeleteDocumentArgs => + typeof args === 'object' && + args !== null && + typeof (args as DeleteDocumentArgs).documentId === 'string'; \ No newline at end of file From 03c56063fa10d32d70bd28af7870ab6492c18197 Mon Sep 17 00:00:00 2001 From: Darin Kishore Date: Fri, 9 May 2025 03:50:07 -0400 Subject: [PATCH 3/8] feat: extend LinearClientInterface with document methods --- src/services/linear/base-service.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/services/linear/base-service.ts b/src/services/linear/base-service.ts index d410ad2..0c0d907 100644 --- a/src/services/linear/base-service.ts +++ b/src/services/linear/base-service.ts @@ -1,7 +1,12 @@ import { LinearClient } from '@linear/sdk'; import { LinearUser } from '../../types/linear/base'; -export interface LinearClientInterface extends Pick {} +export interface LinearClientInterface extends Pick {} export abstract class LinearBaseService { protected client: LinearClientInterface; @@ -29,4 +34,4 @@ export abstract class LinearBaseService { email: viewer.email }; } -} +} \ No newline at end of file From 2e127eb2e712008d7937fafaee419ead15eaee6d Mon Sep 17 00:00:00 2001 From: Darin Kishore Date: Fri, 9 May 2025 03:50:11 -0400 Subject: [PATCH 4/8] feat: implement DocumentService for Linear document operations --- src/services/linear/document-service.ts | 382 ++++++++++++++++++++++++ 1 file changed, 382 insertions(+) create mode 100644 src/services/linear/document-service.ts diff --git a/src/services/linear/document-service.ts b/src/services/linear/document-service.ts new file mode 100644 index 0000000..64b649b --- /dev/null +++ b/src/services/linear/document-service.ts @@ -0,0 +1,382 @@ +import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; +import { + CreateDocumentArgs, + DeleteDocumentArgs, + DeleteDocumentResponse, + GetDocumentArgs, + GetDocumentsArgs, + LinearDocument, + LinearDocumentResponse, + LinearDocumentsResponse, + UpdateDocumentArgs +} from '../../types/linear/document'; +import { LinearBaseService } from './base-service'; +import { cleanDescription } from './utils'; + +export class DocumentService extends LinearBaseService { + /** + * Gets a list of documents with optional filtering + * @param args The document filtering arguments + * @returns List of documents with pagination info + */ + async getDocuments(args: GetDocumentsArgs): Promise { + try { + // Validate and normalize arguments + const first = Math.min(args.first || 50, 100); // Cap at 100 + const includeArchived = args.includeArchived !== false; // Default to true if not explicitly set to false + + // Build filter conditions + const filter: Record = {}; + + if (args.nameFilter) { + filter.title = { contains: args.nameFilter }; + } + + if (args.teamId) { + filter.team = { id: { eq: args.teamId } }; + } + + if (args.projectId) { + filter.project = { id: { eq: args.projectId } }; + } + + // Fetch documents with pagination + const documents = await (this.client as any).documents({ + first, + after: args.after, + includeArchived, + filter: Object.keys(filter).length > 0 ? filter : undefined + }); + + // Process and format the results + const formattedDocuments = await Promise.all( + documents.nodes.map(async (document: any) => { + // Fetch related data + const [creator, lastUpdatedBy, project, team] = await Promise.all([ + document.creator, + document.updatedBy, + document.project, + document.team + ]); + + return { + id: document.id, + title: document.title, + contentPreview: document.content ? cleanDescription(document.content.slice(0, 200)) : undefined, + icon: document.icon, + slugId: document.slugId, + createdAt: document.createdAt?.toISOString(), + updatedAt: document.updatedAt?.toISOString(), + archivedAt: document.archivedAt?.toISOString(), + creator: creator ? { + id: creator.id, + name: creator.name + } : undefined, + lastUpdatedBy: lastUpdatedBy ? { + id: lastUpdatedBy.id, + name: lastUpdatedBy.name + } : undefined, + project: project ? { + id: project.id, + name: project.name + } : undefined, + team: team ? { + id: team.id, + name: team.name, + key: team.key + } : undefined, + url: document.url, + isPublic: document.isPublic + }; + }) + ); + + // Extract pagination information + const pageInfo = { + hasNextPage: documents.pageInfo.hasNextPage, + endCursor: documents.pageInfo.endCursor || undefined + }; + + return { + documents: formattedDocuments, + pageInfo, + totalCount: formattedDocuments.length + }; + } catch (error) { + if (error instanceof McpError) { + throw error; + } + throw new McpError( + ErrorCode.InternalError, + `Failed to fetch documents: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + /** + * Gets a single document by ID + * @param args The document retrieval arguments + * @returns The requested document + */ + async getDocument(args: GetDocumentArgs): Promise { + try { + // Fetch document by ID or slug + let document; + if (args.documentId) { + document = await this.client.document(args.documentId); + } else if (args.documentSlug) { + // Use a GraphQL query to fetch by slug if needed + throw new McpError(ErrorCode.InvalidRequest, 'Fetching by slug is not yet implemented'); + } else { + throw new McpError(ErrorCode.InvalidRequest, 'Either documentId or documentSlug must be provided'); + } + + if (!document) { + throw new McpError(ErrorCode.InvalidRequest, `Document not found: ${args.documentId || args.documentSlug}`); + } + + // Determine whether to include full content + const includeFull = args.includeFull !== false; // Default to true if not explicitly set to false + + // Fetch related data + const [creator, lastUpdatedBy, project, team] = await Promise.all([ + document.creator, + document.updatedBy, + document.project, + document.team + ]); + + // Format response + const formattedDocument: LinearDocument = { + id: document.id, + title: document.title, + content: includeFull ? document.content : undefined, + contentPreview: !includeFull && document.content ? cleanDescription(document.content.slice(0, 200)) : undefined, + icon: document.icon, + slugId: document.slugId, + createdAt: document.createdAt?.toISOString(), + updatedAt: document.updatedAt?.toISOString(), + archivedAt: document.archivedAt?.toISOString(), + creator: creator ? { + id: creator.id, + name: creator.name + } : undefined, + lastUpdatedBy: lastUpdatedBy ? { + id: lastUpdatedBy.id, + name: lastUpdatedBy.name + } : undefined, + project: project ? { + id: project.id, + name: project.name + } : undefined, + team: team ? { + id: team.id, + name: team.name, + key: team.key + } : undefined, + url: document.url, + isPublic: document.isPublic + }; + + return { document: formattedDocument }; + } catch (error) { + if (error instanceof McpError) { + throw error; + } + throw new McpError( + ErrorCode.InternalError, + `Failed to fetch document: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + /** + * Creates a new document + * @param args The document creation parameters + * @returns The created document + */ + async createDocument(args: CreateDocumentArgs): Promise { + try { + // Prepare input for Linear SDK + const input: Record = { + teamId: args.teamId, + title: args.title + }; + + // Add optional fields if provided + if (args.content !== undefined) input.content = args.content; + if (args.icon !== undefined) input.icon = args.icon; + if (args.projectId !== undefined) input.projectId = args.projectId; + if (args.isPublic !== undefined) input.isPublic = args.isPublic; + + // Create document + const result = await (this.client as any).createDocument({ input }); + + if (!result.document) { + throw new McpError(ErrorCode.InternalError, 'Failed to create document'); + } + + const document = result.document; + + // Fetch related data + const [creator, lastUpdatedBy, project, team] = await Promise.all([ + document.creator, + document.updatedBy, + document.project, + document.team + ]); + + // Format response + const formattedDocument: LinearDocument = { + id: document.id, + title: document.title, + content: document.content, + icon: document.icon, + slugId: document.slugId, + createdAt: document.createdAt?.toISOString(), + updatedAt: document.updatedAt?.toISOString(), + creator: creator ? { + id: creator.id, + name: creator.name + } : undefined, + lastUpdatedBy: lastUpdatedBy ? { + id: lastUpdatedBy.id, + name: lastUpdatedBy.name + } : undefined, + project: project ? { + id: project.id, + name: project.name + } : undefined, + team: team ? { + id: team.id, + name: team.name, + key: team.key + } : undefined, + url: document.url, + isPublic: document.isPublic + }; + + return { document: formattedDocument }; + } catch (error) { + if (error instanceof McpError) { + throw error; + } + throw new McpError( + ErrorCode.InternalError, + `Failed to create document: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + /** + * Updates an existing document + * @param args The document update parameters + * @returns The updated document + */ + async updateDocument(args: UpdateDocumentArgs): Promise { + try { + // Fetch the document to verify it exists + const existingDocument = await this.client.document(args.documentId); + if (!existingDocument) { + throw new McpError(ErrorCode.InvalidRequest, `Document not found: ${args.documentId}`); + } + + // Prepare input for Linear SDK + const input: Record = { + id: args.documentId + }; + + // Add optional fields if provided + if (args.title !== undefined) input.title = args.title; + if (args.content !== undefined) input.content = args.content; + if (args.icon !== undefined) input.icon = args.icon; + if (args.projectId !== undefined) input.projectId = args.projectId; + if (args.teamId !== undefined) input.teamId = args.teamId; + if (args.isArchived !== undefined) input.isArchived = args.isArchived; + if (args.isPublic !== undefined) input.isPublic = args.isPublic; + + // Update document + const result = await (this.client as any).documentUpdate({ input }); + + if (!result.document) { + throw new McpError(ErrorCode.InternalError, 'Failed to update document'); + } + + const document = result.document; + + // Fetch related data + const [creator, lastUpdatedBy, project, team] = await Promise.all([ + document.creator, + document.updatedBy, + document.project, + document.team + ]); + + // Format response + const formattedDocument: LinearDocument = { + id: document.id, + title: document.title, + content: document.content, + icon: document.icon, + slugId: document.slugId, + createdAt: document.createdAt?.toISOString(), + updatedAt: document.updatedAt?.toISOString(), + archivedAt: document.archivedAt?.toISOString(), + creator: creator ? { + id: creator.id, + name: creator.name + } : undefined, + lastUpdatedBy: lastUpdatedBy ? { + id: lastUpdatedBy.id, + name: lastUpdatedBy.name + } : undefined, + project: project ? { + id: project.id, + name: project.name + } : undefined, + team: team ? { + id: team.id, + name: team.name, + key: team.key + } : undefined, + url: document.url, + isPublic: document.isPublic + }; + + return { document: formattedDocument }; + } catch (error) { + if (error instanceof McpError) { + throw error; + } + throw new McpError( + ErrorCode.InternalError, + `Failed to update document: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + /** + * Deletes a document + * @param args The document deletion parameters + * @returns Success status + */ + async deleteDocument(args: DeleteDocumentArgs): Promise { + try { + // Delete the document + const result = await (this.client as any).deleteDocument({ id: args.documentId }); + + return { + success: result.success || false, + message: result.success ? 'Document deleted successfully' : 'Failed to delete document' + }; + } catch (error) { + if (error instanceof McpError) { + throw error; + } + throw new McpError( + ErrorCode.InternalError, + `Failed to delete document: ${error instanceof Error ? error.message : String(error)}` + ); + } + } +} \ No newline at end of file From afe692b09fc64ec054353cbfffce15ad2175fa8a Mon Sep 17 00:00:00 2001 From: Darin Kishore Date: Fri, 9 May 2025 03:50:16 -0400 Subject: [PATCH 5/8] feat: integrate DocumentService with LinearAPIService --- src/services/linear/index.ts | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/services/linear/index.ts b/src/services/linear/index.ts index c634104..fa1c89b 100644 --- a/src/services/linear/index.ts +++ b/src/services/linear/index.ts @@ -1,6 +1,7 @@ import { LinearBaseService, LinearClientInterface } from './base-service'; import { CommentService } from './comment-service'; import { CycleService } from './cycle-service'; +import { DocumentService } from './document-service'; import { IssueService } from './issue-service'; import { ProjectService } from './project-service'; import { SearchService } from './search-service'; @@ -18,6 +19,7 @@ export class LinearAPIService { private commentService: CommentService; private projectService: ProjectService; private searchService: SearchService; + private documentService: DocumentService; constructor(clientOrApiKey: string | LinearClientInterface) { // Initialize the client @@ -38,6 +40,7 @@ export class LinearAPIService { this.commentService = new CommentService(clientOrApiKey); this.projectService = new ProjectService(clientOrApiKey); this.searchService = new SearchService(clientOrApiKey); + this.documentService = new DocumentService(clientOrApiKey); } // Team operations @@ -80,6 +83,27 @@ export class LinearAPIService { return this.projectService.createProjectUpdate(args); } + // Document operations + async getDocuments(args: import('../../types').GetDocumentsArgs) { + return this.documentService.getDocuments(args); + } + + async getDocument(args: import('../../types').GetDocumentArgs) { + return this.documentService.getDocument(args); + } + + async createDocument(args: import('../../types').CreateDocumentArgs) { + return this.documentService.createDocument(args); + } + + async updateDocument(args: import('../../types').UpdateDocumentArgs) { + return this.documentService.updateDocument(args); + } + + async deleteDocument(args: import('../../types').DeleteDocumentArgs) { + return this.documentService.deleteDocument(args); + } + // Search operations async searchIssues(args: import('../../types').SearchIssuesArgs) { return this.searchService.searchIssues(args); @@ -99,7 +123,7 @@ export class LinearAPIService { // Export all services and utilities export { - cleanDescription, CommentService, CycleService, extractMentions, getComments, + cleanDescription, CommentService, CycleService, DocumentService, extractMentions, getComments, getRelationships, IssueService, LinearBaseService, ProjectService, SearchService, TeamService -}; +}; \ No newline at end of file From 72ad11b521f9bd5b31bf2168afbb25099e4310d6 Mon Sep 17 00:00:00 2001 From: Darin Kishore Date: Fri, 9 May 2025 03:50:20 -0400 Subject: [PATCH 6/8] feat: add MCP tool schemas and handlers for document operations --- src/index.ts | 261 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 260 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 20e74bb..94d6f5a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,12 @@ import { McpError, } from '@modelcontextprotocol/sdk/types.js'; import { LinearAPIService } from './services/linear/index'; -import { isCreateCommentArgs, isCreateIssueArgs, isCreateProjectUpdateArgs, isDeleteIssueArgs, isGetIssueArgs, isGetProjectUpdatesArgs, isGetProjectsArgs, isGetTeamsArgs, isSearchIssuesArgs, isUpdateIssueArgs } from './types/linear/index'; +import { + isCreateCommentArgs, isCreateDocumentArgs, isCreateIssueArgs, isCreateProjectUpdateArgs, + isDeleteDocumentArgs, isDeleteIssueArgs, isGetDocumentArgs, isGetDocumentsArgs, isGetIssueArgs, + isGetProjectUpdatesArgs, isGetProjectsArgs, isGetTeamsArgs, isSearchIssuesArgs, + isUpdateDocumentArgs, isUpdateIssueArgs +} from './types/linear/index'; // Get Linear API key from environment variable const API_KEY = process.env.LINEAR_API_KEY; @@ -332,6 +337,155 @@ class LinearServer { required: ['projectId'] } }, + { + name: 'get_documents', + description: 'Get a list of Linear documents with optional name filtering and pagination\n\nExamples:\n1. Basic usage: {}\n2. Filter by name: {nameFilter: \"meeting notes\"}\n3. Include archived: {includeArchived: true, nameFilter: \"roadmap\"}\n4. Pagination: {first: 25, after: \"cursor-from-previous-response\"}\n5. Filter by project: {projectId: \"project-123\"}\n6. Filter by team: {teamId: \"team-abc\"}\n\nReturns a paginated list of documents with metadata including:\n- Document details (id, title, content preview, etc.)\n- Creator information\n- Last editor information \n- Project and team associations\n- Creation and update timestamps', + inputSchema: { + type: 'object', + properties: { + nameFilter: { + type: 'string', + description: 'Optional filter to search by document title' + }, + includeArchived: { + type: 'boolean', + description: 'Whether to include archived documents (default: true)', + default: true + }, + teamId: { + type: 'string', + description: 'Filter documents by team ID' + }, + projectId: { + type: 'string', + description: 'Filter documents by project ID' + }, + first: { + type: 'number', + description: 'Number of items to return (default: 50, max: 100)', + default: 50 + }, + after: { + type: 'string', + description: 'Cursor for pagination. Use the endCursor from a previous response to fetch the next page' + } + } + } + }, + { + name: 'get_document', + description: 'Get detailed information about a specific Linear document\n\nReturns comprehensive document information including:\n- Full document content in markdown format\n- Metadata (creation date, last edited date, etc.)\n- Creator and editor information\n- Team and project associations\n- Document URL\n\nYou can retrieve a document using either its ID or its URL slug.', + inputSchema: { + type: 'object', + properties: { + documentId: { + type: 'string', + description: 'The ID of the Linear document' + }, + documentSlug: { + type: 'string', + description: 'The URL slug of the document (alternative to documentId)' + }, + includeFull: { + type: 'boolean', + description: 'Whether to include the full document content (default: true)', + default: true + } + }, + required: ['documentId'] + } + }, + { + name: 'create_document', + description: 'Create a new document in Linear\n\nExamples:\n1. Basic document: {teamId: \"team-123\", title: \"Meeting Notes\", content: \"# Meeting Notes\\n\\nDiscussion points...\"}\n2. Project document: {teamId: \"team-123\", projectId: \"project-abc\", title: \"Design Spec\"}\n3. With icon: {teamId: \"team-123\", title: \"Design System\", icon: \"🎨\", content: \"...\"}\n\nReturns the newly created document with its ID, URL, and other metadata.', + inputSchema: { + type: 'object', + properties: { + teamId: { + type: 'string', + description: 'ID of the team this document belongs to' + }, + title: { + type: 'string', + description: 'Title of the document' + }, + content: { + type: 'string', + description: 'Content of the document in markdown format' + }, + icon: { + type: 'string', + description: 'Emoji icon for the document' + }, + projectId: { + type: 'string', + description: 'ID of the project to associate this document with' + }, + isPublic: { + type: 'boolean', + description: 'Whether the document should be accessible outside the organization (default: false)', + default: false + } + }, + required: ['teamId', 'title'] + } + }, + { + name: 'update_document', + description: 'Update an existing Linear document\n\nExamples:\n1. Update content: {documentId: \"doc-123\", content: \"# Updated Content\"}\n2. Update title: {documentId: \"doc-123\", title: \"New Title\"}\n3. Change icon: {documentId: \"doc-123\", icon: \"🚀\"}\n4. Change project: {documentId: \"doc-123\", projectId: \"project-456\"}\n5. Archive: {documentId: \"doc-123\", isArchived: true}\n\nYou only need to include the fields you want to update. Returns the updated document with its new values.', + inputSchema: { + type: 'object', + properties: { + documentId: { + type: 'string', + description: 'ID of the document to update' + }, + title: { + type: 'string', + description: 'New title for the document' + }, + content: { + type: 'string', + description: 'New content for the document in markdown format' + }, + icon: { + type: 'string', + description: 'New emoji icon for the document' + }, + projectId: { + type: 'string', + description: 'ID of the project to move this document to' + }, + teamId: { + type: 'string', + description: 'ID of the team to move this document to' + }, + isArchived: { + type: 'boolean', + description: 'Whether the document should be archived' + }, + isPublic: { + type: 'boolean', + description: 'Whether the document should be accessible outside the organization' + } + }, + required: ['documentId'] + } + }, + { + name: 'delete_document', + description: 'Delete an existing Linear document\n\nThis action permanently removes the document from Linear and cannot be undone. Returns a success message when the document is successfully deleted.', + inputSchema: { + type: 'object', + properties: { + documentId: { + type: 'string', + description: 'ID of the document to delete' + } + }, + required: ['documentId'] + } + }, ], })); @@ -358,6 +512,16 @@ class LinearServer { return await this.handleGetProjectUpdates(request.params.arguments); case 'create_project_update': return await this.handleCreateProjectUpdate(request.params.arguments); + case 'get_documents': + return await this.handleGetDocuments(request.params.arguments); + case 'get_document': + return await this.handleGetDocument(request.params.arguments); + case 'create_document': + return await this.handleCreateDocument(request.params.arguments); + case 'update_document': + return await this.handleUpdateDocument(request.params.arguments); + case 'delete_document': + return await this.handleDeleteDocument(request.params.arguments); default: throw new McpError( ErrorCode.MethodNotFound, @@ -557,6 +721,101 @@ class LinearServer { }; } + private async handleGetDocuments(args: unknown) { + if (!isGetDocumentsArgs(args)) { + throw new McpError( + ErrorCode.InvalidParams, + 'Invalid get_documents arguments' + ); + } + + const documents = await this.linearAPI.getDocuments(args); + return { + content: [ + { + type: 'text', + text: JSON.stringify(documents, null, 2) + } + ] + }; + } + + private async handleGetDocument(args: unknown) { + if (!isGetDocumentArgs(args)) { + throw new McpError( + ErrorCode.InvalidParams, + 'Invalid get_document arguments' + ); + } + + const document = await this.linearAPI.getDocument(args); + return { + content: [ + { + type: 'text', + text: JSON.stringify(document, null, 2) + } + ] + }; + } + + private async handleCreateDocument(args: unknown) { + if (!isCreateDocumentArgs(args)) { + throw new McpError( + ErrorCode.InvalidParams, + 'Invalid create_document arguments' + ); + } + + const document = await this.linearAPI.createDocument(args); + return { + content: [ + { + type: 'text', + text: JSON.stringify(document, null, 2) + } + ] + }; + } + + private async handleUpdateDocument(args: unknown) { + if (!isUpdateDocumentArgs(args)) { + throw new McpError( + ErrorCode.InvalidParams, + 'Invalid update_document arguments' + ); + } + + const document = await this.linearAPI.updateDocument(args); + return { + content: [ + { + type: 'text', + text: JSON.stringify(document, null, 2) + } + ] + }; + } + + private async handleDeleteDocument(args: unknown) { + if (!isDeleteDocumentArgs(args)) { + throw new McpError( + ErrorCode.InvalidParams, + 'Invalid delete_document arguments' + ); + } + + const result = await this.linearAPI.deleteDocument(args); + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2) + } + ] + }; + } + async run() { const transport = new StdioServerTransport(); await this.server.connect(transport); From 74dde31133bd172c3ecce1e2f76112f8acddaae7 Mon Sep 17 00:00:00 2001 From: Darin Kishore Date: Fri, 9 May 2025 03:50:25 -0400 Subject: [PATCH 7/8] test: add tests for document operations --- .../linear-api-document-management.test.ts | 378 ++++++++++++++++++ src/services/__tests__/test-utils.ts | 17 + 2 files changed, 395 insertions(+) create mode 100644 src/services/__tests__/linear-api-document-management.test.ts diff --git a/src/services/__tests__/linear-api-document-management.test.ts b/src/services/__tests__/linear-api-document-management.test.ts new file mode 100644 index 0000000..378bf36 --- /dev/null +++ b/src/services/__tests__/linear-api-document-management.test.ts @@ -0,0 +1,378 @@ +import { describe, test, expect, beforeEach, mock } from 'bun:test'; +import { LinearAPIService } from '../linear/index.js'; +import { createMockLinearClient } from './test-utils'; +import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; + +describe('LinearAPIService - Document Management', () => { + let service: LinearAPIService; + let mockClient: any; + + beforeEach(() => { + mockClient = createMockLinearClient(); + service = new LinearAPIService(mockClient); + }); + + // Helper to create a mock document object for testing + const createMockDocument = (overrides: Record = {}) => ({ + id: 'doc-1', + title: 'Test Document', + content: 'This is a test document with markdown content.', + icon: '📄', + slugId: 'test-document', + createdAt: new Date('2025-01-24'), + updatedAt: new Date('2025-01-24'), + archivedAt: undefined, + creator: Promise.resolve({ id: 'user-1', name: 'John Doe' }), + updatedBy: Promise.resolve({ id: 'user-1', name: 'John Doe' }), + project: Promise.resolve({ id: 'project-1', name: 'Project X' }), + team: Promise.resolve({ id: 'team-1', name: 'Engineering', key: 'ENG' }), + url: 'https://linear.app/team/doc/test-document', + isPublic: false, + ...overrides + }); + + // --- getDocuments Tests --- + describe('getDocuments', () => { + test('retrieves documents with default parameters', async () => { + const mockDocuments = [ + createMockDocument(), + createMockDocument({ + id: 'doc-2', + title: 'Another Document', + slugId: 'another-document' + }) + ]; + + mockClient.documents.mockImplementation(async () => ({ + nodes: mockDocuments, + pageInfo: { + hasNextPage: false, + endCursor: null + } + })); + + const result = await service.getDocuments({}); + + expect(mockClient.documents).toHaveBeenCalledWith({ + first: 50, + after: undefined, + includeArchived: true, + filter: undefined + }); + + expect(result.documents.length).toBe(2); + expect(result.documents[0].id).toBe('doc-1'); + expect(result.documents[0].title).toBe('Test Document'); + expect(result.documents[0].contentPreview).toBeDefined(); + expect(result.pageInfo.hasNextPage).toBe(false); + }); + + test('filters documents by name', async () => { + mockClient.documents.mockImplementation(async (args: any) => ({ + nodes: [createMockDocument()], + pageInfo: { + hasNextPage: false, + endCursor: null + } + })); + + await service.getDocuments({ + nameFilter: 'Test' + }); + + // Check that filter was passed correctly + expect(mockClient.documents).toHaveBeenCalledWith({ + first: 50, + after: undefined, + includeArchived: true, + filter: { + title: { contains: 'Test' } + } + }); + }); + + test('filters documents by team and project', async () => { + mockClient.documents.mockImplementation(async (args: any) => ({ + nodes: [createMockDocument()], + pageInfo: { + hasNextPage: false, + endCursor: null + } + })); + + await service.getDocuments({ + teamId: 'team-1', + projectId: 'project-1' + }); + + // Check that filter was passed correctly + expect(mockClient.documents).toHaveBeenCalledWith({ + first: 50, + after: undefined, + includeArchived: true, + filter: { + team: { id: { eq: 'team-1' } }, + project: { id: { eq: 'project-1' } } + } + }); + }); + + test('handles pagination', async () => { + mockClient.documents.mockImplementation(async (args: any) => ({ + nodes: [createMockDocument()], + pageInfo: { + hasNextPage: true, + endCursor: 'cursor-1' + } + })); + + const result = await service.getDocuments({ + first: 10, + after: 'initial-cursor' + }); + + expect(mockClient.documents).toHaveBeenCalledWith({ + first: 10, + after: 'initial-cursor', + includeArchived: true, + filter: undefined + }); + + expect(result.pageInfo.hasNextPage).toBe(true); + expect(result.pageInfo.endCursor).toBe('cursor-1'); + }); + + test('handles API errors gracefully', async () => { + mockClient.documents.mockImplementation(async () => { + throw new Error('API error'); + }); + + await expect(service.getDocuments({})).rejects.toThrow( + /Failed to fetch documents: API error/ + ); + }); + }); + + // --- getDocument Tests --- + describe('getDocument', () => { + test('retrieves document by ID with full content', async () => { + const mockDocument = createMockDocument(); + mockClient.document.mockImplementation(async () => mockDocument); + + const result = await service.getDocument({ + documentId: 'doc-1', + includeFull: true + }); + + expect(mockClient.document).toHaveBeenCalledWith('doc-1'); + expect(result.document.id).toBe('doc-1'); + expect(result.document.title).toBe('Test Document'); + expect(result.document.content).toBe('This is a test document with markdown content.'); + expect(result.document.contentPreview).toBeUndefined(); // Content preview not included when full content is requested + }); + + test('retrieves document by ID with content preview only', async () => { + const mockDocument = createMockDocument(); + mockClient.document.mockImplementation(async () => mockDocument); + + const result = await service.getDocument({ + documentId: 'doc-1', + includeFull: false + }); + + expect(result.document.content).toBeUndefined(); + expect(result.document.contentPreview).toBeDefined(); + }); + + test('throws error when document not found', async () => { + mockClient.document.mockImplementation(async () => null); + + await expect(service.getDocument({ + documentId: 'nonexistent-doc' + })).rejects.toThrow( + /Document not found: nonexistent-doc/ + ); + }); + }); + + // --- createDocument Tests --- + describe('createDocument', () => { + test('creates document with required fields', async () => { + const mockCreatedDocument = createMockDocument(); + + mockClient.createDocument.mockImplementation(async () => ({ + success: true, + document: mockCreatedDocument + })); + + const result = await service.createDocument({ + teamId: 'team-1', + title: 'Test Document' + }); + + expect(mockClient.createDocument).toHaveBeenCalledWith({ + input: { + teamId: 'team-1', + title: 'Test Document' + } + }); + + expect(result.document.id).toBe('doc-1'); + expect(result.document.title).toBe('Test Document'); + }); + + test('creates document with all optional fields', async () => { + const mockCreatedDocument = createMockDocument({ + isPublic: true, + icon: '📝' + }); + + mockClient.createDocument.mockImplementation(async () => ({ + success: true, + document: mockCreatedDocument + })); + + const result = await service.createDocument({ + teamId: 'team-1', + title: 'Test Document', + content: 'Document content', + icon: '📝', + projectId: 'project-1', + isPublic: true + }); + + expect(mockClient.createDocument).toHaveBeenCalledWith({ + input: { + teamId: 'team-1', + title: 'Test Document', + content: 'Document content', + icon: '📝', + projectId: 'project-1', + isPublic: true + } + }); + + expect(result.document.isPublic).toBe(true); + expect(result.document.icon).toBe('📝'); + }); + + test('throws error when create document fails', async () => { + mockClient.createDocument.mockImplementation(async () => ({ + success: false, + document: undefined + })); + + await expect(service.createDocument({ + teamId: 'team-1', + title: 'Test Document' + })).rejects.toThrow( + /Failed to create document/ + ); + }); + + test('handles API errors gracefully', async () => { + mockClient.createDocument.mockImplementation(async () => { + throw new Error('API error'); + }); + + await expect(service.createDocument({ + teamId: 'team-1', + title: 'Test Document' + })).rejects.toThrow( + /Failed to create document: API error/ + ); + }); + }); + + // --- updateDocument Tests --- + describe('updateDocument', () => { + test('updates document title and content', async () => { + // Mock the existing document fetch + mockClient.document.mockImplementation(async () => createMockDocument()); + + // Mock the document update response + mockClient.documentUpdate.mockImplementation(async () => ({ + success: true, + document: createMockDocument({ + title: 'Updated Title', + content: 'Updated content' + }) + })); + + const result = await service.updateDocument({ + documentId: 'doc-1', + title: 'Updated Title', + content: 'Updated content' + }); + + expect(mockClient.documentUpdate).toHaveBeenCalledWith({ + input: { + id: 'doc-1', + title: 'Updated Title', + content: 'Updated content' + } + }); + + expect(result.document.title).toBe('Updated Title'); + expect(result.document.content).toBe('Updated content'); + }); + + test('throws error when document not found for update', async () => { + mockClient.document.mockImplementation(async () => null); + + await expect(service.updateDocument({ + documentId: 'nonexistent-doc', + title: 'Updated Title' + })).rejects.toThrow( + /Document not found: nonexistent-doc/ + ); + + expect(mockClient.documentUpdate).not.toHaveBeenCalled(); + }); + }); + + // --- deleteDocument Tests --- + describe('deleteDocument', () => { + test('deletes document successfully', async () => { + mockClient.deleteDocument.mockImplementation(async () => ({ + success: true + })); + + const result = await service.deleteDocument({ + documentId: 'doc-1' + }); + + expect(mockClient.deleteDocument).toHaveBeenCalledWith({ + id: 'doc-1' + }); + + expect(result.success).toBe(true); + expect(result.message).toBe('Document deleted successfully'); + }); + + test('returns failure when API returns unsuccessful response', async () => { + mockClient.deleteDocument.mockImplementation(async () => ({ + success: false + })); + + const result = await service.deleteDocument({ + documentId: 'doc-1' + }); + + expect(result.success).toBe(false); + expect(result.message).toBe('Failed to delete document'); + }); + + test('handles API errors gracefully', async () => { + mockClient.deleteDocument.mockImplementation(async () => { + throw new Error('API error'); + }); + + await expect(service.deleteDocument({ + documentId: 'doc-1' + })).rejects.toThrow( + /Failed to delete document: API error/ + ); + }); + }); +}); \ No newline at end of file diff --git a/src/services/__tests__/test-utils.ts b/src/services/__tests__/test-utils.ts index e75e54b..9cfddf6 100644 --- a/src/services/__tests__/test-utils.ts +++ b/src/services/__tests__/test-utils.ts @@ -176,6 +176,23 @@ export function createMockLinearClient(): LinearClientInterface { deleteIssue: mock(() => Promise.resolve()) as any, project: projectFn as any, projects: projectsFn as any, + // Document method mocks + document: mock(() => Promise.resolve(null)) as any, + documents: mock(() => Promise.resolve({ + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null } + })) as any, + createDocument: mock(() => Promise.resolve({ + success: true, + document: null + })) as any, + documentUpdate: mock(() => Promise.resolve({ + success: true, + document: null + })) as any, + deleteDocument: mock(() => Promise.resolve({ + success: true + })) as any, _request: requestFn as any }; From 4697fa964cf1bb0129a508c064669a547868251c Mon Sep 17 00:00:00 2001 From: Darin Kishore Date: Fri, 9 May 2025 05:14:05 -0400 Subject: [PATCH 8/8] fix: correct document creation and update API calls --- src/services/linear/document-service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/linear/document-service.ts b/src/services/linear/document-service.ts index 64b649b..ceb5342 100644 --- a/src/services/linear/document-service.ts +++ b/src/services/linear/document-service.ts @@ -210,7 +210,7 @@ export class DocumentService extends LinearBaseService { if (args.isPublic !== undefined) input.isPublic = args.isPublic; // Create document - const result = await (this.client as any).createDocument({ input }); + const result = await (this.client as any).createDocument(input); if (!result.document) { throw new McpError(ErrorCode.InternalError, 'Failed to create document'); @@ -296,7 +296,7 @@ export class DocumentService extends LinearBaseService { if (args.isPublic !== undefined) input.isPublic = args.isPublic; // Update document - const result = await (this.client as any).documentUpdate({ input }); + const result = await (this.client as any).documentUpdate(input); if (!result.document) { throw new McpError(ErrorCode.InternalError, 'Failed to update document');