@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 +8 -2
- package/dist/attachment.js +109 -0
- package/dist/bitbucket.js +65 -0
- package/dist/index.js +45 -2
- package/dist/jira.js +37 -1
- package/package.json +3 -2
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
|
|
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,
|
|
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
|
|
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
|
|
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.
|
|
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",
|