@stubbedev/atlassian-mcp 0.3.10 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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; images are auto-resized via sharp and returned inline so the model can see them, text/JSON inline, larger/binary files via `saveTo` |
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-resized inline via sharp; text inline; binary/large via `saveTo`) |
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
- ### Native dependency: `sharp`
251
+ ### Attachment decoding pipeline
252
252
 
253
- 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`.
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
 
@@ -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
- 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
- };
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${buffer.length > MAX_INLINE_BYTES ? ` larger than ${formatBytes(MAX_INLINE_BYTES)} or` : ''} not inline-renderable. Pass saveTo=/absolute/path to write it to disk.`,
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 { buildAttachmentResult } from './attachment.js';
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(60_000),
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();
@@ -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 (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.',
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). Larger images are downscaled with sharp.' },
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; binary or >10 MB requires saveTo=/absolute/path.',
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). Larger images are downscaled with sharp.' },
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
  },
@@ -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
- return await bitbucket.getAttachment(normalizeBitbucketArgs(args));
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) {
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.10",
3
+ "version": "0.4.0",
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
- "sharp": "^0.34.5"
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",