@stubbedev/atlassian-mcp 0.2.11 → 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();
@@ -371,6 +392,24 @@ export class BitbucketClient {
371
392
  });
372
393
  return text(`${data.values.length} user(s)${pageHint(data)}:\n${lines.join('\n')}`);
373
394
  }
395
+ async searchUsersRaw(args) {
396
+ const params = new URLSearchParams();
397
+ if (args.query)
398
+ params.set('filter', args.query);
399
+ params.set('limit', String(args.limit ?? 50));
400
+ let path;
401
+ if (args.projectKey && args.repoSlug) {
402
+ path = `${this.rp(args.projectKey, args.repoSlug)}/permissions/users?${params}`;
403
+ }
404
+ else if (args.projectKey) {
405
+ path = `/projects/${encodeURIComponent(args.projectKey)}/permissions/users?${params}`;
406
+ }
407
+ else {
408
+ path = `/users?${params}`;
409
+ }
410
+ const data = await this.request('GET', path);
411
+ return (data?.values ?? []).map((entry) => entry.user ?? entry);
412
+ }
374
413
  async listPullRequests(args) {
375
414
  const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
376
415
  const { state = 'OPEN', fromBranch, text: searchText, limit = 25, start = 0 } = args;
@@ -448,6 +487,8 @@ export class BitbucketClient {
448
487
  if (!pr)
449
488
  return text('Pull request not found.');
450
489
  const sections = [];
490
+ const attachmentRefs = new Map();
491
+ collectAttachmentRefs(pr.description, 'description', attachmentRefs);
451
492
  const reviewers = pr.reviewers.map((r) => `${r.user.displayName}${r.approved ? ' ✓' : ''}`).join(', ');
452
493
  const url = pr.links?.self?.[0]?.href;
453
494
  const header = [
@@ -503,6 +544,8 @@ export class BitbucketClient {
503
544
  sections.push(`Comments:\n(no ${commentsState} BLOCKER comments)`);
504
545
  }
505
546
  else {
547
+ for (const comment of data.values)
548
+ collectFromCommentTree(comment, attachmentRefs);
506
549
  const blocks = data.values.flatMap((comment) => formatCommentThread(comment));
507
550
  sections.push(`Comments (${data.values.length} BLOCKER thread(s))${pageHint(data)}:\n\n${blocks.join('\n\n')}`);
508
551
  }
@@ -513,6 +556,8 @@ export class BitbucketClient {
513
556
  const matchesState = commentMatchesState(comment, commentsState);
514
557
  return matchesState && commentMatchesSeverity(comment, commentsSeverity);
515
558
  });
559
+ for (const comment of comments)
560
+ collectFromCommentTree(comment, attachmentRefs);
516
561
  if (comments.length === 0) {
517
562
  sections.push('Comments:\n(no matching comments)');
518
563
  }
@@ -523,6 +568,14 @@ export class BitbucketClient {
523
568
  }
524
569
  }
525
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
+ }
526
579
  if (includeDiff) {
527
580
  const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${prId}/diff`);
528
581
  sections.push(`Diff:\n${data ? formatDiff(data, args.diffMaxChars ?? 8000) : '(no diff found)'}`);
@@ -761,6 +814,46 @@ export class BitbucketClient {
761
814
  }
762
815
  return text(content);
763
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
+ }
847
+ async fetchFileText(projectKey, repoSlug, filePath) {
848
+ try {
849
+ const encoded = filePath.split('/').map(encodeURIComponent).join('/');
850
+ const content = await this.requestText(`${this.rp(projectKey, repoSlug)}/raw/${encoded}`);
851
+ return content;
852
+ }
853
+ catch {
854
+ return null;
855
+ }
856
+ }
764
857
  async getPrComments(args) {
765
858
  const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
766
859
  const limit = args.limit ?? 50;
package/dist/index.js CHANGED
@@ -132,18 +132,18 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
132
132
  ...(jira ? [
133
133
  {
134
134
  name: 'start_work',
135
- description: 'Start working on a Jira ticket: fetches the ticket, creates a local git branch with an auto-generated name (e.g. feature/FOO-123-add-payment-gateway), and optionally transitions the ticket. Use when told "make a branch for FOO-123", "start working on this ticket", "check out a branch for this issue", or "begin work on FOO-123".',
135
+ description: 'Start working on a Jira ticket end-to-end: resolves the ticket (by key or free-text search with a picker when multiple match), creates a local branch with an auto-generated name, fetches the project README from Bitbucket so you have commit/PR conventions in context, and prints a ready-to-use next-steps summary. Use when told "make a branch for FOO-123", "start working on this ticket", "I want to work on the login bug", or "begin work on the payment gateway story". If issueKey is omitted, provide query for free-text search.',
136
136
  inputSchema: {
137
137
  type: 'object',
138
138
  properties: {
139
- issueKey: { type: 'string', description: 'Jira issue key, e.g. FOO-123' },
139
+ issueKey: { type: 'string', description: 'Jira issue key, e.g. FOO-123 (provide this OR query)' },
140
+ query: { type: 'string', description: 'Free-text search when issueKey is unknown — shows a picker if multiple tickets match' },
140
141
  repoPath: { type: 'string', description: 'Local repo path (defaults to cwd)' },
141
142
  baseBranch: { type: 'string', description: 'Branch to base off (default: master)' },
142
143
  branchName: { type: 'string', description: 'Override the generated branch name' },
143
144
  transitionName: { type: 'string', description: 'Jira transition to apply, e.g. "In Progress" (optional)' },
144
145
  push: { type: 'boolean', description: 'Push branch to remote after creation (default false)', default: false },
145
146
  },
146
- required: ['issueKey'],
147
147
  },
148
148
  },
149
149
  {
@@ -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: {
@@ -332,6 +346,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
332
346
  fromBranch: { type: 'string', description: 'Source branch (defaults to current branch)' },
333
347
  toBranch: { type: 'string', description: 'Target branch (default: master)' },
334
348
  reviewers: { type: 'array', items: { type: 'string' }, description: 'Reviewer usernames. Use bitbucket_search resource=users to look up valid usernames before setting this.' },
349
+ pickReviewers: { type: 'boolean', description: 'Show an interactive reviewer picker before creating the PR (lists users with repo access)' },
335
350
  },
336
351
  required: ['title'],
337
352
  },
@@ -392,6 +407,24 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
392
407
  required: ['path'],
393
408
  },
394
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
+ },
395
428
  {
396
429
  name: 'bitbucket_pr_tasks',
397
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.',
@@ -462,8 +495,69 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
462
495
  if (!jira)
463
496
  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
464
497
  const a = args;
465
- const fields = await jira.getIssueFields(a.issueKey);
466
- const branchName = a.branchName ?? slugifyBranchName(a.issueKey, fields.summary, fields.type);
498
+ if (!a.issueKey && !a.query) {
499
+ throw new Error('Provide issueKey (e.g. FOO-123) or query (free-text search).');
500
+ }
501
+ // Resolve issueKey from free-text query if not provided directly
502
+ let issueKey = a.issueKey;
503
+ if (!issueKey && a.query) {
504
+ const candidates = await jira.findIssues(a.query, 10);
505
+ if (candidates.length === 0) {
506
+ return { content: [{ type: 'text', text: `No Jira tickets found for: "${a.query}"` }] };
507
+ }
508
+ if (candidates.length === 1) {
509
+ issueKey = candidates[0].key;
510
+ }
511
+ else {
512
+ // Multiple matches — present a picker
513
+ const pickerMessage = [
514
+ `Found ${candidates.length} tickets matching "${a.query}". Which one do you want to work on?`,
515
+ ...candidates.map((c, i) => `${i + 1}. [${c.key}] ${c.summary} (${c.status})`),
516
+ ].join('\n');
517
+ try {
518
+ const pickerResult = await server.elicitInput({
519
+ message: pickerMessage,
520
+ requestedSchema: {
521
+ type: 'object',
522
+ properties: {
523
+ ticket: {
524
+ type: 'string',
525
+ title: 'Select a ticket',
526
+ oneOf: [
527
+ ...candidates.map((c) => ({ const: c.key, title: `[${c.key}] ${c.summary}` })),
528
+ { const: '__cancel__', title: 'Cancel' },
529
+ ],
530
+ },
531
+ },
532
+ required: ['ticket'],
533
+ },
534
+ });
535
+ if (pickerResult.action === 'cancel' ||
536
+ pickerResult.action === 'decline' ||
537
+ pickerResult.content?.ticket === '__cancel__') {
538
+ return { content: [{ type: 'text', text: 'Cancelled.' }] };
539
+ }
540
+ issueKey = pickerResult.content?.ticket;
541
+ if (!issueKey || issueKey === '__cancel__') {
542
+ return { content: [{ type: 'text', text: 'Cancelled.' }] };
543
+ }
544
+ }
545
+ catch {
546
+ // Client doesn't support elicitation — list options and ask caller to retry with issueKey
547
+ const list = candidates.map((c) => ` • ${c.key} — ${c.summary} (${c.status})`).join('\n');
548
+ return {
549
+ content: [{
550
+ type: 'text',
551
+ text: `Found ${candidates.length} tickets matching "${a.query}":\n${list}\n\nRe-run start_work with the desired issueKey.`,
552
+ }],
553
+ };
554
+ }
555
+ }
556
+ }
557
+ if (!issueKey)
558
+ throw new Error('Could not resolve issue key.');
559
+ const fields = await jira.getIssueFields(issueKey);
560
+ const branchName = a.branchName ?? slugifyBranchName(issueKey, fields.summary, fields.type);
467
561
  const repoPath = a.repoPath ?? process.cwd();
468
562
  // Check if branch already exists on remote before creating
469
563
  const remote = checkRemoteBranch(branchName, repoPath);
@@ -475,7 +569,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
475
569
  const contextLines = [authorLine, commitLine].filter(Boolean).join('\n');
476
570
  const message = [
477
571
  `Branch "${branchName}" already exists on remote.`,
478
- `Ticket: ${a.issueKey} — ${fields.summary}`,
572
+ `Ticket: ${issueKey} — ${fields.summary}`,
479
573
  contextLines,
480
574
  ].filter(Boolean).join('\n');
481
575
  try {
@@ -509,7 +603,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
509
603
  if (chosen === 'cancel') {
510
604
  return { content: [{ type: 'text', text: 'Cancelled.' }] };
511
605
  }
512
- // new_name — instruct the model to re-run with a custom name
513
606
  return {
514
607
  content: [{
515
608
  type: 'text',
@@ -517,11 +610,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
517
610
  }],
518
611
  };
519
612
  }
520
- // Fallback: unknown action
521
613
  return { content: [{ type: 'text', text: 'Cancelled.' }] };
522
614
  }
523
615
  catch {
524
- // Client doesn't support elicitation — fall back to informational text
525
616
  return {
526
617
  content: [{
527
618
  type: 'text',
@@ -543,21 +634,52 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
543
634
  push: a.push ?? false,
544
635
  });
545
636
  const lines = [
546
- `Ticket: ${a.issueKey} — ${fields.summary}`,
637
+ `Ticket: ${issueKey} — ${fields.summary}`,
547
638
  `Status: ${fields.status}`,
548
639
  branchResult.content[0].text,
549
640
  ];
550
641
  if (a.transitionName) {
551
642
  try {
552
- await jira.mutateIssue({ issueKey: a.issueKey, transitionName: a.transitionName });
643
+ await jira.mutateIssue({ issueKey, transitionName: a.transitionName });
553
644
  lines.push(`Jira: transitioned → ${a.transitionName}`);
554
645
  }
555
646
  catch (err) {
556
647
  lines.push(`Jira: could not transition — ${err.message}`);
557
648
  }
558
649
  }
559
- if (bitbucket)
560
- lines.push(``, `Next: push commits then use bitbucket_mutate to open a PR.`);
650
+ // Fetch README from Bitbucket for project conventions
651
+ if (bitbucket) {
652
+ try {
653
+ const remoteUrl = (() => {
654
+ try {
655
+ return execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: repoPath, encoding: 'utf-8' }).trim();
656
+ }
657
+ catch {
658
+ return '';
659
+ }
660
+ })();
661
+ const parsed = parseBitbucketRemote(remoteUrl);
662
+ if (parsed) {
663
+ const readme = await bitbucket.fetchFileText(parsed.projectKey, parsed.repoSlug, 'README.md');
664
+ if (readme) {
665
+ const maxLen = 4000;
666
+ const truncated = readme.length > maxLen ? readme.slice(0, maxLen) + '\n... (truncated)' : readme;
667
+ lines.push('');
668
+ lines.push('Project conventions (from README.md):');
669
+ lines.push('────────────────────────────────────');
670
+ lines.push(truncated);
671
+ lines.push('────────────────────────────────────');
672
+ lines.push('Follow the conventions above when writing commit messages and the PR description.');
673
+ }
674
+ }
675
+ }
676
+ catch { /* README fetch is best-effort */ }
677
+ lines.push('');
678
+ lines.push('Next steps:');
679
+ lines.push(' 1. Make your changes and commit following the project conventions.');
680
+ lines.push(' 2. Use bitbucket_mutate (create) to open a PR — the Jira summary and ticket key make a good title/description starting point.');
681
+ lines.push(' 3. Add reviewers: bitbucket_search resource=users to find colleagues, or pass pickReviewers=true in create to get an interactive picker.');
682
+ }
561
683
  return { content: [{ type: 'text', text: lines.join('\n') }] };
562
684
  }
563
685
  // Jira
@@ -591,6 +713,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
591
713
  if (!jira)
592
714
  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
593
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
+ }
594
722
  case 'jira_comment': {
595
723
  if (!jira)
596
724
  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
@@ -636,6 +764,42 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
636
764
  return await bitbucket.declinePr({ ...a, message: a.declineMessage });
637
765
  if (action === 'merge')
638
766
  return await bitbucket.mergePr({ ...a, message: a.mergeMessage, mergeStrategy: a.mergeStrategy });
767
+ // Handle interactive reviewer picker for PR creation
768
+ const createArgs = a.create;
769
+ if (createArgs?.pickReviewers && !a.prId) {
770
+ const projectKey = a.projectKey;
771
+ const repoSlug = a.repoSlug;
772
+ const users = await bitbucket.searchUsersRaw({ projectKey, repoSlug, limit: 30 });
773
+ if (users.length > 0) {
774
+ // Build boolean checkbox schema — one field per user
775
+ // key must be safe for JSON schema property names
776
+ const toSchemaKey = (uname) => uname.replace(/[^a-zA-Z0-9]/g, '_');
777
+ const userMap = new Map(); // schemaKey -> username
778
+ const properties = {};
779
+ for (const u of users) {
780
+ const key = toSchemaKey(u.name);
781
+ userMap.set(key, u.name);
782
+ properties[key] = { type: 'boolean', title: `${u.displayName} (${u.name})` };
783
+ }
784
+ try {
785
+ const pickerResult = await server.elicitInput({
786
+ message: 'Select reviewers to add to this PR:',
787
+ requestedSchema: { type: 'object', properties },
788
+ });
789
+ if (pickerResult.action === 'accept' && pickerResult.content) {
790
+ const selected = [];
791
+ for (const [key, username] of userMap) {
792
+ if (pickerResult.content[key] === true)
793
+ selected.push(username);
794
+ }
795
+ createArgs.reviewers = selected;
796
+ }
797
+ }
798
+ catch {
799
+ // elicitation not supported — proceed without reviewer picker
800
+ }
801
+ }
802
+ }
639
803
  return await bitbucket.mutatePullRequest(a);
640
804
  }
641
805
  case 'bitbucket_comment': {
@@ -653,6 +817,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
653
817
  if (!bitbucket)
654
818
  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
655
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
+ }
656
825
  case 'bitbucket_pr_tasks': {
657
826
  if (!bitbucket)
658
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) {
@@ -285,6 +286,22 @@ export class JiraClient {
285
286
  const page = pagination(data.total, startAt, data.issues.length);
286
287
  return text(`Found ${data.total} issues${page}:\n${lines.join('\n')}`);
287
288
  }
289
+ async findIssues(query, maxResults = 10) {
290
+ const jql = buildJQL({ query });
291
+ const params = new URLSearchParams({
292
+ jql,
293
+ maxResults: String(maxResults),
294
+ startAt: '0',
295
+ fields: 'summary,status,issuetype',
296
+ });
297
+ const data = await this.request('GET', `/search?${params}`);
298
+ return (data?.issues ?? []).map((i) => ({
299
+ key: i.key,
300
+ summary: i.fields.summary,
301
+ status: i.fields.status.name,
302
+ type: i.fields.issuetype.name,
303
+ }));
304
+ }
288
305
  async myIssues(args) {
289
306
  return this.searchIssues({
290
307
  jql: 'assignee = currentUser() ORDER BY updated DESC',
@@ -381,7 +398,7 @@ export class JiraClient {
381
398
  const includeSprint = args.includeSprint ?? true;
382
399
  const commentsMaxResults = args.commentsMaxResults ?? 10;
383
400
  const commentsStartAt = args.commentsStartAt ?? 0;
384
- 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';
385
402
  const issue = await this.request('GET', `/issue/${encodeURIComponent(args.issueKey)}?fields=${fields}`);
386
403
  if (!issue)
387
404
  return text('Issue not found.');
@@ -435,6 +452,14 @@ export class JiraClient {
435
452
  lines.push(`Transitions: ${names.length > 0 ? names.join(', ') : '(none)'}`);
436
453
  }
437
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
+ }
438
463
  if (includeComments) {
439
464
  const comments = await this.request('GET', `/issue/${encodeURIComponent(args.issueKey)}/comment?startAt=${commentsStartAt}&maxResults=${commentsMaxResults}`);
440
465
  const items = comments?.comments ?? [];
@@ -701,6 +726,33 @@ export class JiraClient {
701
726
  const page = data.isLast ? '' : ` (use startAt=${(args.startAt ?? 0) + data.values.length} for next page)`;
702
727
  return text(`${data.values.length} board(s)${page}:\n${lines.join('\n')}`);
703
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
+ }
704
756
  async transitionIssue(args) {
705
757
  const transitionId = await this.resolveTransitionId(args.issueKey, args.transitionId, args.transitionName);
706
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.11",
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",