Skip to content

Kpg/attach files to issues #3

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down
8 changes: 7 additions & 1 deletion src/core/handlers/handler.factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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];
Expand Down
172 changes: 172 additions & 0 deletions src/features/attachments/handlers/attachment.handler.ts
Original file line number Diff line number Diff line change
@@ -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<BaseToolResponse> {
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<FileUploadResponse>(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<string, string> = {
'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}`);
}
}
}
26 changes: 24 additions & 2 deletions src/graphql/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { LinearClient } from '@linear/sdk';
import { DocumentNode } from 'graphql';
import gql from 'graphql-tag';
import {
CreateIssueInput,
CreateIssueResponse,
Expand All @@ -8,7 +9,8 @@ import {
SearchIssuesInput,
SearchIssuesResponse,
DeleteIssueResponse,
IssueBatchResponse
IssueBatchResponse,
Issue
} from '../features/issues/types/issue.types.js';
import {
ProjectInput,
Expand All @@ -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;

Expand Down Expand Up @@ -122,7 +139,7 @@ export class LinearGraphQLClient {
// Update a single issue
async updateIssue(id: string, input: UpdateIssueInput): Promise<UpdateIssuesResponse> {
return this.execute<UpdateIssuesResponse>(UPDATE_ISSUES_MUTATION, {
ids: [id],
id: id,
input,
});
}
Expand Down Expand Up @@ -171,4 +188,9 @@ export class LinearGraphQLClient {
async deleteIssue(id: string): Promise<DeleteIssueResponse> {
return this.execute<DeleteIssueResponse>(DELETE_ISSUE_MUTATION, { id: id });
}

// Method to get a single issue by ID
async getIssue(id: string): Promise<SingleIssueResponse> {
return this.execute<SingleIssueResponse>(GET_ISSUE_QUERY, { id });
}
}
7 changes: 4 additions & 3 deletions src/graphql/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading