@atlassian-dc-mcp/jira 0.27.0 → 0.28.0

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.
@@ -0,0 +1,111 @@
1
+ import type { AttachmentGatewaySide } from '@atlassian-dc-mcp/common';
2
+ import { JiraService } from '../jira-service.js';
3
+ import { AttachmentService, IssueService } from '../jira-client/index.js';
4
+
5
+ const DISABLED_DOWNLOAD: AttachmentGatewaySide = { enabled: false, roots: [], maxBytes: 25 * 1024 * 1024 };
6
+
7
+ jest.mock('../jira-client/index.js', () => ({
8
+ AttachmentService: {
9
+ getAttachment: jest.fn(),
10
+ },
11
+ IssueService: {
12
+ getIssue: jest.fn(),
13
+ },
14
+ MyselfService: {},
15
+ SearchService: {},
16
+ OpenAPI: { BASE: '', TOKEN: '', VERSION: '' },
17
+ }));
18
+
19
+ function mockFetchText(text: string) {
20
+ global.fetch = jest.fn().mockResolvedValue({
21
+ ok: true,
22
+ status: 200,
23
+ statusText: 'OK',
24
+ headers: { get: () => 'text/plain' },
25
+ arrayBuffer: async () => Buffer.from(text),
26
+ }) as unknown as typeof fetch;
27
+ }
28
+
29
+ describe('JiraService.downloadAttachments', () => {
30
+ let service: JiraService;
31
+
32
+ beforeEach(() => {
33
+ service = new JiraService('test-host', 'test-token', undefined, () => 25);
34
+ jest.clearAllMocks();
35
+ });
36
+
37
+ afterEach(() => jest.restoreAllMocks());
38
+
39
+ it('downloads a single attachment by id using its content URL', async () => {
40
+ (AttachmentService.getAttachment as jest.Mock).mockResolvedValue({
41
+ id: '10001',
42
+ filename: 'report.pdf',
43
+ mimeType: 'application/pdf',
44
+ content: 'https://test-host/secure/attachment/10001/report.pdf',
45
+ });
46
+ mockFetchText('pdf-bytes');
47
+
48
+ const result = await service.downloadAttachments({ attachmentId: '10001', returnContent: 'text', downloadSide: DISABLED_DOWNLOAD });
49
+
50
+ expect(AttachmentService.getAttachment).toHaveBeenCalledWith('10001');
51
+ expect(global.fetch).toHaveBeenCalledWith(
52
+ 'https://test-host/secure/attachment/10001/report.pdf',
53
+ expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer test-token' }) }),
54
+ );
55
+ expect(result.success).toBe(true);
56
+ expect(result.data).toMatchObject({ count: 1 });
57
+ expect(result.data!.attachments[0]).toMatchObject({ filename: 'report.pdf', content: 'pdf-bytes' });
58
+ });
59
+
60
+ it('downloads all attachments on an issue', async () => {
61
+ (IssueService.getIssue as jest.Mock).mockResolvedValue({
62
+ fields: {
63
+ attachment: [
64
+ { id: '1', filename: 'a.txt', content: 'https://test-host/a' },
65
+ { id: '2', filename: 'b.txt', content: 'https://test-host/b' },
66
+ ],
67
+ },
68
+ });
69
+ mockFetchText('x');
70
+
71
+ const result = await service.downloadAttachments({ issueKey: 'PROJ-1', downloadSide: DISABLED_DOWNLOAD });
72
+
73
+ expect(IssueService.getIssue).toHaveBeenCalledWith('PROJ-1', undefined, ['attachment']);
74
+ expect(result.success).toBe(true);
75
+ expect(result.data!.count).toBe(2);
76
+ });
77
+
78
+ it('filters issue attachments by filename', async () => {
79
+ (IssueService.getIssue as jest.Mock).mockResolvedValue({
80
+ fields: {
81
+ attachment: [
82
+ { id: '1', filename: 'a.txt', content: 'https://test-host/a' },
83
+ { id: '2', filename: 'b.txt', content: 'https://test-host/b' },
84
+ ],
85
+ },
86
+ });
87
+ mockFetchText('x');
88
+
89
+ const result = await service.downloadAttachments({ issueKey: 'PROJ-1', filename: 'b.txt', downloadSide: DISABLED_DOWNLOAD });
90
+
91
+ expect(result.success).toBe(true);
92
+ expect(result.data!.count).toBe(1);
93
+ expect(result.data!.attachments[0].filename).toBe('b.txt');
94
+ expect(global.fetch).toHaveBeenCalledTimes(1);
95
+ });
96
+
97
+ it('fails when neither attachmentId nor issueKey is provided', async () => {
98
+ const result = await service.downloadAttachments({ downloadSide: DISABLED_DOWNLOAD });
99
+ expect(result.success).toBe(false);
100
+ expect(result.error).toContain('attachmentId or issueKey');
101
+ });
102
+
103
+ it('fails when an issue has no matching attachment', async () => {
104
+ (IssueService.getIssue as jest.Mock).mockResolvedValue({ fields: { attachment: [] } });
105
+
106
+ const result = await service.downloadAttachments({ issueKey: 'PROJ-1', filename: 'x.txt', downloadSide: DISABLED_DOWNLOAD });
107
+
108
+ expect(result.success).toBe(false);
109
+ expect(result.error).toContain('No attachment named "x.txt"');
110
+ });
111
+ });
@@ -0,0 +1,70 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import type { AttachmentGatewaySide } from '@atlassian-dc-mcp/common';
5
+ import { JiraService } from '../jira-service.js';
6
+ import { IssueService } from '../jira-client/index.js';
7
+
8
+ jest.mock('../jira-client/index.js', () => ({
9
+ AttachmentService: {},
10
+ IssueService: {
11
+ addAttachment: jest.fn(),
12
+ },
13
+ MyselfService: {},
14
+ SearchService: {},
15
+ OpenAPI: { BASE: '', TOKEN: '', VERSION: '' },
16
+ }));
17
+
18
+ describe('JiraService.uploadAttachment', () => {
19
+ let service: JiraService;
20
+ let root: string;
21
+ const uploadSide = (): AttachmentGatewaySide => ({ enabled: true, roots: [fs.realpathSync(root)], maxBytes: 1024 });
22
+
23
+ beforeEach(() => {
24
+ service = new JiraService('test-host', 'test-token', undefined, () => 25);
25
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-mcp-jup-'));
26
+ jest.clearAllMocks();
27
+ (IssueService.addAttachment as jest.Mock).mockResolvedValue([{ id: '1', filename: 'a.txt' }]);
28
+ });
29
+
30
+ afterEach(() => {
31
+ fs.rmSync(root, { recursive: true, force: true });
32
+ jest.restoreAllMocks();
33
+ });
34
+
35
+ it('uploads a file resolved within the upload root', async () => {
36
+ fs.writeFileSync(path.join(root, 'a.txt'), 'hello');
37
+
38
+ const result = await service.uploadAttachment('PROJ-1', 'a.txt', uploadSide());
39
+
40
+ expect(result.success).toBe(true);
41
+ expect(IssueService.addAttachment).toHaveBeenCalledTimes(1);
42
+ const [issueKey, formData] = (IssueService.addAttachment as jest.Mock).mock.calls[0];
43
+ expect(issueKey).toBe('PROJ-1');
44
+ expect(formData.file.name).toBe('a.txt');
45
+ });
46
+
47
+ it('rejects an absolute path outside the root', async () => {
48
+ const result = await service.uploadAttachment('PROJ-1', '/etc/passwd', uploadSide());
49
+ expect(result.success).toBe(false);
50
+ expect(IssueService.addAttachment).not.toHaveBeenCalled();
51
+ });
52
+
53
+ it('rejects a symlink inside the root', async () => {
54
+ const target = path.join(root, 'real.txt');
55
+ fs.writeFileSync(target, 'secret');
56
+ fs.symlinkSync(target, path.join(root, 'link.txt'));
57
+
58
+ const result = await service.uploadAttachment('PROJ-1', 'link.txt', uploadSide());
59
+ expect(result.success).toBe(false);
60
+ expect(IssueService.addAttachment).not.toHaveBeenCalled();
61
+ });
62
+
63
+ it('rejects a file over the size limit', async () => {
64
+ fs.writeFileSync(path.join(root, 'big.txt'), 'x'.repeat(2048));
65
+
66
+ const result = await service.uploadAttachment('PROJ-1', 'big.txt', uploadSide());
67
+ expect(result.success).toBe(false);
68
+ expect(IssueService.addAttachment).not.toHaveBeenCalled();
69
+ });
70
+ });
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { connectServer, createMcpServer, formatToolResponse, initializeRuntimeConfig } from '@atlassian-dc-mcp/common';
1
+ import { connectServer, createMcpServer, formatToolResponse, initializeRuntimeConfig, resolveAttachmentGateway } from '@atlassian-dc-mcp/common';
2
2
  import { JiraService, jiraToolSchemas } from './jira-service.js';
3
- import { getDefaultPageSize, getJiraRuntimeConfig } from './config.js';
3
+ import { getDefaultPageSize, getJiraRuntimeConfig, JIRA_PRODUCT } from './config.js';
4
4
  import { createRequire } from 'node:module';
5
5
 
6
6
  const require = createRequire(import.meta.url);
@@ -147,4 +147,49 @@ server.tool(
147
147
  }
148
148
  );
149
149
 
150
+ const attachmentGateway = resolveAttachmentGateway(JIRA_PRODUCT);
151
+
152
+ // Filesystem-reading upload is only registered when the operator explicitly enables it.
153
+ if (attachmentGateway.upload.enabled) {
154
+ server.tool(
155
+ "jira_uploadAttachment",
156
+ `Upload a local file as an attachment to a JIRA issue in the ${jiraInstanceType}. The file must live under the server-configured upload directory; the path is given relative to that directory.`,
157
+ jiraToolSchemas.uploadAttachment,
158
+ async ({ issueKey, sourcePath, filename }) => {
159
+ const result = await jiraService.uploadAttachment(issueKey, sourcePath, attachmentGateway.upload, filename);
160
+ return formatToolResponse(result);
161
+ }
162
+ );
163
+ }
164
+
165
+ // Download always returns content inline (no filesystem access). Saving to disk is
166
+ // only offered when the operator enables it, and is confined to the configured directory.
167
+ const downloadSaveEnabled = attachmentGateway.download.enabled;
168
+ server.tool(
169
+ "jira_downloadAttachment",
170
+ `Download attachment(s) from a JIRA issue in the ${jiraInstanceType}, by issue key (optionally filtered by filename) or by a single attachment id. Returns the file content inline (base64 or text). Useful for inspecting a file or moving it elsewhere (e.g. re-uploading to a Confluence page).${downloadSaveEnabled ? ' Can also save into the server-configured download directory; existing files are never overwritten.' : ' Saving to local disk is disabled on this server.'}`,
171
+ { ...jiraToolSchemas.downloadAttachment, ...(downloadSaveEnabled ? jiraToolSchemas.downloadAttachmentSaveFields : {}) },
172
+ async ({ issueKey, attachmentId, filename, returnContent, maxInlineBytes, save, saveName }: {
173
+ issueKey?: string;
174
+ attachmentId?: string;
175
+ filename?: string;
176
+ returnContent?: 'none' | 'base64' | 'text';
177
+ maxInlineBytes?: number;
178
+ save?: boolean;
179
+ saveName?: string;
180
+ }) => {
181
+ const result = await jiraService.downloadAttachments({
182
+ issueKey,
183
+ attachmentId,
184
+ filename,
185
+ save,
186
+ saveName,
187
+ returnContent,
188
+ maxInlineBytes,
189
+ downloadSide: attachmentGateway.download,
190
+ });
191
+ return formatToolResponse(result);
192
+ }
193
+ );
194
+
150
195
  await connectServer(server);
@@ -1,6 +1,17 @@
1
+ import { File } from 'node:buffer';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { basename } from 'node:path';
1
4
  import { z } from 'zod';
2
- import { handleApiOperation, resolveOpenApiBase } from '@atlassian-dc-mcp/common';
3
- import { IssueLinkService, IssueLinkTypeService, IssueService, MyselfService, OpenAPI, SearchService } from './jira-client/index.js';
5
+ import {
6
+ downloadAttachment,
7
+ handleApiOperation,
8
+ resolveDownloadDestination,
9
+ resolveOpenApiBase,
10
+ resolveUploadSource,
11
+ type AttachmentContentEncoding,
12
+ type AttachmentGatewaySide,
13
+ } from '@atlassian-dc-mcp/common';
14
+ import { AttachmentService, IssueLinkService, IssueLinkTypeService, IssueService, MyselfService, OpenAPI, SearchService } from './jira-client/index.js';
4
15
  import { request as __request } from './jira-client/core/request.js';
5
16
  import type { StringList } from './jira-client/models/StringList.js';
6
17
  import { getDefaultPageSize, getMissingConfig, JIRA_PRODUCT } from './config.js';
@@ -28,6 +39,7 @@ function resolveToken(token: string | (() => string | undefined), missingTokenMe
28
39
 
29
40
  export class JiraService {
30
41
  private readonly getPageSize: () => number;
42
+ private readonly tokenProvider: string | (() => string | undefined);
31
43
 
32
44
  constructor(
33
45
  host: string | undefined,
@@ -43,6 +55,8 @@ export class JiraService {
43
55
  });
44
56
  OpenAPI.TOKEN = resolveToken(token, 'Missing required environment variable: JIRA_API_TOKEN');
45
57
  OpenAPI.VERSION = '2';
58
+ OpenAPI.HEADERS = { 'X-Atlassian-Token': 'no-check' };
59
+ this.tokenProvider = token;
46
60
  this.getPageSize = getPageSize;
47
61
  }
48
62
 
@@ -177,6 +191,104 @@ export class JiraService {
177
191
  }, 'Error transitioning issue');
178
192
  }
179
193
 
194
+ async uploadAttachment(issueKey: string, sourcePath: string, uploadSide: AttachmentGatewaySide, filename?: string) {
195
+ return handleApiOperation(async () => {
196
+ const { absolutePath } = await resolveUploadSource({ requestedPath: sourcePath, side: uploadSide });
197
+ const buffer = await readFile(absolutePath);
198
+ const name = filename || basename(absolutePath);
199
+ const file = new File([buffer], name);
200
+ // IssueService types formData as Blob, but the API expects { file } — getFormData handles File via isBlob()
201
+ return IssueService.addAttachment(issueKey, { file } as any);
202
+ }, 'Error uploading attachment');
203
+ }
204
+
205
+ /**
206
+ * Download attachments from a JIRA issue, or a single attachment by its id.
207
+ * Exactly one of `attachmentId` or `issueKey` must be provided.
208
+ * @param params.attachmentId Download a single attachment by its numeric id
209
+ * @param params.issueKey Download attachments from this issue (optionally filtered by filename)
210
+ * @param params.filename When using issueKey, download only attachments with this exact filename
211
+ * @param params.save Whether to write the attachment(s) to the operator-configured download directory
212
+ * @param params.saveName Optional file name (basename only) when saving a single attachment
213
+ * @param params.returnContent Whether/how to embed the bytes inline in the response
214
+ * @param params.maxInlineBytes Inline embedding cap
215
+ * @param params.downloadSide Resolved download gateway (roots, size limit, enabled flag)
216
+ */
217
+ async downloadAttachments(params: {
218
+ attachmentId?: string;
219
+ issueKey?: string;
220
+ filename?: string;
221
+ save?: boolean;
222
+ saveName?: string;
223
+ returnContent?: AttachmentContentEncoding;
224
+ maxInlineBytes?: number;
225
+ downloadSide: AttachmentGatewaySide;
226
+ }) {
227
+ return handleApiOperation(async () => {
228
+ if (params.save && !params.downloadSide.enabled) {
229
+ throw new Error(
230
+ 'Saving attachments to disk is disabled on this server. Enable it with ' +
231
+ 'JIRA_ATTACHMENTS_DOWNLOAD_ENABLED and configure a download directory.',
232
+ );
233
+ }
234
+ const beans = await this.resolveAttachmentBeans(params);
235
+ const multiple = beans.length > 1;
236
+ const attachments = [];
237
+ for (const bean of beans) {
238
+ const url = bean?.content;
239
+ if (!url) {
240
+ throw new Error(`Attachment "${bean?.filename ?? bean?.id}" has no download URL`);
241
+ }
242
+ const filename = bean?.filename ?? String(bean?.id ?? 'attachment');
243
+ let destination: string | undefined;
244
+ if (params.save) {
245
+ const requestedName = multiple ? filename : params.saveName ?? filename;
246
+ destination = await resolveDownloadDestination({ requestedName, side: params.downloadSide });
247
+ }
248
+ attachments.push(
249
+ await downloadAttachment({
250
+ url,
251
+ token: this.tokenProvider,
252
+ filename,
253
+ mediaType: bean?.mimeType,
254
+ options: {
255
+ destination,
256
+ returnContent: params.returnContent,
257
+ maxInlineBytes: params.maxInlineBytes,
258
+ maxDownloadBytes: params.downloadSide.maxBytes,
259
+ },
260
+ }),
261
+ );
262
+ }
263
+ return { count: attachments.length, attachments };
264
+ }, 'Error downloading attachment');
265
+ }
266
+
267
+ private async resolveAttachmentBeans(params: {
268
+ attachmentId?: string;
269
+ issueKey?: string;
270
+ filename?: string;
271
+ }): Promise<Array<Record<string, any>>> {
272
+ if (params.attachmentId) {
273
+ const bean = await AttachmentService.getAttachment(params.attachmentId);
274
+ return [bean as Record<string, any>];
275
+ }
276
+ if (!params.issueKey) {
277
+ throw new Error('Either attachmentId or issueKey must be provided');
278
+ }
279
+ const issue = await IssueService.getIssue(params.issueKey, undefined, toIssueFieldSelection(['attachment']));
280
+ const all = (((issue as any)?.fields?.attachment) ?? []) as Array<Record<string, any>>;
281
+ const filtered = params.filename ? all.filter((a) => a?.filename === params.filename) : all;
282
+ if (filtered.length === 0) {
283
+ throw new Error(
284
+ params.filename
285
+ ? `No attachment named "${params.filename}" found on issue ${params.issueKey}`
286
+ : `No attachments found on issue ${params.issueKey}`,
287
+ );
288
+ }
289
+ return filtered;
290
+ }
291
+
180
292
  async getIssueLinkTypes() {
181
293
  return handleApiOperation(
182
294
  () => IssueLinkTypeService.getIssueLinkTypes(),
@@ -267,6 +379,22 @@ export const jiraToolSchemas = {
267
379
  fields: z.record(z.any()).optional().describe("Optional fields required by the transition screen. Use jira_getTransitions to see which fields are available for each transition."),
268
380
  customFields: z.record(z.any()).optional().describe("Optional fields merged into the JIRA transition payload. Can be used for update operations such as comments. Example: {'update': {'comment': [{'add': {'body': 'text'}}]}}")
269
381
  },
382
+ uploadAttachment: {
383
+ issueKey: z.string().describe("JIRA issue key (e.g., PROJ-123)"),
384
+ sourcePath: z.string().describe("Path to the file to upload, relative to a server-configured attachment upload directory. Absolute paths and '..' segments are rejected; symlinks and non-regular files are refused."),
385
+ filename: z.string().optional().describe("Override for the attachment filename (defaults to the basename of sourcePath)")
386
+ },
387
+ downloadAttachment: {
388
+ issueKey: z.string().optional().describe("JIRA issue key (e.g., PROJ-123) whose attachment(s) to download. Provide either issueKey or attachmentId."),
389
+ attachmentId: z.string().optional().describe("Numeric id of a single attachment to download. Provide either attachmentId or issueKey."),
390
+ filename: z.string().optional().describe("When using issueKey, download only attachments with this exact filename. If omitted, all attachments on the issue are downloaded."),
391
+ returnContent: z.enum(['none', 'base64', 'text']).optional().describe("Whether to embed the file bytes in the response: 'none' (default), 'base64' for binary, or 'text' for UTF-8 text."),
392
+ maxInlineBytes: z.number().optional().describe("Maximum bytes to embed inline when returnContent is base64/text. Larger files are omitted from the inline content. Defaults to 1 MiB.")
393
+ },
394
+ downloadAttachmentSaveFields: {
395
+ save: z.boolean().optional().describe("Save the attachment(s) into the server-configured download directory. Requires disk downloads to be enabled on the server."),
396
+ saveName: z.string().optional().describe("Optional file name (no directories) to use when saving a single attachment; defaults to the attachment's own name. Existing files are never overwritten.")
397
+ },
270
398
  getIssueLinkTypes: {},
271
399
  linkIssues: {
272
400
  inwardIssueKey: z.string().describe("Key of the inward issue (the one the inward link description applies to, e.g. the issue that 'is blocked by'). Example: PROJ-123"),