@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 +8 -2
- package/dist/attachment.js +109 -0
- package/dist/bitbucket.js +93 -0
- package/dist/index.js +184 -15
- package/dist/jira.js +53 -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();
|
|
@@ -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:
|
|
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
|
|
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: {
|
|
@@ -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
|
-
|
|
466
|
-
|
|
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: ${
|
|
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: ${
|
|
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
|
|
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
|
-
|
|
560
|
-
|
|
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.
|
|
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",
|