@ecubelabs/atlassian-mcp 1.14.1 → 1.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { JiraService } from './libs/jira-client.js';
3
- import { formatToolResponse } from './libs/response-formatter.js';
3
+ import { formatToolResponse, saveBinaryToTempFile } from './libs/response-formatter.js';
4
4
  export const registerJiraTools = (server) => {
5
5
  // Initialize Jira service
6
6
  const jiraService = new JiraService();
@@ -23,9 +23,15 @@ export const registerJiraTools = (server) => {
23
23
  }, async ({ issueKey, expand }) => {
24
24
  try {
25
25
  const issue = await jiraService.getIssue(issueKey, expand);
26
+ const attachments = (issue.fields?.attachment ?? []);
27
+ const attachmentSummary = attachments.length > 0
28
+ ? ` | ${attachments.length} attachment(s): ${attachments
29
+ .map((a) => `${a.filename} (id: ${a.id})`)
30
+ .join(', ')}. Use the download-attachment tool to fetch them.`
31
+ : '';
26
32
  return formatToolResponse(issue, {
27
33
  toolName: 'get-issue',
28
- summary: `Issue ${issue.key}: ${issue.fields?.summary ?? ''} (Status: ${issue.fields?.status?.name ?? 'unknown'})`,
34
+ summary: `Issue ${issue.key}: ${issue.fields?.summary ?? ''} (Status: ${issue.fields?.status?.name ?? 'unknown'})${attachmentSummary}`,
29
35
  });
30
36
  }
31
37
  catch (error) {
@@ -39,6 +45,65 @@ export const registerJiraTools = (server) => {
39
45
  };
40
46
  }
41
47
  });
48
+ server.tool('download-attachment', 'Download a file attached to a Jira issue and save it to a local temp file, returning the file path. ' +
49
+ 'The file can then be opened with the Read tool. Identify the attachment either by its attachmentId ' +
50
+ '(found in an issue\'s "attachment" field via get-issue) or by issueKey + filename.', {
51
+ attachmentId: z
52
+ .string()
53
+ .optional()
54
+ .describe('Attachment ID (from the issue\'s "attachment" field). Takes precedence over filename.'),
55
+ issueKey: z
56
+ .string()
57
+ .optional()
58
+ .describe('Jira issue key (e.g. PROJ-123). Required when resolving the attachment by filename.'),
59
+ filename: z
60
+ .string()
61
+ .optional()
62
+ .describe('Attachment filename to look up within the issue. Used together with issueKey.'),
63
+ }, async ({ attachmentId, issueKey, filename }) => {
64
+ try {
65
+ let resolvedId = attachmentId;
66
+ let resolvedFilename;
67
+ // Resolve attachment id by issueKey + filename when id is not provided
68
+ if (!resolvedId) {
69
+ if (!issueKey || !filename) {
70
+ throw new Error('Provide either attachmentId, or both issueKey and filename');
71
+ }
72
+ const issue = await jiraService.getIssue(issueKey);
73
+ const attachments = (issue.fields?.attachment ?? []);
74
+ const match = attachments.find((a) => a.filename === filename);
75
+ if (!match) {
76
+ const available = attachments.map((a) => a.filename).join(', ') || '(none)';
77
+ throw new Error(`No attachment named "${filename}" found on ${issueKey}. Available: ${available}`);
78
+ }
79
+ resolvedId = match.id;
80
+ resolvedFilename = match.filename;
81
+ }
82
+ // Fetch metadata to get the original filename / mimeType / size
83
+ const metadata = await jiraService.getAttachmentMetadata(resolvedId);
84
+ resolvedFilename = resolvedFilename ?? metadata.filename ?? `attachment-${resolvedId}`;
85
+ const data = await jiraService.downloadAttachment(resolvedId);
86
+ const filePath = saveBinaryToTempFile(data, resolvedFilename);
87
+ const message = [
88
+ `Downloaded attachment "${resolvedFilename}" (id: ${resolvedId}, ${metadata.mimeType ?? 'unknown type'}, ${data.length.toLocaleString()} bytes).`,
89
+ ``,
90
+ `File: ${filePath}`,
91
+ ``,
92
+ `Use the Read tool to open this file.`,
93
+ ].join('\n');
94
+ return { content: [{ type: 'text', text: message }] };
95
+ }
96
+ catch (error) {
97
+ return {
98
+ content: [
99
+ {
100
+ type: 'text',
101
+ text: `Failed to download attachment: ${error instanceof Error ? error.message : String(error)}`,
102
+ },
103
+ ],
104
+ };
105
+ }
106
+ });
42
107
  server.tool('search-issues', 'Search Jira issues using JQL', {
43
108
  jql: z.string().describe('JQL query string. Must be a bounded query with search restrictions.'),
44
109
  fields: z
@@ -14,6 +14,29 @@ export class JiraService extends BaseApiService {
14
14
  params: expand ? { expand: expand.join(',') } : undefined,
15
15
  }));
16
16
  }
17
+ /**
18
+ * 첨부파일 메타데이터 조회 (파일명, mimeType, 크기, 다운로드 URL 등)
19
+ * @see https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-attachments/#api-rest-api-3-attachment-id-get
20
+ */
21
+ async getAttachmentMetadata(attachmentId) {
22
+ return this.makeRequest(() => this.client.get(`/attachment/${attachmentId}`));
23
+ }
24
+ /**
25
+ * 첨부파일 바이너리 콘텐츠 다운로드
26
+ *
27
+ * `redirect=false` 로 요청하여 미디어 서비스로의 cross-host 리다이렉트 없이
28
+ * 바이너리를 직접(200 OK) 받는다. 이렇게 하면 Basic 인증 헤더가 외부 호스트로
29
+ * 전달되며 발생할 수 있는 인증 문제를 피할 수 있다.
30
+ * @see https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-attachments/#api-rest-api-3-attachment-content-id-get
31
+ */
32
+ async downloadAttachment(attachmentId) {
33
+ const data = await this.makeRequest(() => this.client.get(`/attachment/content/${attachmentId}`, {
34
+ params: { redirect: false },
35
+ responseType: 'arraybuffer',
36
+ headers: { Accept: '*/*' },
37
+ }));
38
+ return Buffer.from(data);
39
+ }
17
40
  /**
18
41
  * JQL 검색 (Enhanced Search)
19
42
  * @see https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-search/#api-rest-api-3-search-jql-post
@@ -57,10 +57,23 @@ export function formatToolResponse(data, options) {
57
57
  .join('\n');
58
58
  return { content: [{ type: 'text', text: message }] };
59
59
  }
60
+ /**
61
+ * 바이너리 데이터(첨부파일 등)를 temp 디렉터리에 저장하고 경로를 반환한다.
62
+ * MCP 응답은 텍스트 채널이므로 바이너리는 파일로 저장한 뒤 경로를 돌려주어
63
+ * 에이전트가 Read 도구로 읽도록 한다.
64
+ */
65
+ export function saveBinaryToTempFile(data, originalFilename) {
66
+ const tempDir = getTempDir();
67
+ const safeName = originalFilename.replace(/[^a-zA-Z0-9._-]/g, '_') || 'attachment';
68
+ const fileName = `atlassian-mcp-attachment-${Date.now()}-${randomUUID().slice(0, 8)}-${safeName}`;
69
+ const filePath = join(tempDir, fileName);
70
+ writeFileSync(filePath, data);
71
+ return filePath;
72
+ }
60
73
  export function cleanupOldTempFiles() {
61
74
  try {
62
75
  const dir = getTempDir();
63
- const files = readdirSync(dir).filter((f) => f.startsWith('atlassian-mcp-') && f.endsWith('.json'));
76
+ const files = readdirSync(dir).filter((f) => f.startsWith('atlassian-mcp-') && (f.endsWith('.json') || f.startsWith('atlassian-mcp-attachment-')));
64
77
  const now = Date.now();
65
78
  for (const file of files) {
66
79
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecubelabs/atlassian-mcp",
3
- "version": "1.14.1",
3
+ "version": "1.15.2",
4
4
  "bin": "./dist/index.js",
5
5
  "repository": {
6
6
  "url": "https://github.com/Ecube-Labs/skynet.git"
@@ -28,7 +28,7 @@
28
28
  "marklassian": "^1.1.0",
29
29
  "p-limit": "^5.0.0",
30
30
  "winston": "^3.17.0",
31
- "zod": "^3.24.2"
31
+ "zod": "^3.25.0"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@types/node": "^20.14.8",