@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 +8 -2
- package/dist/attachment.js +109 -0
- package/dist/bitbucket.js +68 -0
- package/dist/context.js +53 -9
- package/dist/index.js +105 -4
- package/dist/jira.js +40 -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();
|
|
@@ -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
|
-
|
|
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
|
-
'
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
'
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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",
|