@stubbedev/atlassian-mcp 0.3.10 → 0.4.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 +31 -4
- package/dist/attachment.js +251 -10
- package/dist/bitbucket.js +34 -4
- package/dist/index.js +48 -9
- package/dist/jira.js +61 -5
- package/dist/video.js +211 -0
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ A [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server for **s
|
|
|
29
29
|
|---|---|
|
|
30
30
|
| `jira_search` | Discover resources: `issues`, `projects`, `issue_types`, `boards`, `sprints`, `board_overview`, `versions`, or `users` via `resource` param |
|
|
31
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
|
|
32
|
+
| `jira_get_attachment` | Fetch a Jira attachment by ID. Images, videos, animated images (GIF/APNG/animated WebP), audio, and PDFs are all decoded inline so the model can see/hear them. Text/JSON inline. Oversized or non-renderable attachments are auto-saved to a temp file and the path is returned. `saveTo=/absolute/path` streams the original to disk |
|
|
33
33
|
| `jira_mutate` | Create, update, transition, comment, link, add to sprint, or log work — all in one call |
|
|
34
34
|
| `jira_comment` | Add, update, or delete a comment on an issue (`action`: `add` / `update` / `delete`) |
|
|
35
35
|
| `jira_version` | Manage fix versions/releases (`action`: `create` / `update` / `release` / `archive` / `delete`) |
|
|
@@ -40,7 +40,7 @@ A [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server for **s
|
|
|
40
40
|
|---|---|
|
|
41
41
|
| `bitbucket_search` | Discover resources: `pull_requests` (default), `repos`, or `branches` via `resource` param; `mine=true` for your inbox |
|
|
42
42
|
| `bitbucket_get_pr` | Full PR details: metadata, commits, comments, blockers, build status, optional diff, and any attachments referenced from the description or comments |
|
|
43
|
-
| `bitbucket_get_attachment` | Fetch a repo attachment by ID (images auto-
|
|
43
|
+
| `bitbucket_get_attachment` | Fetch a repo attachment by ID. Same decoding pipeline as `jira_get_attachment` (images, videos, animated images, audio, PDFs). Oversized or non-renderable attachments are auto-saved to a temp file and the path is returned; `saveTo` streams the original to disk |
|
|
44
44
|
| `bitbucket_mutate` | Create/update a PR, or perform lifecycle actions: `approve`, `unapprove`, `merge`, `decline` |
|
|
45
45
|
| `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) |
|
|
46
46
|
| `bitbucket_get_file` | Raw file content from Bitbucket at a branch, tag, or commit |
|
|
@@ -248,9 +248,36 @@ npm install
|
|
|
248
248
|
|
|
249
249
|
Then use `node /path/to/atlassian-mcp/dist/index.js` instead of the `npx` command in the configs above.
|
|
250
250
|
|
|
251
|
-
###
|
|
251
|
+
### Attachment decoding pipeline
|
|
252
252
|
|
|
253
|
-
|
|
253
|
+
The attachment tools (`jira_get_attachment`, `bitbucket_get_attachment`) decode binary attachments into model-readable content before returning them:
|
|
254
|
+
|
|
255
|
+
| Input | What gets returned | How |
|
|
256
|
+
| --- | --- | --- |
|
|
257
|
+
| Static images (PNG/JPEG/WebP/AVIF/SVG…) | Resized image content blocks | `sharp` (long edge ≤ `maxDimension`, default 1568) |
|
|
258
|
+
| Animated images (GIF/APNG/animated WebP) | N sampled frames as image content blocks | `ffmpeg-static` + `sharp` (default 6 frames @ 768 px) |
|
|
259
|
+
| Video (mp4/webm/mov/…) | N sampled frames as image content blocks | `ffmpeg-static` + `sharp`. Uniform or scene-change sampling. Re-call with `start`, `end`, `frames`, `mode`, `sceneThreshold` to zoom in |
|
|
260
|
+
| Audio (mp3/wav/ogg/…) | MCP audio content block | passthrough |
|
|
261
|
+
| PDFs | Extracted text — or rasterized pages if text is empty (scanned PDFs) | `unpdf` + `@napi-rs/canvas` |
|
|
262
|
+
| Text-like (json/xml/yaml/…) | Text content block | passthrough |
|
|
263
|
+
| Everything else (or oversized) | Auto-saved to a temp file; path is returned | `os.tmpdir()` with `atlmcp-` prefix |
|
|
264
|
+
|
|
265
|
+
Auto-saved files are periodically pruned by TTL and total-size quota — see *Environment overrides* below.
|
|
266
|
+
|
|
267
|
+
### Native dependencies
|
|
268
|
+
|
|
269
|
+
- [`sharp`](https://sharp.pixelplumbing.com/) — image decode/resize. Ships prebuilt binaries for glibc Linux (x64/arm64), macOS, Windows. Alpine / musl users may need `npm install --cpu=x64 --os=linux --libc=musl sharp`.
|
|
270
|
+
- [`ffmpeg-static`](https://www.npmjs.com/package/ffmpeg-static) + [`ffprobe-static`](https://www.npmjs.com/package/ffprobe-static) — video/audio decode. ~80 MB bundled binary per platform. Override with env vars (below) if you have system ffmpeg.
|
|
271
|
+
- [`@napi-rs/canvas`](https://www.npmjs.com/package/@napi-rs/canvas) + [`unpdf`](https://www.npmjs.com/package/unpdf) — PDF text extraction and page rasterization.
|
|
272
|
+
|
|
273
|
+
### Environment overrides
|
|
274
|
+
|
|
275
|
+
| Variable | Purpose | Default |
|
|
276
|
+
| --- | --- | --- |
|
|
277
|
+
| `ATLASSIAN_MCP_FFMPEG_PATH` | Path to `ffmpeg` binary. Overrides `ffmpeg-static`. Use this if you have system ffmpeg or `ffmpeg-static` doesn't ship for your platform (Alpine/musl, some ARM variants). | bundled `ffmpeg-static` |
|
|
278
|
+
| `ATLASSIAN_MCP_FFPROBE_PATH` | Path to `ffprobe` binary. Overrides `ffprobe-static`. | bundled `ffprobe-static` |
|
|
279
|
+
| `ATLASSIAN_MCP_TMP_TTL_DAYS` | Auto-saved attachments older than this are pruned. | `7` |
|
|
280
|
+
| `ATLASSIAN_MCP_TMP_MAX_BYTES` | Total-size quota for auto-saved attachments in `os.tmpdir()`. When exceeded, oldest are evicted. | `1073741824` (1 GB) |
|
|
254
281
|
|
|
255
282
|
---
|
|
256
283
|
|
package/dist/attachment.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import sharp from 'sharp';
|
|
2
|
-
import { writeFile } from 'fs/promises';
|
|
3
|
-
import { resolve as resolvePath } from 'path';
|
|
2
|
+
import { writeFile, readdir, stat, rm } from 'fs/promises';
|
|
3
|
+
import { resolve as resolvePath, join as joinPath } from 'path';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { processVideo, DEFAULT_VIDEO_FRAMES, DEFAULT_VIDEO_MAX_DIMENSION, DEFAULT_VIDEO_QUALITY, MAX_VIDEO_SOURCE_BYTES, } from './video.js';
|
|
4
6
|
export const MAX_INLINE_BYTES = 10 * 1024 * 1024;
|
|
5
7
|
export const DEFAULT_MAX_DIMENSION = 1568;
|
|
6
8
|
export const DEFAULT_JPEG_QUALITY = 85;
|
|
@@ -26,7 +28,6 @@ export function isTextMime(mimeType) {
|
|
|
26
28
|
].some((m) => mt === m || mt.startsWith(`${m};`));
|
|
27
29
|
}
|
|
28
30
|
async function processImage(buffer, mimeType, opts) {
|
|
29
|
-
// SVG: pass through. Sharp can rasterize but the LLM benefits more from the source markup.
|
|
30
31
|
if (mimeType.toLowerCase() === 'image/svg+xml') {
|
|
31
32
|
return { data: buffer, mimeType, resized: false };
|
|
32
33
|
}
|
|
@@ -53,6 +54,138 @@ async function processImage(buffer, mimeType, opts) {
|
|
|
53
54
|
const data = await pipeline.jpeg({ quality: opts.quality, mozjpeg: true }).toBuffer();
|
|
54
55
|
return { data, mimeType: 'image/jpeg', resized: needsResize };
|
|
55
56
|
}
|
|
57
|
+
function sanitizeFilename(name) {
|
|
58
|
+
return name.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 80) || 'attachment';
|
|
59
|
+
}
|
|
60
|
+
// --- Auto-saved temp file retention --------------------------------------
|
|
61
|
+
// Auto-saved attachments accumulate in os.tmpdir() with prefix `atlmcp-`.
|
|
62
|
+
// We prune on each save (with a 1h cooldown) using two policies:
|
|
63
|
+
// 1. TTL: delete files whose mtime is older than ATLASSIAN_MCP_TMP_TTL_DAYS.
|
|
64
|
+
// 2. Quota: if total size of remaining `atlmcp-*` files exceeds
|
|
65
|
+
// ATLASSIAN_MCP_TMP_MAX_BYTES, evict oldest-mtime-first until under cap.
|
|
66
|
+
// Both knobs default to sane values if unset. Cleanup runs *before* the new
|
|
67
|
+
// write so we never delete the file we're about to create.
|
|
68
|
+
const TMP_PREFIX = 'atlmcp-';
|
|
69
|
+
const TMP_PRUNE_COOLDOWN_MS = 60 * 60 * 1000;
|
|
70
|
+
const tmpTtlMs = (() => {
|
|
71
|
+
const days = parseFloat(process.env.ATLASSIAN_MCP_TMP_TTL_DAYS ?? '');
|
|
72
|
+
return (Number.isFinite(days) && days > 0 ? days : 7) * 24 * 60 * 60 * 1000;
|
|
73
|
+
})();
|
|
74
|
+
const tmpMaxBytes = (() => {
|
|
75
|
+
const v = parseInt(process.env.ATLASSIAN_MCP_TMP_MAX_BYTES ?? '', 10);
|
|
76
|
+
return Number.isFinite(v) && v > 0 ? v : 1024 * 1024 * 1024;
|
|
77
|
+
})();
|
|
78
|
+
let lastPruneAt = 0;
|
|
79
|
+
async function pruneTmpFiles() {
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
if (now - lastPruneAt < TMP_PRUNE_COOLDOWN_MS)
|
|
82
|
+
return;
|
|
83
|
+
lastPruneAt = now;
|
|
84
|
+
const dir = tmpdir();
|
|
85
|
+
let entries;
|
|
86
|
+
try {
|
|
87
|
+
entries = await readdir(dir);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const survivors = [];
|
|
93
|
+
for (const name of entries) {
|
|
94
|
+
if (!name.startsWith(TMP_PREFIX))
|
|
95
|
+
continue;
|
|
96
|
+
const p = joinPath(dir, name);
|
|
97
|
+
try {
|
|
98
|
+
const st = await stat(p);
|
|
99
|
+
if (!st.isFile())
|
|
100
|
+
continue;
|
|
101
|
+
if (now - st.mtimeMs > tmpTtlMs) {
|
|
102
|
+
await rm(p, { force: true });
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
survivors.push({ path: p, size: st.size, mtime: st.mtimeMs });
|
|
106
|
+
}
|
|
107
|
+
catch { /* ignore — file may have vanished mid-scan */ }
|
|
108
|
+
}
|
|
109
|
+
let total = survivors.reduce((s, c) => s + c.size, 0);
|
|
110
|
+
if (total > tmpMaxBytes) {
|
|
111
|
+
survivors.sort((a, b) => a.mtime - b.mtime);
|
|
112
|
+
for (const c of survivors) {
|
|
113
|
+
if (total <= tmpMaxBytes)
|
|
114
|
+
break;
|
|
115
|
+
try {
|
|
116
|
+
await rm(c.path, { force: true });
|
|
117
|
+
total -= c.size;
|
|
118
|
+
}
|
|
119
|
+
catch { /* ignore */ }
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
async function autoSaveOversized(id, filename, buffer) {
|
|
124
|
+
await pruneTmpFiles();
|
|
125
|
+
const path = joinPath(tmpdir(), `${TMP_PREFIX}${id}-${sanitizeFilename(filename)}`);
|
|
126
|
+
await writeFile(path, buffer);
|
|
127
|
+
return path;
|
|
128
|
+
}
|
|
129
|
+
async function isAnimatedImage(buffer) {
|
|
130
|
+
try {
|
|
131
|
+
const meta = await sharp(buffer, { failOn: 'none', animated: true }).metadata();
|
|
132
|
+
return (meta.pages ?? 1) > 1;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
async function buildVideoResult(id, header, buffer, opts) {
|
|
139
|
+
let result;
|
|
140
|
+
try {
|
|
141
|
+
result = await processVideo(buffer, {
|
|
142
|
+
frames: opts.frames,
|
|
143
|
+
start: opts.start,
|
|
144
|
+
end: opts.end,
|
|
145
|
+
mode: opts.mode,
|
|
146
|
+
sceneThreshold: opts.sceneThreshold,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
return {
|
|
151
|
+
content: [{
|
|
152
|
+
type: 'text',
|
|
153
|
+
text: `${header}\nFailed to process ${opts.sourceLabel}: ${err.message}. Pass saveTo=/absolute/path to write the original to disk.`,
|
|
154
|
+
}],
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
if (result.frames.length === 0) {
|
|
158
|
+
return {
|
|
159
|
+
content: [{
|
|
160
|
+
type: 'text',
|
|
161
|
+
text: `${header}\nNo frames extracted from ${opts.sourceLabel}. Pass saveTo=/absolute/path to write the original to disk.`,
|
|
162
|
+
}],
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
const m = result.meta;
|
|
166
|
+
const tsNote = result.approximateTimestamps ? ' (timestamps approximate)' : '';
|
|
167
|
+
const summary = [
|
|
168
|
+
`Attachment #${id}: ${header}`,
|
|
169
|
+
`Duration: ${m.duration.toFixed(1)}s, ${m.width}×${m.height} @ ${m.fps.toFixed(1)}fps, codec ${m.codec}`,
|
|
170
|
+
`Sampled ${result.frames.length} frame(s) via ${result.mode} mode from ${result.effectiveStart.toFixed(1)}s–${result.effectiveEnd.toFixed(1)}s at ${opts.maxDimension}px / q${opts.quality}${result.dedupApplied ? ' (mpdecimate enabled)' : ''}${tsNote}.`,
|
|
171
|
+
`Re-call with start=<sec> end=<sec> frames=<n> or mode=scenes to refine.`,
|
|
172
|
+
].join('\n');
|
|
173
|
+
// Parallel sharp re-encode.
|
|
174
|
+
const encoded = await Promise.all(result.frames.map((f) => sharp(f.data, { failOn: 'none' })
|
|
175
|
+
.rotate()
|
|
176
|
+
.resize({ width: opts.maxDimension, height: opts.maxDimension, fit: 'inside', withoutEnlargement: true })
|
|
177
|
+
.jpeg({ quality: opts.quality, mozjpeg: true })
|
|
178
|
+
.toBuffer()));
|
|
179
|
+
const content = [{ type: 'text', text: summary }];
|
|
180
|
+
for (let i = 0; i < result.frames.length; i++) {
|
|
181
|
+
const f = result.frames[i];
|
|
182
|
+
const data = encoded[i];
|
|
183
|
+
const tsPrefix = f.approximate ? '~' : '';
|
|
184
|
+
content.push({ type: 'text', text: `Frame ${i + 1} @ ${tsPrefix}${f.timestampSec.toFixed(2)}s (${formatBytes(data.length)}):` });
|
|
185
|
+
content.push({ type: 'image', data: data.toString('base64'), mimeType: 'image/jpeg' });
|
|
186
|
+
}
|
|
187
|
+
return { content };
|
|
188
|
+
}
|
|
56
189
|
export async function buildAttachmentResult(args) {
|
|
57
190
|
const { id, filename, mimeType, buffer, saveTo } = args;
|
|
58
191
|
const sizeLabel = formatBytes(buffer.length);
|
|
@@ -64,13 +197,26 @@ export async function buildAttachmentResult(args) {
|
|
|
64
197
|
}
|
|
65
198
|
const mt = mimeType.toLowerCase();
|
|
66
199
|
if (mt.startsWith('image/')) {
|
|
200
|
+
// Animated images (GIF/APNG/animated WebP) get routed to the video pipeline so the LLM sees motion.
|
|
201
|
+
if (await isAnimatedImage(buffer)) {
|
|
202
|
+
if (buffer.length > MAX_VIDEO_SOURCE_BYTES) {
|
|
203
|
+
const path = await autoSaveOversized(id, filename, buffer);
|
|
204
|
+
return { content: [{ type: 'text', text: `${header}\nAnimated image exceeds ${formatBytes(MAX_VIDEO_SOURCE_BYTES)} processing cap. Original saved to ${path}.` }] };
|
|
205
|
+
}
|
|
206
|
+
return buildVideoResult(id, header, buffer, {
|
|
207
|
+
maxDimension: args.maxDimension ?? DEFAULT_VIDEO_MAX_DIMENSION,
|
|
208
|
+
quality: args.quality ?? DEFAULT_VIDEO_QUALITY,
|
|
209
|
+
frames: args.frames ?? DEFAULT_VIDEO_FRAMES,
|
|
210
|
+
start: args.start,
|
|
211
|
+
end: args.end,
|
|
212
|
+
mode: args.mode ?? 'uniform',
|
|
213
|
+
sceneThreshold: args.sceneThreshold,
|
|
214
|
+
sourceLabel: 'animated image',
|
|
215
|
+
});
|
|
216
|
+
}
|
|
67
217
|
if (buffer.length > MAX_INLINE_BYTES) {
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
};
|
|
218
|
+
const path = await autoSaveOversized(id, filename, buffer);
|
|
219
|
+
return { content: [{ type: 'text', text: `${header}\nImage exceeds ${formatBytes(MAX_INLINE_BYTES)} inline cap. Original saved to ${path}.` }] };
|
|
74
220
|
}
|
|
75
221
|
const maxDimension = args.maxDimension ?? DEFAULT_MAX_DIMENSION;
|
|
76
222
|
const quality = args.quality ?? DEFAULT_JPEG_QUALITY;
|
|
@@ -97,13 +243,108 @@ export async function buildAttachmentResult(args) {
|
|
|
97
243
|
};
|
|
98
244
|
}
|
|
99
245
|
}
|
|
246
|
+
if (mt.startsWith('video/')) {
|
|
247
|
+
if (buffer.length > MAX_VIDEO_SOURCE_BYTES) {
|
|
248
|
+
const path = await autoSaveOversized(id, filename, buffer);
|
|
249
|
+
return { content: [{ type: 'text', text: `${header}\nVideo exceeds ${formatBytes(MAX_VIDEO_SOURCE_BYTES)} processing cap. Original saved to ${path}.` }] };
|
|
250
|
+
}
|
|
251
|
+
return buildVideoResult(id, header, buffer, {
|
|
252
|
+
maxDimension: args.maxDimension ?? DEFAULT_VIDEO_MAX_DIMENSION,
|
|
253
|
+
quality: args.quality ?? DEFAULT_VIDEO_QUALITY,
|
|
254
|
+
frames: args.frames ?? DEFAULT_VIDEO_FRAMES,
|
|
255
|
+
start: args.start,
|
|
256
|
+
end: args.end,
|
|
257
|
+
mode: args.mode ?? 'uniform',
|
|
258
|
+
sceneThreshold: args.sceneThreshold,
|
|
259
|
+
sourceLabel: 'video',
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
if (mt.startsWith('audio/')) {
|
|
263
|
+
if (buffer.length > MAX_INLINE_BYTES) {
|
|
264
|
+
const path = await autoSaveOversized(id, filename, buffer);
|
|
265
|
+
return { content: [{ type: 'text', text: `${header}\nAudio exceeds ${formatBytes(MAX_INLINE_BYTES)} inline cap. Original saved to ${path}.` }] };
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
content: [
|
|
269
|
+
{ type: 'text', text: `Attachment #${id}: ${header}` },
|
|
270
|
+
{ type: 'audio', data: buffer.toString('base64'), mimeType: mt },
|
|
271
|
+
],
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
if (mt === 'application/pdf') {
|
|
275
|
+
if (buffer.length > MAX_INLINE_BYTES) {
|
|
276
|
+
const path = await autoSaveOversized(id, filename, buffer);
|
|
277
|
+
return { content: [{ type: 'text', text: `${header}\nPDF exceeds ${formatBytes(MAX_INLINE_BYTES)} inline cap. Original saved to ${path}.` }] };
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
const { extractText, getDocumentProxy, definePDFJSModule, renderPageAsImage } = await import('unpdf');
|
|
281
|
+
const pdf = await getDocumentProxy(new Uint8Array(buffer));
|
|
282
|
+
const { totalPages, text } = await extractText(pdf, { mergePages: true });
|
|
283
|
+
const body = (typeof text === 'string' ? text : text.join('\n\n')).trim();
|
|
284
|
+
// Empty/sparse text → likely scanned PDF. Rasterize first few pages so the model can see them.
|
|
285
|
+
const RASTER_THRESHOLD = 20;
|
|
286
|
+
const MAX_RASTER_PAGES = 3;
|
|
287
|
+
if (body.length < RASTER_THRESHOLD && totalPages > 0) {
|
|
288
|
+
try {
|
|
289
|
+
await definePDFJSModule(() => import('pdfjs-dist'));
|
|
290
|
+
// unpdf requires an explicit canvasImport in Node so @napi-rs/canvas can be loaded lazily.
|
|
291
|
+
// NodeNext ESM types differ between two synthesized views of @napi-rs/canvas; cast through any.
|
|
292
|
+
const renderOpts = { width: 0, canvasImport: () => import('@napi-rs/canvas') };
|
|
293
|
+
const pageCount = Math.min(totalPages, MAX_RASTER_PAGES);
|
|
294
|
+
const maxDimension = args.maxDimension ?? DEFAULT_MAX_DIMENSION;
|
|
295
|
+
const quality = args.quality ?? DEFAULT_JPEG_QUALITY;
|
|
296
|
+
const rasterized = await Promise.all(Array.from({ length: pageCount }, (_, i) => renderPageAsImage(new Uint8Array(buffer), i + 1, { ...renderOpts, width: maxDimension }).then(async (ab) => {
|
|
297
|
+
const png = Buffer.from(ab);
|
|
298
|
+
return sharp(png, { failOn: 'none' })
|
|
299
|
+
.resize({ width: maxDimension, height: maxDimension, fit: 'inside', withoutEnlargement: true })
|
|
300
|
+
.jpeg({ quality, mozjpeg: true })
|
|
301
|
+
.toBuffer();
|
|
302
|
+
})));
|
|
303
|
+
const content = [{
|
|
304
|
+
type: 'text',
|
|
305
|
+
text: `Attachment #${id}: ${header}\nNo extractable text found (likely scanned). Rasterized first ${pageCount} of ${totalPages} page(s):`,
|
|
306
|
+
}];
|
|
307
|
+
for (let i = 0; i < rasterized.length; i++) {
|
|
308
|
+
content.push({ type: 'text', text: `Page ${i + 1} (${formatBytes(rasterized[i].length)}):` });
|
|
309
|
+
content.push({ type: 'image', data: rasterized[i].toString('base64'), mimeType: 'image/jpeg' });
|
|
310
|
+
}
|
|
311
|
+
return { content };
|
|
312
|
+
}
|
|
313
|
+
catch (rasterErr) {
|
|
314
|
+
const path = await autoSaveOversized(id, filename, buffer);
|
|
315
|
+
return {
|
|
316
|
+
content: [{
|
|
317
|
+
type: 'text',
|
|
318
|
+
text: `${header}\nNo extractable text and rasterization failed: ${rasterErr.message}. Original saved to ${path}.`,
|
|
319
|
+
}],
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
content: [{
|
|
325
|
+
type: 'text',
|
|
326
|
+
text: `Attachment #${id}: ${header}\nExtracted text from ${totalPages} page(s):\n\n${body}`,
|
|
327
|
+
}],
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
catch (err) {
|
|
331
|
+
const path = await autoSaveOversized(id, filename, buffer);
|
|
332
|
+
return {
|
|
333
|
+
content: [{
|
|
334
|
+
type: 'text',
|
|
335
|
+
text: `${header}\nFailed to extract PDF text: ${err.message}. Original saved to ${path}.`,
|
|
336
|
+
}],
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
}
|
|
100
340
|
if (isTextMime(mt) && buffer.length <= MAX_INLINE_BYTES) {
|
|
101
341
|
return { content: [{ type: 'text', text: `Attachment #${id}: ${header}\n\n${buffer.toString('utf-8')}` }] };
|
|
102
342
|
}
|
|
343
|
+
const path = await autoSaveOversized(id, filename, buffer);
|
|
103
344
|
return {
|
|
104
345
|
content: [{
|
|
105
346
|
type: 'text',
|
|
106
|
-
text: `${header}\nAttachment #${id} is
|
|
347
|
+
text: `${header}\nAttachment #${id} is not inline-renderable. Original saved to ${path}.`,
|
|
107
348
|
}],
|
|
108
349
|
};
|
|
109
350
|
}
|
package/dist/bitbucket.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { execSync } from 'child_process';
|
|
2
|
-
import {
|
|
2
|
+
import { createWriteStream } from 'fs';
|
|
3
|
+
import { Readable } from 'stream';
|
|
4
|
+
import { pipeline } from 'stream/promises';
|
|
5
|
+
import { resolve as resolvePath } from 'path';
|
|
6
|
+
import { buildAttachmentResult, formatBytes } from './attachment.js';
|
|
7
|
+
import { MAX_VIDEO_SOURCE_BYTES } from './video.js';
|
|
3
8
|
const EMOJI_RE = /\p{Extended_Pictographic}/u;
|
|
4
9
|
const ATTACHMENT_REF_RE = /!?\[([^\]]*)\]\(attachment:(\d+)\)/g;
|
|
5
10
|
function collectAttachmentRefs(input, source, out) {
|
|
@@ -893,28 +898,53 @@ export class BitbucketClient {
|
|
|
893
898
|
if (!id)
|
|
894
899
|
throw new Error('attachmentId is required.');
|
|
895
900
|
const url = `${this.baseUrl}/rest/api/1.0${this.rp(projectKey, repoSlug)}/attachments/${encodeURIComponent(id)}`;
|
|
901
|
+
const fetchTimeoutMs = args.saveTo ? 300_000 : 60_000;
|
|
896
902
|
const res = await fetch(url, {
|
|
897
903
|
method: 'GET',
|
|
898
904
|
headers: { Authorization: this.headers.Authorization },
|
|
899
|
-
signal: AbortSignal.timeout(
|
|
905
|
+
signal: AbortSignal.timeout(fetchTimeoutMs),
|
|
900
906
|
});
|
|
901
907
|
if (!res.ok) {
|
|
902
908
|
const errText = await res.text();
|
|
903
909
|
throw new Error(formatBitbucketError(res.status, 'GET', `${this.rp(projectKey, repoSlug)}/attachments/${id}`, parseBitbucketErrorDetails(errText)));
|
|
904
910
|
}
|
|
905
|
-
const buffer = Buffer.from(await res.arrayBuffer());
|
|
906
911
|
const contentDisposition = res.headers.get('content-disposition') ?? '';
|
|
907
912
|
const filenameMatch = contentDisposition.match(/filename\*?=(?:UTF-8'')?"?([^";]+)"?/i);
|
|
908
913
|
const filename = filenameMatch ? decodeURIComponent(filenameMatch[1]) : `attachment-${id}`;
|
|
909
914
|
const mimeType = (res.headers.get('content-type') ?? 'application/octet-stream').split(';')[0].trim();
|
|
915
|
+
const declaredLength = parseInt(res.headers.get('content-length') ?? '0', 10);
|
|
916
|
+
// saveTo path: stream directly to disk so we never buffer the whole attachment in memory.
|
|
917
|
+
if (args.saveTo) {
|
|
918
|
+
const path = resolvePath(args.saveTo);
|
|
919
|
+
if (!res.body)
|
|
920
|
+
throw new Error(`Attachment #${id} response has no body.`);
|
|
921
|
+
await pipeline(Readable.fromWeb(res.body), createWriteStream(path));
|
|
922
|
+
const sizeLabel = declaredLength > 0 ? formatBytes(declaredLength) : 'unknown size';
|
|
923
|
+
return { content: [{ type: 'text', text: `Saved attachment #${id} (${filename} — ${mimeType}, ${sizeLabel}) to ${path}` }] };
|
|
924
|
+
}
|
|
925
|
+
if (declaredLength > MAX_VIDEO_SOURCE_BYTES) {
|
|
926
|
+
try {
|
|
927
|
+
await res.body?.cancel();
|
|
928
|
+
}
|
|
929
|
+
catch { /* ignore */ }
|
|
930
|
+
throw new Error(`Attachment #${id} is ${formatBytes(declaredLength)}, exceeds the ${formatBytes(MAX_VIDEO_SOURCE_BYTES)} inline cap. Pass saveTo=/absolute/path to stream it to disk.`);
|
|
931
|
+
}
|
|
932
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
933
|
+
if (buffer.length > MAX_VIDEO_SOURCE_BYTES) {
|
|
934
|
+
throw new Error(`Attachment #${id} downloaded ${formatBytes(buffer.length)}, exceeds the ${formatBytes(MAX_VIDEO_SOURCE_BYTES)} inline cap. Pass saveTo=/absolute/path to stream it to disk.`);
|
|
935
|
+
}
|
|
910
936
|
return buildAttachmentResult({
|
|
911
937
|
id,
|
|
912
938
|
filename,
|
|
913
939
|
mimeType,
|
|
914
940
|
buffer,
|
|
915
|
-
saveTo: args.saveTo,
|
|
916
941
|
maxDimension: args.maxDimension,
|
|
917
942
|
quality: args.quality,
|
|
943
|
+
frames: args.frames,
|
|
944
|
+
start: args.start,
|
|
945
|
+
end: args.end,
|
|
946
|
+
mode: args.mode,
|
|
947
|
+
sceneThreshold: args.sceneThreshold,
|
|
918
948
|
});
|
|
919
949
|
}
|
|
920
950
|
async fetchFileText(projectKey, repoSlug, filePath) {
|
package/dist/index.js
CHANGED
|
@@ -125,6 +125,32 @@ function normalizeJiraMutateArgs(args) {
|
|
|
125
125
|
}
|
|
126
126
|
return out;
|
|
127
127
|
}
|
|
128
|
+
function validateAttachmentArgs(a) {
|
|
129
|
+
if (a.frames !== undefined && (typeof a.frames !== 'number' || !Number.isFinite(a.frames) || a.frames < 1 || a.frames > 60)) {
|
|
130
|
+
throw new McpError(ErrorCode.InvalidParams, 'frames must be a number between 1 and 60.');
|
|
131
|
+
}
|
|
132
|
+
if (a.start !== undefined && (typeof a.start !== 'number' || !Number.isFinite(a.start) || a.start < 0)) {
|
|
133
|
+
throw new McpError(ErrorCode.InvalidParams, 'start must be a non-negative number of seconds.');
|
|
134
|
+
}
|
|
135
|
+
if (a.end !== undefined && (typeof a.end !== 'number' || !Number.isFinite(a.end) || a.end <= 0)) {
|
|
136
|
+
throw new McpError(ErrorCode.InvalidParams, 'end must be a positive number of seconds.');
|
|
137
|
+
}
|
|
138
|
+
if (typeof a.start === 'number' && typeof a.end === 'number' && a.end <= a.start) {
|
|
139
|
+
throw new McpError(ErrorCode.InvalidParams, 'end must be greater than start.');
|
|
140
|
+
}
|
|
141
|
+
if (a.mode !== undefined && a.mode !== 'uniform' && a.mode !== 'scenes') {
|
|
142
|
+
throw new McpError(ErrorCode.InvalidParams, 'mode must be "uniform" or "scenes".');
|
|
143
|
+
}
|
|
144
|
+
if (a.maxDimension !== undefined && (typeof a.maxDimension !== 'number' || !Number.isFinite(a.maxDimension) || a.maxDimension < 64 || a.maxDimension > 4096)) {
|
|
145
|
+
throw new McpError(ErrorCode.InvalidParams, 'maxDimension must be a number between 64 and 4096.');
|
|
146
|
+
}
|
|
147
|
+
if (a.quality !== undefined && (typeof a.quality !== 'number' || !Number.isFinite(a.quality) || a.quality < 1 || a.quality > 100)) {
|
|
148
|
+
throw new McpError(ErrorCode.InvalidParams, 'quality must be a number between 1 and 100.');
|
|
149
|
+
}
|
|
150
|
+
if (a.sceneThreshold !== undefined && (typeof a.sceneThreshold !== 'number' || !Number.isFinite(a.sceneThreshold) || a.sceneThreshold <= 0 || a.sceneThreshold > 1)) {
|
|
151
|
+
throw new McpError(ErrorCode.InvalidParams, 'sceneThreshold must be a number in (0, 1].');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
128
154
|
const JIRA_WIKI_MARKUP_HINT = 'Use Jira wiki markup (Atlassian renderer syntax), not GitHub/CommonMark markdown.';
|
|
129
155
|
function issueTypePrefix(issueType) {
|
|
130
156
|
const t = issueType.toLowerCase();
|
|
@@ -206,7 +232,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
206
232
|
},
|
|
207
233
|
{
|
|
208
234
|
name: 'jira_search',
|
|
209
|
-
description: 'Discover Jira resources. Use when asked "find tickets for...", "what\'s in the backlog", "show me my issues", "list projects", or "which board is for project X". Set resource:\n• "issues" (default) — search by text, JQL, project, status, assignee, issue type, or mine=true for your queue\n• "projects" — list all projects and their keys\n• "issue_types" — valid types and statuses for a project\n• "boards" — list boards (pass project to filter by project key); use this to find the boardId before fetching sprints or board_overview\n• "sprints" — sprints for a board (pass boardId); if you don\'t know the boardId, first use resource=boards\n• "board_overview" — active/future sprints with their issues for a board (pass boardId); use when asked "what\'s in the sprint", "show me the board", or "what\'s everyone working on"\n• "versions" — list fix versions/releases for a project (pass project
|
|
235
|
+
description: 'Discover Jira resources. Use when asked "find tickets for...", "what\'s in the backlog", "show me my issues", "list projects", or "which board is for project X". Set resource:\n• "issues" (default) — search by text, JQL, project, status, assignee, issue type, or mine=true for your queue\n• "projects" — list all projects and their keys\n• "issue_types" — valid types and statuses for a project\n• "boards" — list boards (pass project to filter by project key); use this to find the boardId before fetching sprints or board_overview\n• "sprints" — sprints for a board (pass boardId); if you don\'t know the boardId, first use resource=boards\n• "board_overview" — active/future sprints with their issues for a board (pass boardId); use when asked "what\'s in the sprint", "show me the board", or "what\'s everyone working on"\n• "versions" — list fix versions/releases for a project (pass project; optionally pass query to filter by name substring). If the version you need does not exist, create it yourself with `jira_version action=create` — do NOT ask the user to make it in the Jira UI.\n• "users" — find users by name/email (pass query)',
|
|
210
236
|
inputSchema: {
|
|
211
237
|
type: 'object',
|
|
212
238
|
properties: {
|
|
@@ -308,14 +334,19 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
308
334
|
},
|
|
309
335
|
{
|
|
310
336
|
name: 'jira_get_attachment',
|
|
311
|
-
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
|
|
337
|
+
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; text/JSON/XML come back as text. Videos AND animated images (GIF/APNG/animated WebP) are decoded with ffmpeg: by default 6 frames are sampled uniformly across the whole clip (768 px / q65, mpdecimate to drop near-duplicates) — re-call with start/end/frames or mode=scenes to refine. Audio is returned as an MCP audio block. PDFs return extracted text. Anything still too large or non-renderable is automatically saved to a temp file and the path is returned. Use jira_get first to discover attachment IDs.',
|
|
312
338
|
inputSchema: {
|
|
313
339
|
type: 'object',
|
|
314
340
|
properties: {
|
|
315
341
|
attachmentId: { type: 'string', description: 'Numeric attachment ID from jira_get output' },
|
|
316
342
|
saveTo: { type: 'string', description: 'Optional absolute path to save the original (un-resized) file to disk instead of returning inline' },
|
|
317
|
-
maxDimension: { type: 'number', description: 'Max long-edge size in pixels for inline images (default 1568
|
|
318
|
-
quality: { type: 'number', description: 'JPEG quality for re-encoded inline images (1-100, default 85). Ignored for images with alpha (encoded as PNG).' },
|
|
343
|
+
maxDimension: { type: 'number', description: 'Max long-edge size in pixels for inline images (default 1568 for images, 768 for video frames).' },
|
|
344
|
+
quality: { type: 'number', description: 'JPEG quality for re-encoded inline images (1-100, default 85 for images, 65 for video frames). Ignored for images with alpha (encoded as PNG).' },
|
|
345
|
+
frames: { type: 'number', description: 'Video/animated-image only: number of frames to sample (default 6, range 1-60). Higher = more detail + more context.' },
|
|
346
|
+
start: { type: 'number', description: 'Video/animated-image only: start of sample window in seconds (default 0). Use with end/frames to zoom into a moment of interest after a coarse first pass.' },
|
|
347
|
+
end: { type: 'number', description: 'Video/animated-image only: end of sample window in seconds (default full duration). Must be greater than start.' },
|
|
348
|
+
mode: { type: 'string', enum: ['uniform', 'scenes'], description: 'Video/animated-image only: "uniform" samples N frames evenly (default); "scenes" uses ffmpeg scene-change detection, better for screencasts/narrative content.' },
|
|
349
|
+
sceneThreshold: { type: 'number', description: 'Video/animated-image only: scene-change sensitivity in 0-1 (default 0.3). Only used when mode=scenes. Lower = more frames, higher = fewer.' },
|
|
319
350
|
},
|
|
320
351
|
required: ['attachmentId'],
|
|
321
352
|
},
|
|
@@ -490,7 +521,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
490
521
|
},
|
|
491
522
|
{
|
|
492
523
|
name: 'bitbucket_get_attachment',
|
|
493
|
-
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
|
|
524
|
+
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. Videos AND animated images (GIF/APNG/animated WebP) are decoded with ffmpeg: by default 6 frames are sampled uniformly across the whole clip (768 px / q65, mpdecimate to drop near-duplicates) — re-call with start/end/frames or mode=scenes to refine. Audio is returned as an MCP audio block. PDFs return extracted text. Oversized/non-renderable attachments are auto-saved to a temp file and the path is returned.',
|
|
494
525
|
inputSchema: {
|
|
495
526
|
type: 'object',
|
|
496
527
|
properties: {
|
|
@@ -500,8 +531,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
500
531
|
repo: { type: 'string', description: 'Alias for repoSlug' },
|
|
501
532
|
attachmentId: { type: 'string', description: 'Numeric attachment ID' },
|
|
502
533
|
saveTo: { type: 'string', description: 'Optional absolute path to save the original (un-resized) file to disk instead of returning inline' },
|
|
503
|
-
maxDimension: { type: 'number', description: 'Max long-edge size in pixels for inline images (default 1568
|
|
504
|
-
quality: { type: 'number', description: 'JPEG quality for re-encoded inline images (1-100, default 85). Ignored for images with alpha (encoded as PNG).' },
|
|
534
|
+
maxDimension: { type: 'number', description: 'Max long-edge size in pixels for inline images (default 1568 for images, 768 for video frames).' },
|
|
535
|
+
quality: { type: 'number', description: 'JPEG quality for re-encoded inline images (1-100, default 85 for images, 65 for video frames). Ignored for images with alpha (encoded as PNG).' },
|
|
536
|
+
frames: { type: 'number', description: 'Video/animated-image only: number of frames to sample (default 6, range 1-60).' },
|
|
537
|
+
start: { type: 'number', description: 'Video/animated-image only: start of sample window in seconds (default 0). Use with end/frames to zoom in.' },
|
|
538
|
+
end: { type: 'number', description: 'Video/animated-image only: end of sample window in seconds (default full duration). Must be greater than start.' },
|
|
539
|
+
mode: { type: 'string', enum: ['uniform', 'scenes'], description: 'Video/animated-image only: "uniform" samples N frames evenly (default); "scenes" uses scene-change detection.' },
|
|
540
|
+
sceneThreshold: { type: 'number', description: 'Video/animated-image only: scene-change sensitivity in 0-1 (default 0.3). Only used when mode=scenes. Lower = more frames, higher = fewer.' },
|
|
505
541
|
},
|
|
506
542
|
required: ['attachmentId'],
|
|
507
543
|
},
|
|
@@ -780,7 +816,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
780
816
|
if (resource === 'board_overview')
|
|
781
817
|
return await jira.boardOverview({ boardId: a.boardId, sprintState: a.sprintState, sprintMaxResults: a.maxResults, sprintStartAt: a.startAt, includeIssues: a.includeIssues, assignee: a.assignee, status: a.status });
|
|
782
818
|
if (resource === 'versions')
|
|
783
|
-
return await jira.listVersions({ projectKey: a.projectKey ?? a.project, maxResults: a.maxResults });
|
|
819
|
+
return await jira.listVersions({ projectKey: a.projectKey ?? a.project, query: a.query, maxResults: a.maxResults });
|
|
784
820
|
if (resource === 'users')
|
|
785
821
|
return await jira.searchUsers({ query: a.query ?? '', maxResults: a.maxResults });
|
|
786
822
|
// issues (default)
|
|
@@ -800,6 +836,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
800
836
|
if (!jira)
|
|
801
837
|
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
|
|
802
838
|
const a = args;
|
|
839
|
+
validateAttachmentArgs(a);
|
|
803
840
|
return await jira.getAttachment(a);
|
|
804
841
|
}
|
|
805
842
|
case 'jira_comment': {
|
|
@@ -911,7 +948,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
911
948
|
case 'bitbucket_get_attachment': {
|
|
912
949
|
if (!bitbucket)
|
|
913
950
|
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
|
|
914
|
-
|
|
951
|
+
const normalized = normalizeBitbucketArgs(args);
|
|
952
|
+
validateAttachmentArgs(normalized);
|
|
953
|
+
return await bitbucket.getAttachment(normalized);
|
|
915
954
|
}
|
|
916
955
|
case 'bitbucket_pr_tasks': {
|
|
917
956
|
if (!bitbucket)
|
package/dist/jira.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { execSync } from 'child_process';
|
|
2
|
+
import { createWriteStream } from 'fs';
|
|
3
|
+
import { Readable } from 'stream';
|
|
4
|
+
import { pipeline } from 'stream/promises';
|
|
5
|
+
import { resolve as resolvePath } from 'path';
|
|
2
6
|
import { buildAttachmentResult, formatBytes } from './attachment.js';
|
|
7
|
+
import { MAX_VIDEO_SOURCE_BYTES } from './video.js';
|
|
3
8
|
const JIRA_KEY_IN_BRANCH_RE = /\b([A-Z][A-Z0-9]+)-\d+\b/;
|
|
4
9
|
const EMOJI_RE = /\p{Extended_Pictographic}/u;
|
|
5
10
|
function text(t) {
|
|
@@ -794,6 +799,28 @@ export class JiraClient {
|
|
|
794
799
|
const meta = await this.request('GET', `/attachment/${encodeURIComponent(id)}`);
|
|
795
800
|
if (!meta)
|
|
796
801
|
throw new Error(`Attachment ${id} not found.`);
|
|
802
|
+
// saveTo path: stream response directly to disk to bypass the in-memory size cap and avoid double-buffering.
|
|
803
|
+
if (args.saveTo) {
|
|
804
|
+
const path = resolvePath(args.saveTo);
|
|
805
|
+
const res = await fetch(meta.content, {
|
|
806
|
+
method: 'GET',
|
|
807
|
+
headers: { Authorization: this.headers.Authorization },
|
|
808
|
+
signal: AbortSignal.timeout(300_000),
|
|
809
|
+
});
|
|
810
|
+
if (!res.ok) {
|
|
811
|
+
const errText = await res.text();
|
|
812
|
+
throw new Error(formatJiraError(res.status, 'GET', meta.content, parseJiraErrorDetails(errText)));
|
|
813
|
+
}
|
|
814
|
+
if (!res.body)
|
|
815
|
+
throw new Error(`Attachment #${id} response has no body.`);
|
|
816
|
+
await pipeline(Readable.fromWeb(res.body), createWriteStream(path));
|
|
817
|
+
const sizeLabel = meta.size ? formatBytes(meta.size) : 'unknown size';
|
|
818
|
+
return { content: [{ type: 'text', text: `Saved attachment #${id} (${meta.filename} — ${meta.mimeType ?? 'application/octet-stream'}, ${sizeLabel}) to ${path}` }] };
|
|
819
|
+
}
|
|
820
|
+
// Inline path enforces the 250 MB cap so we don't OOM on accidental huge fetches.
|
|
821
|
+
if (meta.size && meta.size > MAX_VIDEO_SOURCE_BYTES) {
|
|
822
|
+
throw new Error(`Attachment #${id} is ${formatBytes(meta.size)}, exceeds the ${formatBytes(MAX_VIDEO_SOURCE_BYTES)} inline cap. Pass saveTo=/absolute/path to stream it to disk.`);
|
|
823
|
+
}
|
|
797
824
|
const res = await fetch(meta.content, {
|
|
798
825
|
method: 'GET',
|
|
799
826
|
headers: { Authorization: this.headers.Authorization },
|
|
@@ -803,15 +830,30 @@ export class JiraClient {
|
|
|
803
830
|
const errText = await res.text();
|
|
804
831
|
throw new Error(formatJiraError(res.status, 'GET', meta.content, parseJiraErrorDetails(errText)));
|
|
805
832
|
}
|
|
833
|
+
const declaredLength = parseInt(res.headers.get('content-length') ?? '0', 10);
|
|
834
|
+
if (declaredLength > MAX_VIDEO_SOURCE_BYTES) {
|
|
835
|
+
try {
|
|
836
|
+
await res.body?.cancel();
|
|
837
|
+
}
|
|
838
|
+
catch { /* ignore */ }
|
|
839
|
+
throw new Error(`Attachment #${id} is ${formatBytes(declaredLength)}, exceeds the ${formatBytes(MAX_VIDEO_SOURCE_BYTES)} inline cap. Pass saveTo=/absolute/path to stream it to disk.`);
|
|
840
|
+
}
|
|
806
841
|
const buffer = Buffer.from(await res.arrayBuffer());
|
|
842
|
+
if (buffer.length > MAX_VIDEO_SOURCE_BYTES) {
|
|
843
|
+
throw new Error(`Attachment #${id} downloaded ${formatBytes(buffer.length)}, exceeds the ${formatBytes(MAX_VIDEO_SOURCE_BYTES)} inline cap. Pass saveTo=/absolute/path to stream it to disk.`);
|
|
844
|
+
}
|
|
807
845
|
return buildAttachmentResult({
|
|
808
846
|
id,
|
|
809
847
|
filename: meta.filename,
|
|
810
848
|
mimeType: meta.mimeType ?? 'application/octet-stream',
|
|
811
849
|
buffer,
|
|
812
|
-
saveTo: args.saveTo,
|
|
813
850
|
maxDimension: args.maxDimension,
|
|
814
851
|
quality: args.quality,
|
|
852
|
+
frames: args.frames,
|
|
853
|
+
start: args.start,
|
|
854
|
+
end: args.end,
|
|
855
|
+
mode: args.mode,
|
|
856
|
+
sceneThreshold: args.sceneThreshold,
|
|
815
857
|
});
|
|
816
858
|
}
|
|
817
859
|
async transitionIssue(args) {
|
|
@@ -822,9 +864,20 @@ export class JiraClient {
|
|
|
822
864
|
async listVersions(args) {
|
|
823
865
|
const projectKey = await this.resolveProjectKey(args.projectKey);
|
|
824
866
|
const data = await this.request('GET', `/project/${encodeURIComponent(projectKey)}/versions`);
|
|
825
|
-
|
|
867
|
+
const query = args.query?.trim();
|
|
868
|
+
const createHint = (name) => `Create it with: jira_version action=create projectKey=${projectKey} name="${name}"`;
|
|
869
|
+
if (!data || data.length === 0) {
|
|
870
|
+
if (query)
|
|
871
|
+
return text(`No versions in ${projectKey}. ${createHint(query)}`);
|
|
826
872
|
return text(`No versions in ${projectKey}.`);
|
|
827
|
-
|
|
873
|
+
}
|
|
874
|
+
const filtered = query
|
|
875
|
+
? data.filter(v => v.name.toLowerCase().includes(query.toLowerCase()))
|
|
876
|
+
: data;
|
|
877
|
+
if (query && filtered.length === 0) {
|
|
878
|
+
return text(`No version matching "${query}" in ${projectKey} (${data.length} other version(s) exist). ${createHint(query)}`);
|
|
879
|
+
}
|
|
880
|
+
const sorted = [...filtered].sort((a, b) => {
|
|
828
881
|
if (a.released !== b.released)
|
|
829
882
|
return a.released ? 1 : -1;
|
|
830
883
|
if (a.archived !== b.archived)
|
|
@@ -846,8 +899,11 @@ export class JiraClient {
|
|
|
846
899
|
const dateStr = dateParts.length ? ` (${dateParts.join(', ')})` : '';
|
|
847
900
|
return `${i + 1}. [${v.id}] ${v.name}${tagStr}${dateStr}`;
|
|
848
901
|
});
|
|
849
|
-
const
|
|
850
|
-
|
|
902
|
+
const header = query
|
|
903
|
+
? `${filtered.length} version(s) matching "${query}" in ${projectKey}:`
|
|
904
|
+
: `${filtered.length} version(s) in ${projectKey}:`;
|
|
905
|
+
const more = filtered.length > shown.length ? `\n...and ${filtered.length - shown.length} more (raise maxResults).` : '';
|
|
906
|
+
return text(`${header}\n${lines.join('\n')}${more}`);
|
|
851
907
|
}
|
|
852
908
|
async mutateVersion(args) {
|
|
853
909
|
const action = args.action ?? 'create';
|
package/dist/video.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { writeFile, readdir, readFile, mkdtemp, rm } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
// ffmpeg-static / ffprobe-static ship no types; declare just enough to import.
|
|
7
|
+
// @ts-ignore - no @types
|
|
8
|
+
import ffmpegPathImport from 'ffmpeg-static';
|
|
9
|
+
// @ts-ignore - no @types
|
|
10
|
+
import ffprobeStaticImport from 'ffprobe-static';
|
|
11
|
+
const FFMPEG = process.env.ATLASSIAN_MCP_FFMPEG_PATH ||
|
|
12
|
+
ffmpegPathImport ||
|
|
13
|
+
null;
|
|
14
|
+
const FFPROBE = process.env.ATLASSIAN_MCP_FFPROBE_PATH ||
|
|
15
|
+
ffprobeStaticImport?.path ||
|
|
16
|
+
null;
|
|
17
|
+
export const DEFAULT_VIDEO_FRAMES = 6;
|
|
18
|
+
export const DEFAULT_VIDEO_MAX_DIMENSION = 768;
|
|
19
|
+
export const DEFAULT_VIDEO_QUALITY = 65;
|
|
20
|
+
export const MAX_VIDEO_SOURCE_BYTES = 250 * 1024 * 1024;
|
|
21
|
+
export const VIDEO_FRAMES_MIN = 1;
|
|
22
|
+
export const VIDEO_FRAMES_MAX = 60;
|
|
23
|
+
export const DEFAULT_SCENE_THRESHOLD = 0.3;
|
|
24
|
+
function run(cmd, args) {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const proc = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
27
|
+
const stdoutChunks = [];
|
|
28
|
+
let stderr = '';
|
|
29
|
+
proc.stdout.on('data', (c) => stdoutChunks.push(c));
|
|
30
|
+
proc.stderr.on('data', (c) => (stderr += c.toString()));
|
|
31
|
+
proc.on('error', reject);
|
|
32
|
+
proc.on('close', (code) => resolve({ stdout: Buffer.concat(stdoutChunks), stderr, code: code ?? 0 }));
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
async function probeVideo(filePath) {
|
|
36
|
+
if (!FFPROBE)
|
|
37
|
+
throw new Error('ffprobe binary unavailable. Set ATLASSIAN_MCP_FFPROBE_PATH or install ffprobe-static.');
|
|
38
|
+
const { stdout, stderr, code } = await run(FFPROBE, [
|
|
39
|
+
'-v', 'error',
|
|
40
|
+
'-select_streams', 'v:0',
|
|
41
|
+
'-show_entries', 'stream=width,height,r_frame_rate,codec_name,duration,nb_frames,nb_read_frames:format=duration',
|
|
42
|
+
'-count_frames', // populate nb_read_frames for containers without nb_frames (e.g. GIF)
|
|
43
|
+
'-of', 'json',
|
|
44
|
+
filePath,
|
|
45
|
+
]);
|
|
46
|
+
if (code !== 0)
|
|
47
|
+
throw new Error(`ffprobe failed (${code}): ${stderr.trim() || 'unknown error'}`);
|
|
48
|
+
const json = JSON.parse(stdout.toString('utf-8'));
|
|
49
|
+
const stream = json.streams?.[0];
|
|
50
|
+
if (!stream)
|
|
51
|
+
throw new Error('No video stream found.');
|
|
52
|
+
const rate = stream.r_frame_rate ?? '0/1';
|
|
53
|
+
const [num, den] = rate.split('/').map((s) => parseFloat(s));
|
|
54
|
+
const fps = Number.isFinite(num) && Number.isFinite(den) && den > 0 ? num / den : 0;
|
|
55
|
+
// duration fallback chain: format.duration → stream.duration → nb_frames/fps → 0
|
|
56
|
+
const formatDuration = parseFloat(json.format?.duration ?? '');
|
|
57
|
+
const streamDuration = parseFloat(stream.duration ?? '');
|
|
58
|
+
const frameCount = parseInt(stream.nb_frames ?? stream.nb_read_frames ?? '', 10);
|
|
59
|
+
const fromFrames = Number.isFinite(frameCount) && frameCount > 0 && fps > 0 ? frameCount / fps : 0;
|
|
60
|
+
const duration = [formatDuration, streamDuration, fromFrames].find((v) => Number.isFinite(v) && v > 0) ?? 0;
|
|
61
|
+
return {
|
|
62
|
+
duration,
|
|
63
|
+
width: stream.width ?? 0,
|
|
64
|
+
height: stream.height ?? 0,
|
|
65
|
+
fps,
|
|
66
|
+
codec: stream.codec_name ?? 'unknown',
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
// Quick content-derived cache key. Hashes head+tail+length to avoid full-buffer scan on large videos.
|
|
70
|
+
function quickHash(buf) {
|
|
71
|
+
const h = createHash('sha1');
|
|
72
|
+
const HEAD = 1024 * 1024;
|
|
73
|
+
h.update(buf.subarray(0, Math.min(buf.length, HEAD)));
|
|
74
|
+
if (buf.length > HEAD * 2)
|
|
75
|
+
h.update(buf.subarray(buf.length - HEAD));
|
|
76
|
+
h.update(String(buf.length));
|
|
77
|
+
return h.digest('hex');
|
|
78
|
+
}
|
|
79
|
+
const VIDEO_CACHE = new Map();
|
|
80
|
+
const VIDEO_CACHE_MAX = 16;
|
|
81
|
+
function cacheGet(key) {
|
|
82
|
+
const hit = VIDEO_CACHE.get(key);
|
|
83
|
+
if (!hit)
|
|
84
|
+
return undefined;
|
|
85
|
+
// refresh LRU order
|
|
86
|
+
VIDEO_CACHE.delete(key);
|
|
87
|
+
VIDEO_CACHE.set(key, hit);
|
|
88
|
+
return hit;
|
|
89
|
+
}
|
|
90
|
+
function cacheSet(key, value) {
|
|
91
|
+
if (VIDEO_CACHE.size >= VIDEO_CACHE_MAX) {
|
|
92
|
+
const oldest = VIDEO_CACHE.keys().next().value;
|
|
93
|
+
if (oldest !== undefined)
|
|
94
|
+
VIDEO_CACHE.delete(oldest);
|
|
95
|
+
}
|
|
96
|
+
VIDEO_CACHE.set(key, value);
|
|
97
|
+
}
|
|
98
|
+
// Parse pts_time from ffmpeg showinfo stderr. Returns timestamps in input order.
|
|
99
|
+
function parseShowinfoTimestamps(stderr) {
|
|
100
|
+
const out = [];
|
|
101
|
+
const re = /Parsed_showinfo[^\]]*\][^\n]*pts_time:([\d.]+)/g;
|
|
102
|
+
let m;
|
|
103
|
+
while ((m = re.exec(stderr)) !== null) {
|
|
104
|
+
const v = parseFloat(m[1]);
|
|
105
|
+
if (Number.isFinite(v))
|
|
106
|
+
out.push(v);
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
export async function processVideo(buffer, opts = {}) {
|
|
111
|
+
if (!FFMPEG)
|
|
112
|
+
throw new Error('ffmpeg binary unavailable. Set ATLASSIAN_MCP_FFMPEG_PATH or install ffmpeg-static.');
|
|
113
|
+
const frames = Math.max(VIDEO_FRAMES_MIN, Math.min(opts.frames ?? DEFAULT_VIDEO_FRAMES, VIDEO_FRAMES_MAX));
|
|
114
|
+
const dedup = opts.dedup ?? true;
|
|
115
|
+
const mode = opts.mode ?? 'uniform';
|
|
116
|
+
const sceneThreshold = Math.max(0.01, Math.min(opts.sceneThreshold ?? DEFAULT_SCENE_THRESHOLD, 1));
|
|
117
|
+
const cacheKey = `${quickHash(buffer)}:${frames}:${opts.start ?? 'a'}:${opts.end ?? 'z'}:${dedup}:${mode}:${sceneThreshold}`;
|
|
118
|
+
const cached = cacheGet(cacheKey);
|
|
119
|
+
if (cached)
|
|
120
|
+
return cached;
|
|
121
|
+
const dir = await mkdtemp(join(tmpdir(), 'atlmcp-video-'));
|
|
122
|
+
const input = join(dir, 'input');
|
|
123
|
+
try {
|
|
124
|
+
await writeFile(input, buffer);
|
|
125
|
+
const meta = await probeVideo(input);
|
|
126
|
+
const start = Math.max(0, opts.start ?? 0);
|
|
127
|
+
const endRaw = opts.end ?? meta.duration;
|
|
128
|
+
const end = Math.min(meta.duration > 0 ? meta.duration : endRaw, endRaw);
|
|
129
|
+
if (end <= start)
|
|
130
|
+
throw new Error(`Invalid window: start=${start}s end=${end}s (duration=${meta.duration}s).`);
|
|
131
|
+
const window = end - start;
|
|
132
|
+
const extractWithMode = async (curMode, useDedup) => {
|
|
133
|
+
const vfParts = [];
|
|
134
|
+
if (curMode === 'scenes') {
|
|
135
|
+
// Threshold (default 0.3) sets scene-change sensitivity. Output rate is non-uniform; -frames:v caps count.
|
|
136
|
+
vfParts.push(`select='gt(scene\\,${sceneThreshold.toFixed(3)})'`);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
const fps = Math.max(frames / window, 0.001);
|
|
140
|
+
vfParts.push(`fps=${fps}`);
|
|
141
|
+
}
|
|
142
|
+
if (useDedup)
|
|
143
|
+
vfParts.push(`mpdecimate=hi=64*12:lo=64*5:frac=0.33`);
|
|
144
|
+
vfParts.push('showinfo');
|
|
145
|
+
const vf = vfParts.join(',');
|
|
146
|
+
const args = [
|
|
147
|
+
'-hide_banner', '-loglevel', 'info',
|
|
148
|
+
'-ss', start.toFixed(3),
|
|
149
|
+
'-to', end.toFixed(3),
|
|
150
|
+
'-i', input,
|
|
151
|
+
'-vf', vf,
|
|
152
|
+
'-frames:v', String(frames),
|
|
153
|
+
'-fps_mode', 'vfr',
|
|
154
|
+
'-an', '-sn',
|
|
155
|
+
'-q:v', '2',
|
|
156
|
+
join(dir, 'frame-%03d.jpg'),
|
|
157
|
+
];
|
|
158
|
+
const { stderr, code } = await run(FFMPEG, args);
|
|
159
|
+
if (code !== 0)
|
|
160
|
+
throw new Error(`ffmpeg failed (${code}): ${stderr.split('\n').filter((l) => /error/i.test(l)).slice(-3).join(' / ').trim() || 'unknown error'}`);
|
|
161
|
+
const files = (await readdir(dir)).filter((f) => f.startsWith('frame-') && f.endsWith('.jpg')).sort();
|
|
162
|
+
const ptsTimes = parseShowinfoTimestamps(stderr);
|
|
163
|
+
// showinfo emits one line per output frame; align by index.
|
|
164
|
+
const exact = ptsTimes.length === files.length;
|
|
165
|
+
const out = [];
|
|
166
|
+
const step = window / Math.max(1, files.length);
|
|
167
|
+
for (let i = 0; i < files.length; i++) {
|
|
168
|
+
const data = await readFile(join(dir, files[i]));
|
|
169
|
+
// pts_time is offset from -ss seek point; add start for wall-clock.
|
|
170
|
+
const ts = exact ? start + ptsTimes[i] : start + step * (i + 0.5);
|
|
171
|
+
out.push({ data, timestampSec: ts, approximate: !exact });
|
|
172
|
+
}
|
|
173
|
+
return { frames: out, approximate: !exact };
|
|
174
|
+
};
|
|
175
|
+
const cleanFrames = async () => {
|
|
176
|
+
for (const f of await readdir(dir)) {
|
|
177
|
+
if (f.startsWith('frame-'))
|
|
178
|
+
await rm(join(dir, f), { force: true });
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
let extracted = await extractWithMode(mode, dedup);
|
|
182
|
+
let dedupApplied = dedup;
|
|
183
|
+
let effectiveMode = mode;
|
|
184
|
+
// Scenes mode with no scene changes detected → fall back to uniform.
|
|
185
|
+
if (extracted.frames.length === 0 && mode === 'scenes') {
|
|
186
|
+
await cleanFrames();
|
|
187
|
+
effectiveMode = 'uniform';
|
|
188
|
+
extracted = await extractWithMode('uniform', dedup);
|
|
189
|
+
}
|
|
190
|
+
// Dedup killed everything → retry without dedup.
|
|
191
|
+
if (extracted.frames.length === 0 && dedup) {
|
|
192
|
+
await cleanFrames();
|
|
193
|
+
extracted = await extractWithMode(effectiveMode, false);
|
|
194
|
+
dedupApplied = false;
|
|
195
|
+
}
|
|
196
|
+
const result = {
|
|
197
|
+
meta,
|
|
198
|
+
frames: extracted.frames,
|
|
199
|
+
effectiveStart: start,
|
|
200
|
+
effectiveEnd: end,
|
|
201
|
+
dedupApplied,
|
|
202
|
+
mode: effectiveMode,
|
|
203
|
+
approximateTimestamps: extracted.approximate,
|
|
204
|
+
};
|
|
205
|
+
cacheSet(cacheKey, result);
|
|
206
|
+
return result;
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
await rm(dir, { recursive: true, force: true });
|
|
210
|
+
}
|
|
211
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stubbedev/atlassian-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "MCP server for self-hosted Jira and Bitbucket",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -27,8 +27,13 @@
|
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
30
|
+
"@napi-rs/canvas": "^0.1.100",
|
|
30
31
|
"dotenv": "^17.4.2",
|
|
31
|
-
"
|
|
32
|
+
"ffmpeg-static": "^5.3.0",
|
|
33
|
+
"ffprobe-static": "^3.1.0",
|
|
34
|
+
"pdfjs-dist": "^5.6.205",
|
|
35
|
+
"sharp": "^0.34.5",
|
|
36
|
+
"unpdf": "^1.6.2"
|
|
32
37
|
},
|
|
33
38
|
"devDependencies": {
|
|
34
39
|
"@types/node": "^25.6.0",
|