@stubbedev/atlassian-mcp 0.2.12 → 0.3.1

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();
@@ -216,6 +237,9 @@ export class BitbucketClient {
216
237
  this.currentUsernameCache = username;
217
238
  return username;
218
239
  }
240
+ async whoami() {
241
+ return this.getCurrentUsername();
242
+ }
219
243
  /** Returns a URL-safe `/projects/.../repos/...` prefix for REST paths. */
220
244
  rp(projectKey, repoSlug) {
221
245
  return `/projects/${encodeURIComponent(projectKey)}/repos/${encodeURIComponent(repoSlug)}`;
@@ -466,6 +490,8 @@ export class BitbucketClient {
466
490
  if (!pr)
467
491
  return text('Pull request not found.');
468
492
  const sections = [];
493
+ const attachmentRefs = new Map();
494
+ collectAttachmentRefs(pr.description, 'description', attachmentRefs);
469
495
  const reviewers = pr.reviewers.map((r) => `${r.user.displayName}${r.approved ? ' ✓' : ''}`).join(', ');
470
496
  const url = pr.links?.self?.[0]?.href;
471
497
  const header = [
@@ -521,6 +547,8 @@ export class BitbucketClient {
521
547
  sections.push(`Comments:\n(no ${commentsState} BLOCKER comments)`);
522
548
  }
523
549
  else {
550
+ for (const comment of data.values)
551
+ collectFromCommentTree(comment, attachmentRefs);
524
552
  const blocks = data.values.flatMap((comment) => formatCommentThread(comment));
525
553
  sections.push(`Comments (${data.values.length} BLOCKER thread(s))${pageHint(data)}:\n\n${blocks.join('\n\n')}`);
526
554
  }
@@ -531,6 +559,8 @@ export class BitbucketClient {
531
559
  const matchesState = commentMatchesState(comment, commentsState);
532
560
  return matchesState && commentMatchesSeverity(comment, commentsSeverity);
533
561
  });
562
+ for (const comment of comments)
563
+ collectFromCommentTree(comment, attachmentRefs);
534
564
  if (comments.length === 0) {
535
565
  sections.push('Comments:\n(no matching comments)');
536
566
  }
@@ -541,6 +571,14 @@ export class BitbucketClient {
541
571
  }
542
572
  }
543
573
  }
574
+ if (attachmentRefs.size > 0) {
575
+ const lines = [`Attachments referenced: ${attachmentRefs.size}`];
576
+ for (const ref of attachmentRefs.values()) {
577
+ lines.push(` #${ref.id} ${ref.filename} — in ${ref.source}`);
578
+ }
579
+ lines.push('Use bitbucket_get_attachment with attachmentId to view contents.');
580
+ sections.push(lines.join('\n'));
581
+ }
544
582
  if (includeDiff) {
545
583
  const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${prId}/diff`);
546
584
  sections.push(`Diff:\n${data ? formatDiff(data, args.diffMaxChars ?? 8000) : '(no diff found)'}`);
@@ -779,6 +817,36 @@ export class BitbucketClient {
779
817
  }
780
818
  return text(content);
781
819
  }
820
+ async getAttachment(args) {
821
+ const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
822
+ const id = String(args.attachmentId ?? '').trim();
823
+ if (!id)
824
+ throw new Error('attachmentId is required.');
825
+ const url = `${this.baseUrl}/rest/api/1.0${this.rp(projectKey, repoSlug)}/attachments/${encodeURIComponent(id)}`;
826
+ const res = await fetch(url, {
827
+ method: 'GET',
828
+ headers: { Authorization: this.headers.Authorization },
829
+ signal: AbortSignal.timeout(60_000),
830
+ });
831
+ if (!res.ok) {
832
+ const errText = await res.text();
833
+ throw new Error(formatBitbucketError(res.status, 'GET', `${this.rp(projectKey, repoSlug)}/attachments/${id}`, parseBitbucketErrorDetails(errText)));
834
+ }
835
+ const buffer = Buffer.from(await res.arrayBuffer());
836
+ const contentDisposition = res.headers.get('content-disposition') ?? '';
837
+ const filenameMatch = contentDisposition.match(/filename\*?=(?:UTF-8'')?"?([^";]+)"?/i);
838
+ const filename = filenameMatch ? decodeURIComponent(filenameMatch[1]) : `attachment-${id}`;
839
+ const mimeType = (res.headers.get('content-type') ?? 'application/octet-stream').split(';')[0].trim();
840
+ return buildAttachmentResult({
841
+ id,
842
+ filename,
843
+ mimeType,
844
+ buffer,
845
+ saveTo: args.saveTo,
846
+ maxDimension: args.maxDimension,
847
+ quality: args.quality,
848
+ });
849
+ }
782
850
  async fetchFileText(projectKey, repoSlug, filePath) {
783
851
  try {
784
852
  const encoded = filePath.split('/').map(encodeURIComponent).join('/');
package/dist/context.js CHANGED
@@ -9,6 +9,27 @@ function safeExec(cmd, cwd) {
9
9
  return '';
10
10
  }
11
11
  }
12
+ /** Top recent committers from the last `lookback` commits, ranked by count. */
13
+ export function getTopCommitters(repoPath, lookback = 50, top = 5) {
14
+ const raw = safeExec(`git log -n ${lookback} --format=%aN%x09%aE`, repoPath);
15
+ if (!raw)
16
+ return [];
17
+ const counts = new Map();
18
+ for (const line of raw.split('\n')) {
19
+ const [name, email] = line.split('\t');
20
+ if (!name)
21
+ continue;
22
+ const key = (email || name).toLowerCase();
23
+ const existing = counts.get(key);
24
+ if (existing)
25
+ existing.commits++;
26
+ else
27
+ counts.set(key, { name, email: email ?? '', commits: 1 });
28
+ }
29
+ return [...counts.values()]
30
+ .sort((a, b) => b.commits - a.commits)
31
+ .slice(0, top);
32
+ }
12
33
  /**
13
34
  * Unified developer context: git state + linked Jira issues + open PR for current branch.
14
35
  * Either jira or bitbucket may be null when only one product is configured.
@@ -20,6 +41,8 @@ export async function getDevContext(args, jira, bitbucket) {
20
41
  const remote = safeExec('git remote get-url origin', repoPath) || '(no remote)';
21
42
  const recentCommits = safeExec('git log --oneline -5', repoPath) || '(none)';
22
43
  const status = safeExec('git status --short', repoPath) || '(clean)';
44
+ const committers = getTopCommitters(repoPath, 50, 5);
45
+ const parsed = bitbucket?.isRemoteForThisInstance(remote) ? parseBitbucketRemote(remote) : null;
23
46
  // Upstream ahead/behind
24
47
  const upstream = safeExec('git rev-parse --abbrev-ref @{u}', repoPath);
25
48
  let upstreamLine = '';
@@ -35,18 +58,40 @@ export async function getDevContext(args, jira, bitbucket) {
35
58
  upstreamLine = `${upstream}${parts.length ? ` (${parts.join(', ')})` : ' (up to date)'}`;
36
59
  }
37
60
  }
38
- sections.push([
61
+ // Identity — best-effort, parallel
62
+ const [jiraMe, bbMe] = await Promise.all([
63
+ jira ? jira.whoami().catch(() => null) : Promise.resolve(null),
64
+ bitbucket ? bitbucket.whoami().catch(() => null) : Promise.resolve(null),
65
+ ]);
66
+ const youParts = [];
67
+ if (jiraMe)
68
+ youParts.push(`Jira ${jiraMe.name ?? jiraMe.key ?? '(unknown)'}${jiraMe.displayName ? ` "${jiraMe.displayName}"` : ''}`);
69
+ if (bbMe)
70
+ youParts.push(`Bitbucket ${bbMe}`);
71
+ const headerLines = [
39
72
  `Repository: ${repoPath}`,
40
73
  `Branch: ${branch}`,
41
74
  ...(upstreamLine ? [`Upstream: ${upstreamLine}`] : []),
42
75
  `Remote: ${remote}`,
43
- '',
44
- 'Recent commits:',
45
- recentCommits,
46
- '',
47
- 'Working tree:',
48
- status,
49
- ].join('\n'));
76
+ ...(parsed ? [`Bitbucket: ${parsed.projectKey}/${parsed.repoSlug}`] : []),
77
+ ...(youParts.length ? [`You: ${youParts.join(' · ')}`] : []),
78
+ ];
79
+ if (committers.length) {
80
+ headerLines.push('');
81
+ headerLines.push('Top recent committers (last 50):');
82
+ for (const c of committers) {
83
+ const ident = c.email ? `${c.name} <${c.email}>` : c.name;
84
+ headerLines.push(` ${c.commits.toString().padStart(3)} — ${ident}`);
85
+ }
86
+ headerLines.push(' (look up usernames via jira_search resource=users / bitbucket_search resource=users — do NOT shell out to git/gh/bb)');
87
+ }
88
+ headerLines.push('');
89
+ headerLines.push('Recent commits:');
90
+ headerLines.push(recentCommits);
91
+ headerLines.push('');
92
+ headerLines.push('Working tree:');
93
+ headerLines.push(status);
94
+ sections.push(headerLines.join('\n'));
50
95
  // Jira — fetch overview for any tickets referenced in the branch name (parallel)
51
96
  const jiraKeys = jira ? [...new Set(branch.match(JIRA_KEY_RE) ?? [])] : [];
52
97
  const jiraResults = await Promise.all(jiraKeys.map(async (key) => {
@@ -66,7 +111,6 @@ export async function getDevContext(args, jira, bitbucket) {
66
111
  }));
67
112
  sections.push(...jiraResults);
68
113
  // Bitbucket — find the open PR for this branch (only if remote points to this instance)
69
- const parsed = bitbucket?.isRemoteForThisInstance(remote) ? parseBitbucketRemote(remote) : null;
70
114
  if (parsed) {
71
115
  try {
72
116
  const pr = await bitbucket.findOpenPrForBranch(parsed.projectKey, parsed.repoSlug, branch);
package/dist/index.js CHANGED
@@ -12,7 +12,7 @@ import { loadConfig } from './config.js';
12
12
  import { JiraClient } from './jira.js';
13
13
  import { BitbucketClient, parseBitbucketRemote } from './bitbucket.js';
14
14
  import { getContext, getDiff, createBranch, checkRemoteBranch, checkoutRemoteBranch } from './git.js';
15
- import { getDevContext } from './context.js';
15
+ import { getDevContext, getTopCommitters } from './context.js';
16
16
  function currentGitRemote() {
17
17
  try {
18
18
  return execFileSync('git', ['remote', 'get-url', 'origin'], { encoding: 'utf-8' }).trim();
@@ -21,6 +21,14 @@ function currentGitRemote() {
21
21
  return '';
22
22
  }
23
23
  }
24
+ function currentGitBranch() {
25
+ try {
26
+ return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { encoding: 'utf-8' }).trim();
27
+ }
28
+ catch {
29
+ return '';
30
+ }
31
+ }
24
32
  function remoteMatchesBitbucketInstance(remote, bitbucketUrl) {
25
33
  if (!remote)
26
34
  return false;
@@ -39,7 +47,57 @@ const bitbucket = (config.bitbucket && remoteMatchesBitbucketInstance(_remote, c
39
47
  if (config.bitbucket && !bitbucket) {
40
48
  console.error(`[atlassian-mcp] Bitbucket configured but remote "${_remote || '(none)'}" does not match ${config.bitbucket.url} — Bitbucket tools disabled for this repo.`);
41
49
  }
42
- const server = new Server({ name: 'atlassian-mcp', version: _pkg.version }, { capabilities: { tools: {} } });
50
+ async function buildInstructions() {
51
+ const branch = currentGitBranch();
52
+ const isGitRepo = branch !== '';
53
+ const jiraKeys = isGitRepo ? [...new Set(branch.match(/\b[A-Z][A-Z0-9]+-\d+\b/g) ?? [])] : [];
54
+ const parsed = bitbucket && _remote ? parseBitbucketRemote(_remote) : null;
55
+ const committers = isGitRepo ? getTopCommitters(process.cwd(), 50, 5) : [];
56
+ const [jiraMe, bbMe] = await Promise.all([
57
+ jira ? jira.whoami().catch(() => null) : Promise.resolve(null),
58
+ bitbucket ? bitbucket.whoami().catch(() => null) : Promise.resolve(null),
59
+ ]);
60
+ const lines = [];
61
+ lines.push('# atlassian-mcp');
62
+ lines.push('');
63
+ lines.push('Self-hosted Jira + Bitbucket Server tooling. Prefer these tools over shelling out to `git log`, `gh`, or any `bitbucket`/`bb` CLI for anything that touches tickets, PRs, reviewers, comments, or user lookups.');
64
+ lines.push('');
65
+ lines.push('## Configured services');
66
+ lines.push(`- Jira: ${jira ? config.jira.url : '(not configured)'}${jiraMe ? ` — you are ${jiraMe.name ?? jiraMe.key ?? '?'}${jiraMe.displayName ? ` "${jiraMe.displayName}"` : ''}` : ''}`);
67
+ lines.push(`- Bitbucket: ${bitbucket ? config.bitbucket.url : (config.bitbucket ? `${config.bitbucket.url} — DISABLED for this cwd (remote does not match)` : '(not configured)')}${bbMe ? ` — you are ${bbMe}` : ''}`);
68
+ lines.push('');
69
+ if (isGitRepo) {
70
+ lines.push('## Current repo');
71
+ lines.push(`- Branch: ${branch} (may have changed since startup — re-run \`get_dev_context\` to refresh)`);
72
+ lines.push(`- Remote: ${_remote || '(none)'}`);
73
+ if (parsed)
74
+ lines.push(`- Bitbucket repo: ${parsed.projectKey}/${parsed.repoSlug}`);
75
+ if (jiraKeys.length)
76
+ lines.push(`- Jira keys in branch: ${jiraKeys.join(', ')}`);
77
+ }
78
+ else {
79
+ lines.push('## Current repo');
80
+ lines.push('- Not a git repository.');
81
+ }
82
+ if (committers.length) {
83
+ lines.push('');
84
+ lines.push('## Recent committers in this repo (last 50 commits)');
85
+ for (const c of committers) {
86
+ const ident = c.email ? `${c.name} <${c.email}>` : c.name;
87
+ lines.push(`- ${c.commits}× ${ident}`);
88
+ }
89
+ }
90
+ lines.push('');
91
+ lines.push('## Use these tools — do NOT shell out');
92
+ lines.push('- "What am I working on / what\'s the status / show me the context" → call `get_dev_context` first. It returns branch state, linked Jira tickets, the open PR, and reviewer status in one shot.');
93
+ lines.push('- Looking up a person\'s username (for reviewers, assignees, mentions) → ALWAYS use `bitbucket_search resource=users` or `jira_search resource=users`. NEVER use `git log`/`git shortlog`/`gh api`/`bb`/any bitbucket CLI to discover who someone is — those return commit-author strings, not Bitbucket/Jira usernames, and the wrong identifier breaks reviewer assignment.');
94
+ lines.push('- Reading a Jira ticket → `jira_get` (single) or `jira_search` (many). Mutating → `jira_mutate`.');
95
+ lines.push('- Reading a PR → `bitbucket_get_pr`. Creating/updating/merging → `bitbucket_mutate`. Commenting → `bitbucket_comment`.');
96
+ lines.push('- Starting work on a ticket (branch + status transition + README) → `start_work`. Closing it (merge + transition) → `complete_work`.');
97
+ return lines.join('\n');
98
+ }
99
+ const _instructions = await buildInstructions();
100
+ const server = new Server({ name: 'atlassian-mcp', version: _pkg.version }, { capabilities: { tools: {} }, instructions: _instructions });
43
101
  server.onerror = (error) => console.error('[MCP Error]', error);
44
102
  function normalizeBitbucketArgs(args) {
45
103
  const src = (args && typeof args === 'object') ? args : {};
@@ -170,7 +228,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
170
228
  },
171
229
  {
172
230
  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.',
231
+ 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
232
  inputSchema: {
175
233
  type: 'object',
176
234
  properties: {
@@ -246,6 +304,20 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
246
304
  },
247
305
  },
248
306
  },
307
+ {
308
+ name: 'jira_get_attachment',
309
+ 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.',
310
+ inputSchema: {
311
+ type: 'object',
312
+ properties: {
313
+ attachmentId: { type: 'string', description: 'Numeric attachment ID from jira_get output' },
314
+ saveTo: { type: 'string', description: 'Optional absolute path to save the original (un-resized) file to disk instead of returning inline' },
315
+ maxDimension: { type: 'number', description: 'Max long-edge size in pixels for inline images (default 1568). Larger images are downscaled with sharp.' },
316
+ quality: { type: 'number', description: 'JPEG quality for re-encoded inline images (1-100, default 85). Ignored for images with alpha (encoded as PNG).' },
317
+ },
318
+ required: ['attachmentId'],
319
+ },
320
+ },
249
321
  {
250
322
  name: 'jira_comment',
251
323
  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 +358,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
286
358
  },
287
359
  {
288
360
  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.',
361
+ 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
362
  inputSchema: {
291
363
  type: 'object',
292
364
  properties: {
@@ -393,6 +465,24 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
393
465
  required: ['path'],
394
466
  },
395
467
  },
468
+ {
469
+ name: 'bitbucket_get_attachment',
470
+ 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.',
471
+ inputSchema: {
472
+ type: 'object',
473
+ properties: {
474
+ projectKey: { type: 'string', description: 'Bitbucket project code (usually auto-detected)' },
475
+ project: { type: 'string', description: 'Alias for projectKey' },
476
+ repoSlug: { type: 'string', description: 'Repository slug (usually auto-detected)' },
477
+ repo: { type: 'string', description: 'Alias for repoSlug' },
478
+ attachmentId: { type: 'string', description: 'Numeric attachment ID' },
479
+ saveTo: { type: 'string', description: 'Optional absolute path to save the original (un-resized) file to disk instead of returning inline' },
480
+ maxDimension: { type: 'number', description: 'Max long-edge size in pixels for inline images (default 1568). Larger images are downscaled with sharp.' },
481
+ quality: { type: 'number', description: 'JPEG quality for re-encoded inline images (1-100, default 85). Ignored for images with alpha (encoded as PNG).' },
482
+ },
483
+ required: ['attachmentId'],
484
+ },
485
+ },
396
486
  {
397
487
  name: 'bitbucket_pr_tasks',
398
488
  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 +771,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
681
771
  if (!jira)
682
772
  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
683
773
  return await jira.mutateIssue(normalizeJiraMutateArgs(args));
774
+ case 'jira_get_attachment': {
775
+ if (!jira)
776
+ throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
777
+ const a = args;
778
+ return await jira.getAttachment(a);
779
+ }
684
780
  case 'jira_comment': {
685
781
  if (!jira)
686
782
  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
@@ -779,6 +875,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
779
875
  if (!bitbucket)
780
876
  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
781
877
  return await bitbucket.getFile(normalizeBitbucketArgs(args));
878
+ case 'bitbucket_get_attachment': {
879
+ if (!bitbucket)
880
+ throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
881
+ return await bitbucket.getAttachment(normalizeBitbucketArgs(args));
882
+ }
782
883
  case 'bitbucket_pr_tasks': {
783
884
  if (!bitbucket)
784
885
  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) {
@@ -148,6 +149,9 @@ export class JiraClient {
148
149
  this.currentUserCache = me;
149
150
  return me;
150
151
  }
152
+ async whoami() {
153
+ return this.getCurrentUser();
154
+ }
151
155
  async getIssueLinkingEnabled() {
152
156
  if (this.issueLinkingEnabled !== undefined)
153
157
  return this.issueLinkingEnabled;
@@ -397,7 +401,7 @@ export class JiraClient {
397
401
  const includeSprint = args.includeSprint ?? true;
398
402
  const commentsMaxResults = args.commentsMaxResults ?? 10;
399
403
  const commentsStartAt = args.commentsStartAt ?? 0;
400
- const fields = 'summary,description,status,assignee,priority,issuetype,labels,components,parent,fixVersions,issuelinks,subtasks';
404
+ const fields = 'summary,description,status,assignee,priority,issuetype,labels,components,parent,fixVersions,issuelinks,subtasks,attachment';
401
405
  const issue = await this.request('GET', `/issue/${encodeURIComponent(args.issueKey)}?fields=${fields}`);
402
406
  if (!issue)
403
407
  return text('Issue not found.');
@@ -451,6 +455,14 @@ export class JiraClient {
451
455
  lines.push(`Transitions: ${names.length > 0 ? names.join(', ') : '(none)'}`);
452
456
  }
453
457
  lines.push('', 'Description:', f.description ?? '(no description)');
458
+ if (f.attachment?.length) {
459
+ lines.push('', `Attachments: ${f.attachment.length}`);
460
+ for (const att of f.attachment) {
461
+ const mt = att.mimeType ?? 'application/octet-stream';
462
+ lines.push(` #${att.id} ${att.filename} (${mt}, ${formatBytes(att.size)})`);
463
+ }
464
+ lines.push('Use jira_get_attachment with attachmentId to view contents.');
465
+ }
454
466
  if (includeComments) {
455
467
  const comments = await this.request('GET', `/issue/${encodeURIComponent(args.issueKey)}/comment?startAt=${commentsStartAt}&maxResults=${commentsMaxResults}`);
456
468
  const items = comments?.comments ?? [];
@@ -717,6 +729,33 @@ export class JiraClient {
717
729
  const page = data.isLast ? '' : ` (use startAt=${(args.startAt ?? 0) + data.values.length} for next page)`;
718
730
  return text(`${data.values.length} board(s)${page}:\n${lines.join('\n')}`);
719
731
  }
732
+ async getAttachment(args) {
733
+ const id = String(args.attachmentId ?? '').trim();
734
+ if (!id)
735
+ throw new Error('attachmentId is required.');
736
+ const meta = await this.request('GET', `/attachment/${encodeURIComponent(id)}`);
737
+ if (!meta)
738
+ throw new Error(`Attachment ${id} not found.`);
739
+ const res = await fetch(meta.content, {
740
+ method: 'GET',
741
+ headers: { Authorization: this.headers.Authorization },
742
+ signal: AbortSignal.timeout(60_000),
743
+ });
744
+ if (!res.ok) {
745
+ const errText = await res.text();
746
+ throw new Error(formatJiraError(res.status, 'GET', meta.content, parseJiraErrorDetails(errText)));
747
+ }
748
+ const buffer = Buffer.from(await res.arrayBuffer());
749
+ return buildAttachmentResult({
750
+ id,
751
+ filename: meta.filename,
752
+ mimeType: meta.mimeType ?? 'application/octet-stream',
753
+ buffer,
754
+ saveTo: args.saveTo,
755
+ maxDimension: args.maxDimension,
756
+ quality: args.quality,
757
+ });
758
+ }
720
759
  async transitionIssue(args) {
721
760
  const transitionId = await this.resolveTransitionId(args.issueKey, args.transitionId, args.transitionName);
722
761
  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.1",
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",