From c3f75d481be66360a8d2d47ee247b20922433c2b Mon Sep 17 00:00:00 2001 From: Kevin Galligan Date: Tue, 29 Apr 2025 15:40:16 -0400 Subject: [PATCH 1/3] Save --- package.json | 1 + src/core/handlers/handler.factory.ts | 8 +- .../handlers/attachment.handler.ts | 207 ++++++++++++++++++ src/graphql/client.ts | 26 ++- src/graphql/mutations.ts | 7 +- src/index.ts | 65 +++++- 6 files changed, 298 insertions(+), 16 deletions(-) create mode 100644 src/features/attachments/handlers/attachment.handler.ts diff --git a/package.json b/package.json index e497e2b..5c4278d 100644 --- a/package.json +++ b/package.json @@ -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..2aeea82 --- /dev/null +++ b/src/features/attachments/handlers/attachment.handler.ts @@ -0,0 +1,207 @@ +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 { + // Use this.verifyAuth() instead of checking client directly + // const client = this.graphqlClient!; + const client = this.verifyAuth(); + const { issueId, filePath, contentType, fileName: providedFileName, title: providedTitle } = args; + + // Removed redundant client check + // if (!client) { + // throw new McpError(ErrorCode.InternalError, 'Linear client is not available in AttachmentHandler.'); + // } + + // 1. Read file content (using validateRequiredParams could be added here) + this.validateRequiredParams(args, ['issueId', 'filePath', 'contentType']); + 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; + console.error(`[AttachmentHandler] Read ${fileSize} bytes from ${finalFileName}`); + } catch (error: any) { + console.error(`[AttachmentHandler] Error reading file ${filePath}:`, error); + 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}`); } + // Use BaseHandler error handling + this.handleError(error, `read file ${filePath}`); + // throw new McpError(ErrorCode.InternalError, `Failed to read file ${filePath}: ${error.message}`); + } + + // 2. Get Upload URL from Linear + let uploadDetails: UploadFileDetails; + let assetLinkUrl: string; + try { + console.error(`[AttachmentHandler] Requesting upload URL for ${finalFileName} (${fileSize} bytes, ${contentType})...`); + 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) { + console.error("[AttachmentHandler] Invalid response from fileUpload mutation:", uploadResponse); + throw new Error('Failed to get upload URL from Linear.'); + } + uploadDetails = uploadResponse.fileUpload.uploadFile; + assetLinkUrl = uploadDetails.assetUrl; + console.error(`[AttachmentHandler] Received Upload URL: ${uploadDetails.uploadUrl}, Asset URL: ${assetLinkUrl}`); + } catch (error) { + // Use BaseHandler error handling + this.handleError(error, 'request upload URL'); + // console.error("[AttachmentHandler] Error requesting upload URL:", error); + // throw new McpError(ErrorCode.InternalError, `Failed to get upload URL: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + + // 3. Upload File to the received URL + try { + console.error(`[AttachmentHandler] Uploading file to ${uploadDetails.uploadUrl}...`); + 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(); + console.error(`[AttachmentHandler] Upload failed: ${uploadResponse.status}`, errorBody); + throw new Error(`Upload failed: ${uploadResponse.status}`); + } + console.error(`[AttachmentHandler] File uploaded successfully.`); + } catch (error) { + // Use BaseHandler error handling + this.handleError(error, 'upload file'); + // console.error("[AttachmentHandler] Error uploading file:", error); + // throw new McpError(ErrorCode.InternalError, `Failed to upload file: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + + // 4. Fetch existing issue description + let currentDescription = ''; + try { + console.error(`[AttachmentHandler] Fetching current description for issue ${issueId}...`); + const issueResponse = await client.getIssue(issueId); + currentDescription = (issueResponse?.issue as any)?.description || ''; + console.error(`[AttachmentHandler] Fetched current description.`); + } catch (error) { + // Use BaseHandler error handling for fetch failure + this.handleError(error, `fetch description for issue ${issueId}`); + // console.error("[AttachmentHandler] Error fetching issue description:", error); + // throw new McpError(ErrorCode.InternalError, `Failed to fetch issue description: ${error.message}`); + } + + // 5. Append Markdown link and update issue + try { + const attachmentTitle = providedTitle || finalFileName; + const markdownLink = `\n\n![${attachmentTitle}](${assetLinkUrl})\n`; + const newDescription = currentDescription + markdownLink; + + console.error(`[AttachmentHandler] Updating issue ${issueId} description...`); + const updateResponse = await client.updateIssue(issueId, { description: newDescription }); + + if (!(updateResponse as any)?.issueUpdate?.success && !(updateResponse as any)?.success) { + console.error("[AttachmentHandler] Failed to update issue description:", updateResponse); + throw new Error('Issue description update failed after successful file upload.'); + } + + console.error(`[AttachmentHandler] Issue description updated successfully for ${issueId}.`); + + // Use createResponse from BaseHandler + const successMessage = `Attachment uploaded and linked successfully to issue ${issueId}. Asset URL: ${assetLinkUrl}`; + return this.createResponse(successMessage); + + } catch (error) { + // Use BaseHandler error handling for update failure + this.handleError(error, `update description for issue ${issueId}`); + // console.error("[AttachmentHandler] Error updating issue description:", error); + // throw new McpError(ErrorCode.InternalError, `Failed to update issue description: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } +} \ 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..310c833 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,41 @@ 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) => { + // Log arguments to console + console.error(`[DEBUG] linear_add_attachment_to_issue received args: ${JSON.stringify(args, null, 2)}`); + try { + const { handler, method } = getHandler('linear_add_attachment_to_issue'); + const result = await (handler as any)[method](args); + console.error(`[INFO] linear_add_attachment_to_issue completed successfully.`); // Use console.error + return result; + } catch (error) { + // Log error to console + const errorMessage = error instanceof Error ? error.message : String(error); + const errorStack = error instanceof Error ? error.stack : 'N/A'; + console.error(`[ERROR] Error within tool handler: ${errorMessage}\nStack: ${errorStack}`); // Use console.error + // Re-throw McpError or wrap other errors + if (error instanceof McpError) { + throw error; + } + throw new McpError(ErrorCode.InternalError, `Tool execution failed: ${errorMessage}`); + } + } + ); + // --- 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 +299,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); }); From baace9b5615b8ed523f90fc94f3cfb22be337daa Mon Sep 17 00:00:00 2001 From: Kevin Galligan Date: Tue, 29 Apr 2025 15:43:06 -0400 Subject: [PATCH 2/3] Save --- .../handlers/attachment.handler.ts | 43 ++----------------- src/index.ts | 9 +--- 2 files changed, 5 insertions(+), 47 deletions(-) diff --git a/src/features/attachments/handlers/attachment.handler.ts b/src/features/attachments/handlers/attachment.handler.ts index 2aeea82..b2efd72 100644 --- a/src/features/attachments/handlers/attachment.handler.ts +++ b/src/features/attachments/handlers/attachment.handler.ts @@ -84,18 +84,12 @@ export class AttachmentHandler extends BaseHandler { */ // Ensure return type matches BaseHandler methods if needed (using BaseToolResponse) async handleAddAttachment(args: AddAttachmentArgs): Promise { - // Use this.verifyAuth() instead of checking client directly - // const client = this.graphqlClient!; - const client = this.verifyAuth(); + const client = this.verifyAuth(); const { issueId, filePath, contentType, fileName: providedFileName, title: providedTitle } = args; - // Removed redundant client check - // if (!client) { - // throw new McpError(ErrorCode.InternalError, 'Linear client is not available in AttachmentHandler.'); - // } - - // 1. Read file content (using validateRequiredParams could be added here) this.validateRequiredParams(args, ['issueId', 'filePath', 'contentType']); + + // 1. Read file content let fileBuffer: Buffer; let resolvedFileName: string; let fileSize: number; @@ -105,43 +99,32 @@ export class AttachmentHandler extends BaseHandler { fileBuffer = await fs.promises.readFile(filePath); fileSize = fileBuffer.length; finalFileName = providedFileName || resolvedFileName; - console.error(`[AttachmentHandler] Read ${fileSize} bytes from ${finalFileName}`); } catch (error: any) { - console.error(`[AttachmentHandler] Error reading file ${filePath}:`, error); 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}`); } - // Use BaseHandler error handling this.handleError(error, `read file ${filePath}`); - // throw new McpError(ErrorCode.InternalError, `Failed to read file ${filePath}: ${error.message}`); } - // 2. Get Upload URL from Linear + // 2. Get Upload URL from Linear let uploadDetails: UploadFileDetails; let assetLinkUrl: string; try { - console.error(`[AttachmentHandler] Requesting upload URL for ${finalFileName} (${fileSize} bytes, ${contentType})...`); 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) { - console.error("[AttachmentHandler] Invalid response from fileUpload mutation:", uploadResponse); throw new Error('Failed to get upload URL from Linear.'); } uploadDetails = uploadResponse.fileUpload.uploadFile; assetLinkUrl = uploadDetails.assetUrl; - console.error(`[AttachmentHandler] Received Upload URL: ${uploadDetails.uploadUrl}, Asset URL: ${assetLinkUrl}`); } catch (error) { - // Use BaseHandler error handling this.handleError(error, 'request upload URL'); - // console.error("[AttachmentHandler] Error requesting upload URL:", error); - // throw new McpError(ErrorCode.InternalError, `Failed to get upload URL: ${error instanceof Error ? error.message : 'Unknown error'}`); } // 3. Upload File to the received URL try { - console.error(`[AttachmentHandler] Uploading file to ${uploadDetails.uploadUrl}...`); const uploadHeaders: Record = { 'Content-Type': contentType, 'Cache-Control': 'public, max-age=31536000' @@ -152,29 +135,19 @@ export class AttachmentHandler extends BaseHandler { const uploadResponse = await fetch(uploadDetails.uploadUrl, { method: 'PUT', headers: uploadHeaders, body: fileBuffer }); if (!uploadResponse.ok) { const errorBody = await uploadResponse.text(); - console.error(`[AttachmentHandler] Upload failed: ${uploadResponse.status}`, errorBody); throw new Error(`Upload failed: ${uploadResponse.status}`); } - console.error(`[AttachmentHandler] File uploaded successfully.`); } catch (error) { - // Use BaseHandler error handling this.handleError(error, 'upload file'); - // console.error("[AttachmentHandler] Error uploading file:", error); - // throw new McpError(ErrorCode.InternalError, `Failed to upload file: ${error instanceof Error ? error.message : 'Unknown error'}`); } // 4. Fetch existing issue description let currentDescription = ''; try { - console.error(`[AttachmentHandler] Fetching current description for issue ${issueId}...`); const issueResponse = await client.getIssue(issueId); currentDescription = (issueResponse?.issue as any)?.description || ''; - console.error(`[AttachmentHandler] Fetched current description.`); } catch (error) { - // Use BaseHandler error handling for fetch failure this.handleError(error, `fetch description for issue ${issueId}`); - // console.error("[AttachmentHandler] Error fetching issue description:", error); - // throw new McpError(ErrorCode.InternalError, `Failed to fetch issue description: ${error.message}`); } // 5. Append Markdown link and update issue @@ -183,25 +156,17 @@ export class AttachmentHandler extends BaseHandler { const markdownLink = `\n\n![${attachmentTitle}](${assetLinkUrl})\n`; const newDescription = currentDescription + markdownLink; - console.error(`[AttachmentHandler] Updating issue ${issueId} description...`); const updateResponse = await client.updateIssue(issueId, { description: newDescription }); if (!(updateResponse as any)?.issueUpdate?.success && !(updateResponse as any)?.success) { - console.error("[AttachmentHandler] Failed to update issue description:", updateResponse); throw new Error('Issue description update failed after successful file upload.'); } - - console.error(`[AttachmentHandler] Issue description updated successfully for ${issueId}.`); - // Use createResponse from BaseHandler const successMessage = `Attachment uploaded and linked successfully to issue ${issueId}. Asset URL: ${assetLinkUrl}`; return this.createResponse(successMessage); } catch (error) { - // Use BaseHandler error handling for update failure this.handleError(error, `update description for issue ${issueId}`); - // console.error("[AttachmentHandler] Error updating issue description:", error); - // throw new McpError(ErrorCode.InternalError, `Failed to update issue description: ${error instanceof Error ? error.message : 'Unknown error'}`); } } } \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 310c833..4954c82 100644 --- a/src/index.ts +++ b/src/index.ts @@ -267,23 +267,16 @@ async function runLinearServer() { title: z.string().describe('Optional title for the attachment...').optional(), }, async (args) => { - // Log arguments to console - console.error(`[DEBUG] linear_add_attachment_to_issue received args: ${JSON.stringify(args, null, 2)}`); try { const { handler, method } = getHandler('linear_add_attachment_to_issue'); const result = await (handler as any)[method](args); - console.error(`[INFO] linear_add_attachment_to_issue completed successfully.`); // Use console.error return result; } catch (error) { - // Log error to console - const errorMessage = error instanceof Error ? error.message : String(error); - const errorStack = error instanceof Error ? error.stack : 'N/A'; - console.error(`[ERROR] Error within tool handler: ${errorMessage}\nStack: ${errorStack}`); // Use console.error // Re-throw McpError or wrap other errors if (error instanceof McpError) { throw error; } - throw new McpError(ErrorCode.InternalError, `Tool execution failed: ${errorMessage}`); + throw new McpError(ErrorCode.InternalError, `Tool execution failed: ${error instanceof Error ? error.message : String(error)}`); } } ); From 1cdbc08176391222c9afdb35d7daa68aa15399a3 Mon Sep 17 00:00:00 2001 From: Kevin Galligan Date: Tue, 29 Apr 2025 15:45:02 -0400 Subject: [PATCH 3/3] Updated README --- README.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) 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 5c4278d..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",