diff --git a/README.md b/README.md index 716b448..a75aaa6 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,13 @@ The server currently supports the following tools (tested with PAT authenticatio * `linear_create_issues`: Create multiple issues in bulk. * `linear_search_issues`: Search issues (filter by title currently). * `linear_delete_issue`: Delete a single issue. + * `linear_add_attachment_to_issue`: Upload a file from the server's local filesystem and link it in the issue description. + * **Arguments:** + * `issueId` (string, required): ID of the issue to attach to. + * `filePath` (string, required): Local path *on the server* where the file resides. + * `contentType` (string, required): MIME type of the file (e.g., `image/png`, `application/pdf`). + * `fileName` (string, optional): Desired filename for the attachment. Defaults to the name from `filePath` if omitted. + * `title` (string, optional): Title for the attachment link in markdown. Defaults to `fileName` if omitted. * **Projects:** * `linear_create_project_with_issues`: Create a project and associated issues. * `linear_get_project`: Get project details by ID. diff --git a/package.json b/package.json index e497e2b..544f7c1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@touchlab/linear-mcp-integration", - "version": "0.1.1", + "version": "0.1.2", "description": "MCP server providing tools to interact with the Linear API (Issues, Projects, Teams).", "main": "build/index.js", "bin": "build/index.js", @@ -42,6 +42,7 @@ "@types/express": "^5.0.0", "@types/jest": "^29.5.14", "@types/node": "^22.10.10", + "@types/node-fetch": "^2.6.12", "dotenv": "^16.4.7", "express": "^4.21.2", "jest": "^29.7.0", diff --git a/src/core/handlers/handler.factory.ts b/src/core/handlers/handler.factory.ts index 75f678e..bd84d43 100644 --- a/src/core/handlers/handler.factory.ts +++ b/src/core/handlers/handler.factory.ts @@ -5,6 +5,7 @@ import { IssueHandler } from '../../features/issues/handlers/issue.handler.js'; import { ProjectHandler } from '../../features/projects/handlers/project.handler.js'; import { TeamHandler } from '../../features/teams/handlers/team.handler.js'; import { UserHandler } from '../../features/users/handlers/user.handler.js'; +import { AttachmentHandler } from '../../features/attachments/handlers/attachment.handler.js'; /** * Factory for creating and managing feature-specific handlers. @@ -16,6 +17,7 @@ export class HandlerFactory { private projectHandler: ProjectHandler; private teamHandler: TeamHandler; private userHandler: UserHandler; + private attachmentHandler: AttachmentHandler; constructor(auth: LinearAuth, graphqlClient?: LinearGraphQLClient) { // Initialize all handlers with shared dependencies @@ -24,13 +26,14 @@ export class HandlerFactory { this.projectHandler = new ProjectHandler(auth, graphqlClient); this.teamHandler = new TeamHandler(auth, graphqlClient); this.userHandler = new UserHandler(auth, graphqlClient); + this.attachmentHandler = new AttachmentHandler(auth, graphqlClient); } /** * Gets the appropriate handler for a given tool name. */ getHandlerForTool(toolName: string): { - handler: AuthHandler | IssueHandler | ProjectHandler | TeamHandler | UserHandler; + handler: AuthHandler | IssueHandler | ProjectHandler | TeamHandler | UserHandler | AttachmentHandler; method: string; } { // Map tool names to their handlers and methods @@ -55,6 +58,9 @@ export class HandlerFactory { // User tools linear_get_user: { handler: this.userHandler, method: 'handleGetUser' }, + + // Attachment tools + linear_add_attachment_to_issue: { handler: this.attachmentHandler, method: 'handleAddAttachment' }, }; const handlerInfo = handlerMap[toolName]; diff --git a/src/features/attachments/handlers/attachment.handler.ts b/src/features/attachments/handlers/attachment.handler.ts new file mode 100644 index 0000000..b2efd72 --- /dev/null +++ b/src/features/attachments/handlers/attachment.handler.ts @@ -0,0 +1,172 @@ +import { Buffer } from 'buffer'; +import * as fs from 'fs'; // Import Node.js File System module +import * as path from 'path'; // Import Node.js Path module +import { LinearAuth } from '../../../auth.js'; +import { LinearGraphQLClient } from '../../../graphql/client.js'; +import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; +import { gql } from 'graphql-tag'; +import fetch from 'node-fetch'; +import { BaseHandler } from '../../../core/handlers/base.handler.js'; // Import BaseHandler +import { BaseToolResponse } from '../../../core/interfaces/tool-handler.interface.js'; // Import BaseToolResponse + +// Define the expected header structure from Linear's fileUpload mutation +interface HeaderPayload { + key: string; + value: string; +} + +// Define the expected nested structure within the fileUpload response based on docs +interface UploadFileDetails { + uploadUrl: string; // The URL for the PUT request + assetUrl: string; // The URL to use for linking the attachment + headers: HeaderPayload[]; // Headers for the PUT request +} + +// Define the expected payload structure from Linear's fileUpload mutation +interface FileUploadPayload { + success: boolean; + uploadFile?: UploadFileDetails | null; // Changed structure based on docs + // assetUrl is now nested inside uploadFile + contentType?: string; // These might not be returned at this level + filename?: string; + size?: number; + // headers are now nested inside uploadFile +} + + +// Define the expected response structure for the file upload mutation +interface FileUploadResponse { + fileUpload: FileUploadPayload; +} + +// Define input arguments for the handler method +interface AddAttachmentArgs { + issueId: string; + filePath: string; // Changed from fileContentBase64 + contentType: string; + fileName?: string; // Now optional + title?: string; // Optional title +} + +// Define the GraphQL mutation for initiating the file upload based on docs +const FILE_UPLOAD_MUTATION = gql` + mutation FileUpload($contentType: String!, $filename: String!, $size: Int!) { + fileUpload(contentType: $contentType, filename: $filename, size: $size) { + success # Added success field + uploadFile { # Nested structure based on docs + uploadUrl + assetUrl + headers { key value } + } + # assetUrl # Removed from top level + # contentType # Likely not needed here + # filename # Likely not needed here + # size # Likely not needed here + # headers { key value } # Moved into uploadFile + } + } +`; + +/** + * Handles operations related to Linear attachments. + */ +// Make AttachmentHandler extend BaseHandler +export class AttachmentHandler extends BaseHandler { + + // Call super() in constructor + constructor(auth: LinearAuth, graphqlClient?: LinearGraphQLClient) { + super(auth, graphqlClient); + } + + /** + * Handles adding an attachment to a Linear issue by reading a local file, + * uploading it, and appending a markdown link to the issue description. + */ + // Ensure return type matches BaseHandler methods if needed (using BaseToolResponse) + async handleAddAttachment(args: AddAttachmentArgs): Promise { + const client = this.verifyAuth(); + const { issueId, filePath, contentType, fileName: providedFileName, title: providedTitle } = args; + + this.validateRequiredParams(args, ['issueId', 'filePath', 'contentType']); + + // 1. Read file content + let fileBuffer: Buffer; + let resolvedFileName: string; + let fileSize: number; + let finalFileName: string; + try { + resolvedFileName = path.basename(filePath); + fileBuffer = await fs.promises.readFile(filePath); + fileSize = fileBuffer.length; + finalFileName = providedFileName || resolvedFileName; + } catch (error: any) { + if (error.code === 'ENOENT') { throw new McpError(ErrorCode.InvalidParams, `File not found: ${filePath}`); } + if (error.code === 'EACCES') { throw new McpError(ErrorCode.InternalError, `Permission denied reading file: ${filePath}`); } + this.handleError(error, `read file ${filePath}`); + } + + // 2. Get Upload URL from Linear + let uploadDetails: UploadFileDetails; + let assetLinkUrl: string; + try { + const uploadResponse = await client.execute(FILE_UPLOAD_MUTATION, { + contentType: contentType, + filename: finalFileName, + size: fileSize, + }); + if (!uploadResponse?.fileUpload?.success || !uploadResponse?.fileUpload?.uploadFile?.uploadUrl || !uploadResponse?.fileUpload?.uploadFile?.assetUrl) { + throw new Error('Failed to get upload URL from Linear.'); + } + uploadDetails = uploadResponse.fileUpload.uploadFile; + assetLinkUrl = uploadDetails.assetUrl; + } catch (error) { + this.handleError(error, 'request upload URL'); + } + + // 3. Upload File to the received URL + try { + const uploadHeaders: Record = { + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=31536000' + }; + uploadDetails.headers?.forEach((header: HeaderPayload) => { + if (header?.key && header?.value) { uploadHeaders[header.key] = header.value; } + }); + const uploadResponse = await fetch(uploadDetails.uploadUrl, { method: 'PUT', headers: uploadHeaders, body: fileBuffer }); + if (!uploadResponse.ok) { + const errorBody = await uploadResponse.text(); + throw new Error(`Upload failed: ${uploadResponse.status}`); + } + } catch (error) { + this.handleError(error, 'upload file'); + } + + // 4. Fetch existing issue description + let currentDescription = ''; + try { + const issueResponse = await client.getIssue(issueId); + currentDescription = (issueResponse?.issue as any)?.description || ''; + } catch (error) { + this.handleError(error, `fetch description for issue ${issueId}`); + } + + // 5. Append Markdown link and update issue + try { + const attachmentTitle = providedTitle || finalFileName; + const markdownLink = `\n\n![${attachmentTitle}](${assetLinkUrl})\n`; + const newDescription = currentDescription + markdownLink; + + const updateResponse = await client.updateIssue(issueId, { description: newDescription }); + + if (!(updateResponse as any)?.issueUpdate?.success && !(updateResponse as any)?.success) { + throw new Error('Issue description update failed after successful file upload.'); + } + + const successMessage = `Attachment uploaded and linked successfully to issue ${issueId}. Asset URL: ${assetLinkUrl}`; + return this.createResponse(successMessage); + + } catch (error) { + this.handleError(error, `update description for issue ${issueId}`); + } + } +} \ No newline at end of file diff --git a/src/graphql/client.ts b/src/graphql/client.ts index 4c7dfda..9fabf44 100644 --- a/src/graphql/client.ts +++ b/src/graphql/client.ts @@ -1,5 +1,6 @@ import { LinearClient } from '@linear/sdk'; import { DocumentNode } from 'graphql'; +import gql from 'graphql-tag'; import { CreateIssueInput, CreateIssueResponse, @@ -8,7 +9,8 @@ import { SearchIssuesInput, SearchIssuesResponse, DeleteIssueResponse, - IssueBatchResponse + IssueBatchResponse, + Issue } from '../features/issues/types/issue.types.js'; import { ProjectInput, @@ -35,6 +37,21 @@ import { } from './mutations.js'; import { SEARCH_ISSUES_QUERY, GET_TEAMS_QUERY, GET_USER_QUERY, GET_PROJECT_QUERY, SEARCH_PROJECTS_QUERY } from './queries.js'; +// Define the wrapper response type for GetIssue query +interface SingleIssueResponse { + issue: Issue; // Uses the imported Issue type +} + +// Define the query to get a single issue's description +const GET_ISSUE_QUERY = gql` + query GetIssue($id: String!) { + issue(id: $id) { + id + description + } + } +`; + export class LinearGraphQLClient { private linearClient: LinearClient; @@ -122,7 +139,7 @@ export class LinearGraphQLClient { // Update a single issue async updateIssue(id: string, input: UpdateIssueInput): Promise { return this.execute(UPDATE_ISSUES_MUTATION, { - ids: [id], + id: id, input, }); } @@ -171,4 +188,9 @@ export class LinearGraphQLClient { async deleteIssue(id: string): Promise { return this.execute(DELETE_ISSUE_MUTATION, { id: id }); } + + // Method to get a single issue by ID + async getIssue(id: string): Promise { + return this.execute(GET_ISSUE_QUERY, { id }); + } } diff --git a/src/graphql/mutations.ts b/src/graphql/mutations.ts index f2e5b63..86fe0cf 100644 --- a/src/graphql/mutations.ts +++ b/src/graphql/mutations.ts @@ -52,14 +52,15 @@ export const CREATE_BATCH_ISSUES = gql` `; export const UPDATE_ISSUES_MUTATION = gql` - mutation UpdateIssues($ids: [String!]!, $input: IssueUpdateInput!) { - issueUpdate(ids: $ids, input: $input) { + mutation UpdateSingleIssue($id: String!, $input: IssueUpdateInput!) { + issueUpdate(id: $id, input: $input) { success - issues { + issue { id identifier title url + description state { name } diff --git a/src/index.ts b/src/index.ts index 7ab8b69..4954c82 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,22 +3,36 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; // Removed unused schemas import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; +// import * as fs from 'fs'; // Import fs for file logging +// import * as path from 'path'; // Import path for log file path import { LinearAuth } from './auth.js'; import { LinearGraphQLClient } from './graphql/client.js'; import { HandlerFactory } from './core/handlers/handler.factory.js'; // Removed unused toolSchemas import +// Remove log file path definition +// const logFilePath = path.join(process.cwd(), 'mcp-server.log'); + +// Remove logToFile helper function +/* +function logToFile(message: string) { + const timestamp = new Date().toISOString(); + fs.appendFileSync(logFilePath, `${timestamp} - ${message}\n`); +} +*/ + async function runLinearServer() { + // Use console.error for startup message console.error('Starting Linear MCP server using McpServer...'); // --- Initialize Auth and GraphQL Client --- const auth = new LinearAuth(); let graphqlClient: LinearGraphQLClient | undefined; - // Log and Initialize with PAT if available + // Log and Initialize with PAT if available (using console.error) const accessToken = process.env.LINEAR_ACCESS_TOKEN; - console.error(`[DEBUG] LINEAR_ACCESS_TOKEN: ${accessToken ? '***' : 'undefined'}`); // Avoid logging token + console.error(`[DEBUG] LINEAR_ACCESS_TOKEN: ${accessToken ? '***' : 'undefined'}`); if (accessToken) { try { auth.initialize({ @@ -26,13 +40,12 @@ async function runLinearServer() { accessToken }); graphqlClient = new LinearGraphQLClient(auth.getClient()); - console.error('Linear Auth initialized with PAT.'); + console.error('Linear Auth initialized with PAT.'); // Use console.error } catch (error) { - console.error('[ERROR] Failed to initialize PAT auth:', error); - // Allow server to start, but tools requiring auth will fail + console.error('[ERROR] Failed to initialize PAT auth:', error); // Use console.error } } else { - console.error('LINEAR_ACCESS_TOKEN not set. Tools requiring auth will fail until OAuth flow is completed (if implemented).'); + console.error('LINEAR_ACCESS_TOKEN not set. Tools requiring auth will fail...'); // Use console.error } // --- Initialize Handler Factory --- @@ -243,9 +256,34 @@ async function runLinearServer() { } ); + // linear_add_attachment_to_issue + server.tool( + 'linear_add_attachment_to_issue', + { + issueId: z.string().describe('The ID of the Linear issue to attach the file to.'), + fileName: z.string().describe('The desired filename for the attachment...').optional(), + contentType: z.string().describe('The MIME type of the file...'), + filePath: z.string().describe('The local path to the file...'), + title: z.string().describe('Optional title for the attachment...').optional(), + }, + async (args) => { + try { + const { handler, method } = getHandler('linear_add_attachment_to_issue'); + const result = await (handler as any)[method](args); + return result; + } catch (error) { + // Re-throw McpError or wrap other errors + if (error instanceof McpError) { + throw error; + } + throw new McpError(ErrorCode.InternalError, `Tool execution failed: ${error instanceof Error ? error.message : String(error)}`); + } + } + ); + // --- Handle Process Exit Gracefully --- process.on('SIGINT', async () => { - console.error('SIGINT received, closing server...'); + console.error('SIGINT received, closing server...'); // Use console.error await server.close(); process.exit(0); }); @@ -254,15 +292,15 @@ async function runLinearServer() { try { const transport = new StdioServerTransport(); await server.connect(transport); - console.error('Linear MCP server running on stdio using McpServer'); + console.error('Linear MCP server running on stdio using McpServer'); // Use console.error } catch (error) { - console.error('[FATAL] Failed to connect or run server:', error); + console.error('[FATAL] Failed to connect or run server:', error); // Use console.error process.exit(1); } } // --- Run the Server --- runLinearServer().catch(error => { - console.error('[FATAL] Uncaught error during server execution:', error); + console.error('[FATAL] Uncaught error during server execution:', error); // Use console.error process.exit(1); });