@stubbedev/atlassian-mcp 0.2.12 → 0.3.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.
package/README.md CHANGED
@@ -28,7 +28,8 @@ A [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server for **s
28
28
  | Tool | Description |
29
29
  |---|---|
30
30
  | `jira_search` | Discover resources: `issues`, `projects`, `issue_types`, `boards`, `sprints`, `board_overview`, or `users` via `resource` param |
31
- | `jira_get` | Full details for one issue: summary, description, status, sprint, transitions, and comments |
31
+ | `jira_get` | Full details for one issue: summary, description, status, sprint, transitions, comments, and attachment list |
32
+ | `jira_get_attachment` | Fetch a Jira attachment by ID; images are auto-resized via sharp and returned inline so the model can see them, text/JSON inline, larger/binary files via `saveTo` |
32
33
  | `jira_mutate` | Create, update, transition, comment, link, add to sprint, or log work — all in one call |
33
34
  | `jira_comment` | Add, update, or delete a comment on an issue (`action`: `add` / `update` / `delete`) |
34
35
 
@@ -37,7 +38,8 @@ A [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server for **s
37
38
  | Tool | Description |
38
39
  |---|---|
39
40
  | `bitbucket_search` | Discover resources: `pull_requests` (default), `repos`, or `branches` via `resource` param; `mine=true` for your inbox |
40
- | `bitbucket_get_pr` | Full PR details: metadata, commits, comments, blockers, build status, and optional diff |
41
+ | `bitbucket_get_pr` | Full PR details: metadata, commits, comments, blockers, build status, optional diff, and any attachments referenced from the description or comments |
42
+ | `bitbucket_get_attachment` | Fetch a repo attachment by ID (images auto-resized inline via sharp; text inline; binary/large via `saveTo`) |
41
43
  | `bitbucket_mutate` | Create/update a PR, or perform lifecycle actions: `approve`, `unapprove`, `merge`, `decline` |
42
44
  | `bitbucket_comment` | Add, update, or delete a PR comment; for code changes use `suggestion` so Bitbucket shows Apply suggestion (no trailing text after a suggestion block) |
43
45
  | `bitbucket_get_file` | Raw file content from Bitbucket at a branch, tag, or commit |
@@ -239,6 +241,10 @@ npm install
239
241
 
240
242
  Then use `node /path/to/atlassian-mcp/dist/index.js` instead of the `npx` command in the configs above.
241
243
 
244
+ ### Native dependency: `sharp`
245
+
246
+ Image attachments are downscaled and re-encoded with [`sharp`](https://sharp.pixelplumbing.com/) before being returned to the model so they fit in context. Sharp ships prebuilt binaries for glibc Linux (x64/arm64), macOS, and Windows — no extra setup needed on those. Alpine / musl users may need `npm install --cpu=x64 --os=linux --libc=musl sharp`.
247
+
242
248
  ---
243
249
 
244
250
  ## Releases (Maintainers)
@@ -0,0 +1,109 @@
1
+ import sharp from 'sharp';
2
+ import { writeFile } from 'fs/promises';
3
+ import { resolve as resolvePath } from 'path';
4
+ export const MAX_INLINE_BYTES = 10 * 1024 * 1024;
5
+ export const DEFAULT_MAX_DIMENSION = 1568;
6
+ export const DEFAULT_JPEG_QUALITY = 85;
7
+ export function formatBytes(bytes) {
8
+ if (bytes < 1024)
9
+ return `${bytes} B`;
10
+ if (bytes < 1024 * 1024)
11
+ return `${(bytes / 1024).toFixed(1)} KB`;
12
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
13
+ }
14
+ export function isTextMime(mimeType) {
15
+ const mt = mimeType.toLowerCase();
16
+ if (mt.startsWith('text/'))
17
+ return true;
18
+ return [
19
+ 'application/json',
20
+ 'application/xml',
21
+ 'application/javascript',
22
+ 'application/x-yaml',
23
+ 'application/yaml',
24
+ 'application/x-sh',
25
+ 'application/sql',
26
+ ].some((m) => mt === m || mt.startsWith(`${m};`));
27
+ }
28
+ async function processImage(buffer, mimeType, opts) {
29
+ // SVG: pass through. Sharp can rasterize but the LLM benefits more from the source markup.
30
+ if (mimeType.toLowerCase() === 'image/svg+xml') {
31
+ return { data: buffer, mimeType, resized: false };
32
+ }
33
+ const img = sharp(buffer, { failOn: 'none' }).rotate();
34
+ const meta = await img.metadata();
35
+ const width = meta.width ?? 0;
36
+ const height = meta.height ?? 0;
37
+ const longEdge = Math.max(width, height);
38
+ const needsResize = longEdge > opts.maxDimension;
39
+ let pipeline = img;
40
+ if (needsResize) {
41
+ pipeline = pipeline.resize({
42
+ width: width >= height ? opts.maxDimension : undefined,
43
+ height: height > width ? opts.maxDimension : undefined,
44
+ fit: 'inside',
45
+ withoutEnlargement: true,
46
+ });
47
+ }
48
+ const hasAlpha = meta.hasAlpha ?? false;
49
+ if (hasAlpha) {
50
+ const data = await pipeline.png({ compressionLevel: 9 }).toBuffer();
51
+ return { data, mimeType: 'image/png', resized: needsResize };
52
+ }
53
+ const data = await pipeline.jpeg({ quality: opts.quality, mozjpeg: true }).toBuffer();
54
+ return { data, mimeType: 'image/jpeg', resized: needsResize };
55
+ }
56
+ export async function buildAttachmentResult(args) {
57
+ const { id, filename, mimeType, buffer, saveTo } = args;
58
+ const sizeLabel = formatBytes(buffer.length);
59
+ const header = `${filename} — ${mimeType}, ${sizeLabel}`;
60
+ if (saveTo) {
61
+ const path = resolvePath(saveTo);
62
+ await writeFile(path, buffer);
63
+ return { content: [{ type: 'text', text: `Saved attachment #${id} (${header}) to ${path}` }] };
64
+ }
65
+ const mt = mimeType.toLowerCase();
66
+ if (mt.startsWith('image/')) {
67
+ if (buffer.length > MAX_INLINE_BYTES) {
68
+ return {
69
+ content: [{
70
+ type: 'text',
71
+ text: `${header}\nAttachment #${id} exceeds the ${formatBytes(MAX_INLINE_BYTES)} input cap. Pass saveTo=/absolute/path to write it to disk.`,
72
+ }],
73
+ };
74
+ }
75
+ const maxDimension = args.maxDimension ?? DEFAULT_MAX_DIMENSION;
76
+ const quality = args.quality ?? DEFAULT_JPEG_QUALITY;
77
+ try {
78
+ const processed = await processImage(buffer, mimeType, { maxDimension, quality });
79
+ const resizedNote = processed.resized
80
+ ? ` (resized to ${maxDimension}px long edge, re-encoded to ${formatBytes(processed.data.length)})`
81
+ : processed.data.length < buffer.length
82
+ ? ` (re-encoded to ${formatBytes(processed.data.length)})`
83
+ : '';
84
+ return {
85
+ content: [
86
+ { type: 'text', text: `Attachment #${id}: ${header}${resizedNote}` },
87
+ { type: 'image', data: processed.data.toString('base64'), mimeType: processed.mimeType },
88
+ ],
89
+ };
90
+ }
91
+ catch (err) {
92
+ return {
93
+ content: [{
94
+ type: 'text',
95
+ text: `${header}\nFailed to process image: ${err.message}. Pass saveTo to write the original to disk.`,
96
+ }],
97
+ };
98
+ }
99
+ }
100
+ if (isTextMime(mt) && buffer.length <= MAX_INLINE_BYTES) {
101
+ return { content: [{ type: 'text', text: `Attachment #${id}: ${header}\n\n${buffer.toString('utf-8')}` }] };
102
+ }
103
+ return {
104
+ content: [{
105
+ type: 'text',
106
+ text: `${header}\nAttachment #${id} is${buffer.length > MAX_INLINE_BYTES ? ` larger than ${formatBytes(MAX_INLINE_BYTES)} or` : ''} not inline-renderable. Pass saveTo=/absolute/path to write it to disk.`,
107
+ }],
108
+ };
109
+ }
package/dist/bitbucket.js CHANGED
@@ -1,5 +1,26 @@
1
1
  import { execSync } from 'child_process';
2
+ import { buildAttachmentResult } from './attachment.js';
2
3
  const EMOJI_RE = /\p{Extended_Pictographic}/u;
4
+ const ATTACHMENT_REF_RE = /!?\[([^\]]*)\]\(attachment:(\d+)\)/g;
5
+ function collectAttachmentRefs(input, source, out) {
6
+ if (!input)
7
+ return;
8
+ ATTACHMENT_REF_RE.lastIndex = 0;
9
+ let match;
10
+ while ((match = ATTACHMENT_REF_RE.exec(input)) !== null) {
11
+ const id = match[2];
12
+ if (!out.has(id)) {
13
+ out.set(id, { id, filename: match[1] || '(unnamed)', source });
14
+ }
15
+ }
16
+ }
17
+ function collectFromCommentTree(comment, out) {
18
+ if (comment.deleted)
19
+ return;
20
+ collectAttachmentRefs(comment.text, `comment #${comment.id}`, out);
21
+ for (const reply of comment.comments ?? [])
22
+ collectFromCommentTree(reply, out);
23
+ }
3
24
  function safeExec(cmd) {
4
25
  try {
5
26
  return execSync(cmd, { encoding: 'utf-8' }).trim();
@@ -466,6 +487,8 @@ export class BitbucketClient {
466
487
  if (!pr)
467
488
  return text('Pull request not found.');
468
489
  const sections = [];
490
+ const attachmentRefs = new Map();
491
+ collectAttachmentRefs(pr.description, 'description', attachmentRefs);
469
492
  const reviewers = pr.reviewers.map((r) => `${r.user.displayName}${r.approved ? ' ✓' : ''}`).join(', ');
470
493
  const url = pr.links?.self?.[0]?.href;
471
494
  const header = [
@@ -521,6 +544,8 @@ export class BitbucketClient {
521
544
  sections.push(`Comments:\n(no ${commentsState} BLOCKER comments)`);
522
545
  }
523
546
  else {
547
+ for (const comment of data.values)
548
+ collectFromCommentTree(comment, attachmentRefs);
524
549
  const blocks = data.values.flatMap((comment) => formatCommentThread(comment));
525
550
  sections.push(`Comments (${data.values.length} BLOCKER thread(s))${pageHint(data)}:\n\n${blocks.join('\n\n')}`);
526
551
  }
@@ -531,6 +556,8 @@ export class BitbucketClient {
531
556
  const matchesState = commentMatchesState(comment, commentsState);
532
557
  return matchesState && commentMatchesSeverity(comment, commentsSeverity);
533
558
  });
559
+ for (const comment of comments)
560
+ collectFromCommentTree(comment, attachmentRefs);
534
561
  if (comments.length === 0) {
535
562
  sections.push('Comments:\n(no matching comments)');
536
563
  }
@@ -541,6 +568,14 @@ export class BitbucketClient {
541
568
  }
542
569
  }
543
570
  }
571
+ if (attachmentRefs.size > 0) {
572
+ const lines = [`Attachments referenced: ${attachmentRefs.size}`];
573
+ for (const ref of attachmentRefs.values()) {
574
+ lines.push(` #${ref.id} ${ref.filename} — in ${ref.source}`);
575
+ }
576
+ lines.push('Use bitbucket_get_attachment with attachmentId to view contents.');
577
+ sections.push(lines.join('\n'));
578
+ }
544
579
  if (includeDiff) {
545
580
  const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${prId}/diff`);
546
581
  sections.push(`Diff:\n${data ? formatDiff(data, args.diffMaxChars ?? 8000) : '(no diff found)'}`);
@@ -779,6 +814,36 @@ export class BitbucketClient {
779
814
  }
780
815
  return text(content);
781
816
  }
817
+ async getAttachment(args) {
818
+ const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
819
+ const id = String(args.attachmentId ?? '').trim();
820
+ if (!id)
821
+ throw new Error('attachmentId is required.');
822
+ const url = `${this.baseUrl}/rest/api/1.0${this.rp(projectKey, repoSlug)}/attachments/${encodeURIComponent(id)}`;
823
+ const res = await fetch(url, {
824
+ method: 'GET',
825
+ headers: { Authorization: this.headers.Authorization },
826
+ signal: AbortSignal.timeout(60_000),
827
+ });
828
+ if (!res.ok) {
829
+ const errText = await res.text();
830
+ throw new Error(formatBitbucketError(res.status, 'GET', `${this.rp(projectKey, repoSlug)}/attachments/${id}`, parseBitbucketErrorDetails(errText)));
831
+ }
832
+ const buffer = Buffer.from(await res.arrayBuffer());
833
+ const contentDisposition = res.headers.get('content-disposition') ?? '';
834
+ const filenameMatch = contentDisposition.match(/filename\*?=(?:UTF-8'')?"?([^";]+)"?/i);
835
+ const filename = filenameMatch ? decodeURIComponent(filenameMatch[1]) : `attachment-${id}`;
836
+ const mimeType = (res.headers.get('content-type') ?? 'application/octet-stream').split(';')[0].trim();
837
+ return buildAttachmentResult({
838
+ id,
839
+ filename,
840
+ mimeType,
841
+ buffer,
842
+ saveTo: args.saveTo,
843
+ maxDimension: args.maxDimension,
844
+ quality: args.quality,
845
+ });
846
+ }
782
847
  async fetchFileText(projectKey, repoSlug, filePath) {
783
848
  try {
784
849
  const encoded = filePath.split('/').map(encodeURIComponent).join('/');
package/dist/index.js CHANGED
@@ -170,7 +170,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
170
170
  },
171
171
  {
172
172
  name: 'jira_get',
173
- description: 'Full details for one Jira issue: summary, description, status, assignee, sprint, available transitions, and recent comments. Use when asked to "show me FOO-123", "what does this ticket say", "get the details for this issue", or after discovering a key from get_dev_context or jira_search.',
173
+ description: 'Full details for one Jira issue: summary, description, status, assignee, sprint, available transitions, recent comments, and a list of attachments (filename, size, mime type, attachment ID). Use when asked to "show me FOO-123", "what does this ticket say", "get the details for this issue", or after discovering a key from get_dev_context or jira_search. To view an attachment\'s contents (e.g. an image), call jira_get_attachment with the attachment ID surfaced here.',
174
174
  inputSchema: {
175
175
  type: 'object',
176
176
  properties: {
@@ -246,6 +246,20 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
246
246
  },
247
247
  },
248
248
  },
249
+ {
250
+ name: 'jira_get_attachment',
251
+ description: 'Fetch a Jira attachment by ID and return its contents inline. Images are auto-resized (long edge ≤1568 px by default) and re-encoded so they fit in context, then returned as image content blocks (so you can see them); text/JSON/XML come back as text. For binary types (PDF, zip, office docs) or files larger than 10 MB, pass saveTo=/absolute/path to write the original file to disk and then read it locally. For screenshots of code or other detail-heavy images, raise maxDimension. Use jira_get first to discover attachment IDs.',
252
+ inputSchema: {
253
+ type: 'object',
254
+ properties: {
255
+ attachmentId: { type: 'string', description: 'Numeric attachment ID from jira_get output' },
256
+ saveTo: { type: 'string', description: 'Optional absolute path to save the original (un-resized) file to disk instead of returning inline' },
257
+ maxDimension: { type: 'number', description: 'Max long-edge size in pixels for inline images (default 1568). Larger images are downscaled with sharp.' },
258
+ quality: { type: 'number', description: 'JPEG quality for re-encoded inline images (1-100, default 85). Ignored for images with alpha (encoded as PNG).' },
259
+ },
260
+ required: ['attachmentId'],
261
+ },
262
+ },
249
263
  {
250
264
  name: 'jira_comment',
251
265
  description: `Add, update, or delete a comment on a Jira issue. Use when asked to "edit my comment on FOO-123", "delete comment 12345", or "update that comment". action defaults to "add". Can only edit/delete your own comments. ${JIRA_WIKI_MARKUP_HINT}`,
@@ -286,7 +300,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
286
300
  },
287
301
  {
288
302
  name: 'bitbucket_get_pr',
289
- description: 'Full details for one PR: metadata, commits, open comments, blockers, and optional diff. Use when asked to "review this PR", "show me the review comments", "what\'s blocking the merge", or after get_dev_context surfaces a prId. IMPORTANT: The PR branch is often not the locally checked-out branch. Do NOT read files with local tools (Read, git_get_diff, etc.) for PR context — use bitbucket_get_file with the PR\'s source branch instead. The response includes a "Viewing as" line — if it says "you are the author", do NOT add review comments or a summary unless explicitly asked; just answer questions about the PR. If it says "you are a reviewer", default to posting inline comments for suggested changes and a final summary comment.',
303
+ description: 'Full details for one PR: metadata, commits, open comments, blockers, optional diff, and any attachments referenced from the description or comments (with attachment ID + filename). Use when asked to "review this PR", "show me the review comments", "what\'s blocking the merge", or after get_dev_context surfaces a prId. IMPORTANT: The PR branch is often not the locally checked-out branch. Do NOT read files with local tools (Read, git_get_diff, etc.) for PR context — use bitbucket_get_file with the PR\'s source branch instead. To view an attachment\'s contents, call bitbucket_get_attachment with the surfaced attachment ID. The response includes a "Viewing as" line — if it says "you are the author", do NOT add review comments or a summary unless explicitly asked; just answer questions about the PR. If it says "you are a reviewer", default to posting inline comments for suggested changes and a final summary comment.',
290
304
  inputSchema: {
291
305
  type: 'object',
292
306
  properties: {
@@ -393,6 +407,24 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
393
407
  required: ['path'],
394
408
  },
395
409
  },
410
+ {
411
+ name: 'bitbucket_get_attachment',
412
+ description: 'Fetch a Bitbucket repo attachment by ID and return its contents inline. Bitbucket Server attachments are repo-scoped and referenced from PR descriptions/comments via attachment:<id> markdown. Use bitbucket_get_pr first to surface attachment IDs. Images are auto-resized (long edge ≤1568 px by default) and re-encoded, then returned as image content blocks; text/JSON/XML as text; binary or >10 MB requires saveTo=/absolute/path.',
413
+ inputSchema: {
414
+ type: 'object',
415
+ properties: {
416
+ projectKey: { type: 'string', description: 'Bitbucket project code (usually auto-detected)' },
417
+ project: { type: 'string', description: 'Alias for projectKey' },
418
+ repoSlug: { type: 'string', description: 'Repository slug (usually auto-detected)' },
419
+ repo: { type: 'string', description: 'Alias for repoSlug' },
420
+ attachmentId: { type: 'string', description: 'Numeric attachment ID' },
421
+ saveTo: { type: 'string', description: 'Optional absolute path to save the original (un-resized) file to disk instead of returning inline' },
422
+ maxDimension: { type: 'number', description: 'Max long-edge size in pixels for inline images (default 1568). Larger images are downscaled with sharp.' },
423
+ quality: { type: 'number', description: 'JPEG quality for re-encoded inline images (1-100, default 85). Ignored for images with alpha (encoded as PNG).' },
424
+ },
425
+ required: ['attachmentId'],
426
+ },
427
+ },
396
428
  {
397
429
  name: 'bitbucket_pr_tasks',
398
430
  description: 'Manage PR tasks (checklist items). Use when asked to "list the tasks on this PR", "create a task for FOO-123", "mark task #5 as done", or "add a checklist item". Tasks are distinct from comments — they appear as a checklist in the PR sidebar.',
@@ -681,6 +713,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
681
713
  if (!jira)
682
714
  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
683
715
  return await jira.mutateIssue(normalizeJiraMutateArgs(args));
716
+ case 'jira_get_attachment': {
717
+ if (!jira)
718
+ throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
719
+ const a = args;
720
+ return await jira.getAttachment(a);
721
+ }
684
722
  case 'jira_comment': {
685
723
  if (!jira)
686
724
  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
@@ -779,6 +817,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
779
817
  if (!bitbucket)
780
818
  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
781
819
  return await bitbucket.getFile(normalizeBitbucketArgs(args));
820
+ case 'bitbucket_get_attachment': {
821
+ if (!bitbucket)
822
+ throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
823
+ return await bitbucket.getAttachment(normalizeBitbucketArgs(args));
824
+ }
782
825
  case 'bitbucket_pr_tasks': {
783
826
  if (!bitbucket)
784
827
  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
package/dist/jira.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execSync } from 'child_process';
2
+ import { buildAttachmentResult, formatBytes } from './attachment.js';
2
3
  const JIRA_KEY_IN_BRANCH_RE = /\b([A-Z][A-Z0-9]+)-\d+\b/;
3
4
  const EMOJI_RE = /\p{Extended_Pictographic}/u;
4
5
  function text(t) {
@@ -397,7 +398,7 @@ export class JiraClient {
397
398
  const includeSprint = args.includeSprint ?? true;
398
399
  const commentsMaxResults = args.commentsMaxResults ?? 10;
399
400
  const commentsStartAt = args.commentsStartAt ?? 0;
400
- const fields = 'summary,description,status,assignee,priority,issuetype,labels,components,parent,fixVersions,issuelinks,subtasks';
401
+ const fields = 'summary,description,status,assignee,priority,issuetype,labels,components,parent,fixVersions,issuelinks,subtasks,attachment';
401
402
  const issue = await this.request('GET', `/issue/${encodeURIComponent(args.issueKey)}?fields=${fields}`);
402
403
  if (!issue)
403
404
  return text('Issue not found.');
@@ -451,6 +452,14 @@ export class JiraClient {
451
452
  lines.push(`Transitions: ${names.length > 0 ? names.join(', ') : '(none)'}`);
452
453
  }
453
454
  lines.push('', 'Description:', f.description ?? '(no description)');
455
+ if (f.attachment?.length) {
456
+ lines.push('', `Attachments: ${f.attachment.length}`);
457
+ for (const att of f.attachment) {
458
+ const mt = att.mimeType ?? 'application/octet-stream';
459
+ lines.push(` #${att.id} ${att.filename} (${mt}, ${formatBytes(att.size)})`);
460
+ }
461
+ lines.push('Use jira_get_attachment with attachmentId to view contents.');
462
+ }
454
463
  if (includeComments) {
455
464
  const comments = await this.request('GET', `/issue/${encodeURIComponent(args.issueKey)}/comment?startAt=${commentsStartAt}&maxResults=${commentsMaxResults}`);
456
465
  const items = comments?.comments ?? [];
@@ -717,6 +726,33 @@ export class JiraClient {
717
726
  const page = data.isLast ? '' : ` (use startAt=${(args.startAt ?? 0) + data.values.length} for next page)`;
718
727
  return text(`${data.values.length} board(s)${page}:\n${lines.join('\n')}`);
719
728
  }
729
+ async getAttachment(args) {
730
+ const id = String(args.attachmentId ?? '').trim();
731
+ if (!id)
732
+ throw new Error('attachmentId is required.');
733
+ const meta = await this.request('GET', `/attachment/${encodeURIComponent(id)}`);
734
+ if (!meta)
735
+ throw new Error(`Attachment ${id} not found.`);
736
+ const res = await fetch(meta.content, {
737
+ method: 'GET',
738
+ headers: { Authorization: this.headers.Authorization },
739
+ signal: AbortSignal.timeout(60_000),
740
+ });
741
+ if (!res.ok) {
742
+ const errText = await res.text();
743
+ throw new Error(formatJiraError(res.status, 'GET', meta.content, parseJiraErrorDetails(errText)));
744
+ }
745
+ const buffer = Buffer.from(await res.arrayBuffer());
746
+ return buildAttachmentResult({
747
+ id,
748
+ filename: meta.filename,
749
+ mimeType: meta.mimeType ?? 'application/octet-stream',
750
+ buffer,
751
+ saveTo: args.saveTo,
752
+ maxDimension: args.maxDimension,
753
+ quality: args.quality,
754
+ });
755
+ }
720
756
  async transitionIssue(args) {
721
757
  const transitionId = await this.resolveTransitionId(args.issueKey, args.transitionId, args.transitionName);
722
758
  await this.transitionIssueInternal(args.issueKey, transitionId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stubbedev/atlassian-mcp",
3
- "version": "0.2.12",
3
+ "version": "0.3.0",
4
4
  "description": "MCP server for self-hosted Jira and Bitbucket",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -27,7 +27,8 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "@modelcontextprotocol/sdk": "^1.29.0",
30
- "dotenv": "^17.4.2"
30
+ "dotenv": "^17.4.2",
31
+ "sharp": "^0.34.5"
31
32
  },
32
33
  "devDependencies": {
33
34
  "@types/node": "^25.6.0",