@stubbedev/atlassian-mcp 0.4.5 → 0.5.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 +96 -42
- package/bin/cli.mjs +59 -0
- package/package.json +17 -30
- package/scripts/download.mjs +86 -0
- package/scripts/postinstall.mjs +15 -0
- package/dist/attachment.js +0 -350
- package/dist/bitbucket.js +0 -1340
- package/dist/config.js +0 -62
- package/dist/context.js +0 -162
- package/dist/git.js +0 -227
- package/dist/index.js +0 -1055
- package/dist/jira.js +0 -979
- package/dist/video.js +0 -211
package/dist/attachment.js
DELETED
|
@@ -1,350 +0,0 @@
|
|
|
1
|
-
import sharp from 'sharp';
|
|
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';
|
|
6
|
-
export const MAX_INLINE_BYTES = 10 * 1024 * 1024;
|
|
7
|
-
export const DEFAULT_MAX_DIMENSION = 1568;
|
|
8
|
-
export const DEFAULT_JPEG_QUALITY = 85;
|
|
9
|
-
export function formatBytes(bytes) {
|
|
10
|
-
if (bytes < 1024)
|
|
11
|
-
return `${bytes} B`;
|
|
12
|
-
if (bytes < 1024 * 1024)
|
|
13
|
-
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
14
|
-
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
15
|
-
}
|
|
16
|
-
export function isTextMime(mimeType) {
|
|
17
|
-
const mt = mimeType.toLowerCase();
|
|
18
|
-
if (mt.startsWith('text/'))
|
|
19
|
-
return true;
|
|
20
|
-
return [
|
|
21
|
-
'application/json',
|
|
22
|
-
'application/xml',
|
|
23
|
-
'application/javascript',
|
|
24
|
-
'application/x-yaml',
|
|
25
|
-
'application/yaml',
|
|
26
|
-
'application/x-sh',
|
|
27
|
-
'application/sql',
|
|
28
|
-
].some((m) => mt === m || mt.startsWith(`${m};`));
|
|
29
|
-
}
|
|
30
|
-
async function processImage(buffer, mimeType, opts) {
|
|
31
|
-
if (mimeType.toLowerCase() === 'image/svg+xml') {
|
|
32
|
-
return { data: buffer, mimeType, resized: false };
|
|
33
|
-
}
|
|
34
|
-
const img = sharp(buffer, { failOn: 'none' }).rotate();
|
|
35
|
-
const meta = await img.metadata();
|
|
36
|
-
const width = meta.width ?? 0;
|
|
37
|
-
const height = meta.height ?? 0;
|
|
38
|
-
const longEdge = Math.max(width, height);
|
|
39
|
-
const needsResize = longEdge > opts.maxDimension;
|
|
40
|
-
let pipeline = img;
|
|
41
|
-
if (needsResize) {
|
|
42
|
-
pipeline = pipeline.resize({
|
|
43
|
-
width: width >= height ? opts.maxDimension : undefined,
|
|
44
|
-
height: height > width ? opts.maxDimension : undefined,
|
|
45
|
-
fit: 'inside',
|
|
46
|
-
withoutEnlargement: true,
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
|
-
const hasAlpha = meta.hasAlpha ?? false;
|
|
50
|
-
if (hasAlpha) {
|
|
51
|
-
const data = await pipeline.png({ compressionLevel: 9 }).toBuffer();
|
|
52
|
-
return { data, mimeType: 'image/png', resized: needsResize };
|
|
53
|
-
}
|
|
54
|
-
const data = await pipeline.jpeg({ quality: opts.quality, mozjpeg: true }).toBuffer();
|
|
55
|
-
return { data, mimeType: 'image/jpeg', resized: needsResize };
|
|
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
|
-
}
|
|
189
|
-
export async function buildAttachmentResult(args) {
|
|
190
|
-
const { id, filename, mimeType, buffer, saveTo } = args;
|
|
191
|
-
const sizeLabel = formatBytes(buffer.length);
|
|
192
|
-
const header = `${filename} — ${mimeType}, ${sizeLabel}`;
|
|
193
|
-
if (saveTo) {
|
|
194
|
-
const path = resolvePath(saveTo);
|
|
195
|
-
await writeFile(path, buffer);
|
|
196
|
-
return { content: [{ type: 'text', text: `Saved attachment #${id} (${header}) to ${path}` }] };
|
|
197
|
-
}
|
|
198
|
-
const mt = mimeType.toLowerCase();
|
|
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
|
-
}
|
|
217
|
-
if (buffer.length > MAX_INLINE_BYTES) {
|
|
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}.` }] };
|
|
220
|
-
}
|
|
221
|
-
const maxDimension = args.maxDimension ?? DEFAULT_MAX_DIMENSION;
|
|
222
|
-
const quality = args.quality ?? DEFAULT_JPEG_QUALITY;
|
|
223
|
-
try {
|
|
224
|
-
const processed = await processImage(buffer, mimeType, { maxDimension, quality });
|
|
225
|
-
const resizedNote = processed.resized
|
|
226
|
-
? ` (resized to ${maxDimension}px long edge, re-encoded to ${formatBytes(processed.data.length)})`
|
|
227
|
-
: processed.data.length < buffer.length
|
|
228
|
-
? ` (re-encoded to ${formatBytes(processed.data.length)})`
|
|
229
|
-
: '';
|
|
230
|
-
return {
|
|
231
|
-
content: [
|
|
232
|
-
{ type: 'text', text: `Attachment #${id}: ${header}${resizedNote}` },
|
|
233
|
-
{ type: 'image', data: processed.data.toString('base64'), mimeType: processed.mimeType },
|
|
234
|
-
],
|
|
235
|
-
};
|
|
236
|
-
}
|
|
237
|
-
catch (err) {
|
|
238
|
-
return {
|
|
239
|
-
content: [{
|
|
240
|
-
type: 'text',
|
|
241
|
-
text: `${header}\nFailed to process image: ${err.message}. Pass saveTo to write the original to disk.`,
|
|
242
|
-
}],
|
|
243
|
-
};
|
|
244
|
-
}
|
|
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
|
-
}
|
|
340
|
-
if (isTextMime(mt) && buffer.length <= MAX_INLINE_BYTES) {
|
|
341
|
-
return { content: [{ type: 'text', text: `Attachment #${id}: ${header}\n\n${buffer.toString('utf-8')}` }] };
|
|
342
|
-
}
|
|
343
|
-
const path = await autoSaveOversized(id, filename, buffer);
|
|
344
|
-
return {
|
|
345
|
-
content: [{
|
|
346
|
-
type: 'text',
|
|
347
|
-
text: `${header}\nAttachment #${id} is not inline-renderable. Original saved to ${path}.`,
|
|
348
|
-
}],
|
|
349
|
-
};
|
|
350
|
-
}
|