@remcp/runtime 0.2.107 → 0.2.109
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/CHANGELOG.md +13 -0
- package/README.md +14 -2
- package/package.json +2 -2
- package/src/catalog.mjs +8 -0
- package/src/compact-catalog.mjs +2 -2
- package/src/extended/browser.mjs +384 -40
- package/src/extended/catalog.mjs +11 -8
- package/src/extended/desktop.mjs +14 -10
- package/src/extended/diagnostics.mjs +88 -27
- package/src/extended/documents.mjs +252 -82
- package/src/index.mjs +2 -2
- package/src/mcp-v2.mjs +7 -3
- package/src/screenshot-portal.mjs +115 -6
- package/src/sessions.mjs +4 -0
- package/src/tools/files.mjs +738 -288
- package/src/tools/search.mjs +84 -41
- package/src/util.mjs +4 -0
|
@@ -2,8 +2,9 @@ import path from 'node:path';
|
|
|
2
2
|
import process from 'node:process';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { constants } from 'node:fs';
|
|
5
|
-
import { copyFile, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { copyFile, link, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
|
|
6
6
|
import { readDocxText, readPdfText } from '../documents.mjs';
|
|
7
|
+
import { openDirectoryPath } from '../tools/files.mjs';
|
|
7
8
|
import { resolveSafePath, text } from '../util.mjs';
|
|
8
9
|
import {
|
|
9
10
|
clamp,
|
|
@@ -29,28 +30,118 @@ function xmlUnescape(value) {
|
|
|
29
30
|
const MAX_OOXML_ENTRIES = 10_000;
|
|
30
31
|
const MAX_OOXML_UNCOMPRESSED_BYTES = 512 * 1024 * 1024;
|
|
31
32
|
const MAX_DOCUMENT_BYTES = 128 * 1024 * 1024;
|
|
33
|
+
const DOCUMENT_READ_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0);
|
|
34
|
+
|
|
35
|
+
function descriptorAnchorsAvailable() {
|
|
36
|
+
return process.platform === 'linux' && Boolean(constants.O_NOFOLLOW) && Boolean(constants.O_DIRECTORY);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function openDocumentSource(filePath) {
|
|
40
|
+
if (!descriptorAnchorsAvailable()) {
|
|
41
|
+
const handle = await open(filePath, DOCUMENT_READ_FLAGS);
|
|
42
|
+
try {
|
|
43
|
+
const info = await handle.stat();
|
|
44
|
+
if (!info.isFile()) throw new Error('Document path must be a regular file');
|
|
45
|
+
return { handle, close: () => handle.close() };
|
|
46
|
+
} catch (error) {
|
|
47
|
+
await handle.close().catch(() => {});
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const parent = await openDirectoryPath(path.dirname(filePath));
|
|
52
|
+
let handle;
|
|
53
|
+
try {
|
|
54
|
+
handle = await open(path.join(parent.anchor, path.basename(filePath)), DOCUMENT_READ_FLAGS);
|
|
55
|
+
const info = await handle.stat();
|
|
56
|
+
if (!info.isFile()) throw new Error('Document path must be a regular file');
|
|
57
|
+
return {
|
|
58
|
+
handle,
|
|
59
|
+
close: async () => {
|
|
60
|
+
try { await handle.close(); }
|
|
61
|
+
finally { await parent.close().catch(() => {}); }
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (handle) await handle.close().catch(() => {});
|
|
66
|
+
await parent.close().catch(() => {});
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function openDocumentDirectoryPath(directory, { create = false } = {}) {
|
|
72
|
+
const opened = await openDirectoryPath(directory, { create });
|
|
73
|
+
return { ...opened, descriptorBound:opened.anchor !== directory || process.platform === 'linux' };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function createAnchoredTempDirectory(parent, prefix) {
|
|
77
|
+
const directory = await openDocumentDirectoryPath(parent, { create:true });
|
|
78
|
+
try {
|
|
79
|
+
const childAnchor = directory.descriptorBound === false
|
|
80
|
+
? directory.anchor
|
|
81
|
+
: directory.anchor.replace('/proc/self/fd/', `/proc/${process.pid}/fd/`);
|
|
82
|
+
const directoryPath = await mkdtemp(path.join(childAnchor, prefix));
|
|
83
|
+
return { path:directoryPath, close:directory.close };
|
|
84
|
+
} catch (error) {
|
|
85
|
+
await directory.close().catch(() => {});
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function readBounded(handle, maximum, label) {
|
|
91
|
+
const chunks = [];
|
|
92
|
+
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
|
93
|
+
let offset = 0;
|
|
94
|
+
for (;;) {
|
|
95
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
|
|
96
|
+
if (!bytesRead) break;
|
|
97
|
+
if (!Number.isSafeInteger(offset + bytesRead) || offset + bytesRead > maximum) {
|
|
98
|
+
throw new Error(`${label} exceeds the ${maximum}-byte safety limit`);
|
|
99
|
+
}
|
|
100
|
+
chunks.push(Buffer.from(buffer.subarray(0, bytesRead)));
|
|
101
|
+
offset += bytesRead;
|
|
102
|
+
}
|
|
103
|
+
return Buffer.concat(chunks, offset);
|
|
104
|
+
}
|
|
32
105
|
|
|
33
106
|
async function readDocumentFile(filePath) {
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
107
|
+
const opened = await openDocumentSource(filePath);
|
|
108
|
+
try {
|
|
109
|
+
const info = await opened.handle.stat();
|
|
110
|
+
if (!info.isFile()) throw new Error('Document path must be a regular file');
|
|
111
|
+
if (info.size > MAX_DOCUMENT_BYTES) throw new Error(`Document is too large (maximum ${MAX_DOCUMENT_BYTES} bytes)`);
|
|
112
|
+
return await readBounded(opened.handle, MAX_DOCUMENT_BYTES, 'Document');
|
|
113
|
+
} finally {
|
|
114
|
+
await opened.close();
|
|
115
|
+
}
|
|
38
116
|
}
|
|
39
117
|
|
|
40
118
|
async function copyRegularNoFollow(source, destination) {
|
|
41
|
-
const
|
|
119
|
+
const opened = await openDocumentSource(source);
|
|
42
120
|
try {
|
|
43
|
-
const info = await
|
|
121
|
+
const info = await opened.handle.stat();
|
|
44
122
|
if (!info.isFile()) throw new Error('Document path must be a regular file');
|
|
45
123
|
if (info.size > MAX_DOCUMENT_BYTES) throw new Error(`Document is too large (maximum ${MAX_DOCUMENT_BYTES} bytes)`);
|
|
46
124
|
const destinationHandle = await open(destination, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
|
|
47
125
|
try {
|
|
48
|
-
await
|
|
126
|
+
const data = await readBounded(opened.handle, MAX_DOCUMENT_BYTES, 'Document');
|
|
127
|
+
await destinationHandle.writeFile(data);
|
|
49
128
|
} finally {
|
|
50
129
|
await destinationHandle.close();
|
|
51
130
|
}
|
|
52
131
|
} finally {
|
|
53
|
-
await
|
|
132
|
+
await opened.close();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function snapshotDocumentFile(filePath) {
|
|
137
|
+
const dir = await tempDir('remcp-pdf-input-');
|
|
138
|
+
const snapshot = path.join(dir, `input-${randomUUID()}.pdf`);
|
|
139
|
+
try {
|
|
140
|
+
await copyRegularNoFollow(filePath, snapshot);
|
|
141
|
+
return { dir, path: snapshot };
|
|
142
|
+
} catch (error) {
|
|
143
|
+
await removeTemp(dir);
|
|
144
|
+
throw error;
|
|
54
145
|
}
|
|
55
146
|
}
|
|
56
147
|
|
|
@@ -92,6 +183,8 @@ async function validateZipArchive(source) {
|
|
|
92
183
|
if (entries.length > MAX_OOXML_ENTRIES) throw new Error(`OOXML archive has too many entries (${entries.length}; max ${MAX_OOXML_ENTRIES})`);
|
|
93
184
|
const unsafe = entries.find(name => !safeArchiveEntry(name));
|
|
94
185
|
if (unsafe) throw new Error(`OOXML archive contains an unsafe path: ${unsafe}`);
|
|
186
|
+
const details = await runFile('unzip', ['-Z','-v',source], { label:'inspect OOXML entry types', timeout:30_000, maxBuffer:16 * 1024 * 1024 });
|
|
187
|
+
if (/symbolic link|Unix file attributes \([^)]*\b12\d{4}/i.test(details.stdout)) throw new Error('OOXML archive contains a symbolic link');
|
|
95
188
|
const totals = await runFile('unzip', ['-Z','-t',source], { label:'inspect OOXML archive size', timeout:30_000 });
|
|
96
189
|
const uncompressed = Number(totals.stdout.match(/(?:^|,\s)([0-9]+) bytes uncompressed/)?.[1] || 0);
|
|
97
190
|
if (uncompressed > MAX_OOXML_UNCOMPRESSED_BYTES) throw new Error(`OOXML archive expands to ${uncompressed} bytes; max ${MAX_OOXML_UNCOMPRESSED_BYTES}`);
|
|
@@ -126,23 +219,35 @@ async function createZip(sourceDir, destination) {
|
|
|
126
219
|
await runFile('zip', ['-qr',destination,'.'], { cwd:sourceDir, label:'create OOXML archive', timeout:30_000 });
|
|
127
220
|
}
|
|
128
221
|
|
|
129
|
-
async function commitDocumentOutput(staged, destination) {
|
|
222
|
+
async function commitDocumentOutput(staged, destination, { noReplace = false } = {}) {
|
|
223
|
+
const parentDirectory = await openDocumentDirectoryPath(path.dirname(destination));
|
|
224
|
+
const parentAnchor = parentDirectory.anchor;
|
|
225
|
+
const destinationPath = path.join(parentAnchor, path.basename(destination));
|
|
130
226
|
try {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
227
|
+
if (noReplace) {
|
|
228
|
+
await link(staged, destinationPath);
|
|
229
|
+
await unlink(staged);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
await rename(staged, destinationPath);
|
|
234
|
+
return;
|
|
235
|
+
} catch (error) {
|
|
236
|
+
if (!['EEXIST', 'EPERM', 'ENOTEMPTY'].includes(error?.code)) throw error;
|
|
237
|
+
}
|
|
238
|
+
const backup = path.join(parentAnchor, `.${path.basename(destination)}.remcp-old-${randomUUID()}`);
|
|
239
|
+
let moved = false;
|
|
240
|
+
try {
|
|
241
|
+
await rename(destinationPath, backup);
|
|
242
|
+
moved = true;
|
|
243
|
+
await rename(staged, destinationPath);
|
|
244
|
+
await rm(backup, { force: true }).catch(() => {});
|
|
245
|
+
} catch (error) {
|
|
246
|
+
if (moved && !await lstat(destinationPath).catch(() => null)) await rename(backup, destinationPath).catch(() => {});
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
} finally {
|
|
250
|
+
await parentDirectory.close().catch(() => {});
|
|
146
251
|
}
|
|
147
252
|
}
|
|
148
253
|
|
|
@@ -229,9 +334,10 @@ function readWorksheetCells(xml, sharedStrings, maxCells) {
|
|
|
229
334
|
}
|
|
230
335
|
|
|
231
336
|
async function readXlsx(filePath, args) {
|
|
337
|
+
const source = await snapshotDocumentFile(filePath);
|
|
232
338
|
const dir = await tempDir('remcp-xlsx-read-');
|
|
233
339
|
try {
|
|
234
|
-
await extractZip(
|
|
340
|
+
await extractZip(source.path, dir);
|
|
235
341
|
const workbook = await readFile(path.join(dir,'xl','workbook.xml'),'utf8');
|
|
236
342
|
const rels = await readFile(path.join(dir,'xl','_rels','workbook.xml.rels'),'utf8');
|
|
237
343
|
const sheet = workbookSheetPath(workbook, rels, optionalString(args.sheet));
|
|
@@ -240,21 +346,31 @@ async function readXlsx(filePath, args) {
|
|
|
240
346
|
try { shared = readSharedStrings(await readFile(path.join(dir,'xl','sharedStrings.xml'),'utf8')); } catch {}
|
|
241
347
|
const cells = readWorksheetCells(worksheet, shared, clamp(args.max_cells,5000,1,50_000));
|
|
242
348
|
return { kind:'xlsx', sheet:sheet.name, count:cells.length, cells };
|
|
243
|
-
} finally {
|
|
349
|
+
} finally {
|
|
350
|
+
await removeTemp(source.dir);
|
|
351
|
+
await removeTemp(dir);
|
|
352
|
+
}
|
|
244
353
|
}
|
|
245
354
|
|
|
246
|
-
async function readPdfDocument(
|
|
355
|
+
async function readPdfDocument(data) {
|
|
247
356
|
try {
|
|
248
357
|
return readPdfText(data);
|
|
249
358
|
} catch (builtinError) {
|
|
250
359
|
if (commandExists('pdftotext')) {
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
360
|
+
const dir = await tempDir('remcp-pdf-read-');
|
|
361
|
+
try {
|
|
362
|
+
const snapshot = path.join(dir, 'input.pdf');
|
|
363
|
+
await writeFile(snapshot, data, { mode: 0o600 });
|
|
364
|
+
const extracted = await runFile('pdftotext', ['-enc','UTF-8',snapshot,'-'], {
|
|
365
|
+
label:'PDF text extraction',
|
|
366
|
+
timeout:60_000,
|
|
367
|
+
allowFailure:true,
|
|
368
|
+
});
|
|
369
|
+
const fallbackText = String(extracted.stdout || '').replace(/\f/g, '\n').trim();
|
|
370
|
+
if (extracted.code === 0 && fallbackText) return fallbackText;
|
|
371
|
+
} finally {
|
|
372
|
+
await removeTemp(dir);
|
|
373
|
+
}
|
|
258
374
|
}
|
|
259
375
|
throw builtinError;
|
|
260
376
|
}
|
|
@@ -265,7 +381,7 @@ export async function readDocument(args) {
|
|
|
265
381
|
const lower = filePath.toLowerCase();
|
|
266
382
|
const data = await readDocumentFile(filePath);
|
|
267
383
|
if (lower.endsWith('.docx')) return text(readDocxText(data));
|
|
268
|
-
if (lower.endsWith('.pdf')) return text(await readPdfDocument(
|
|
384
|
+
if (lower.endsWith('.pdf')) return text(await readPdfDocument(data));
|
|
269
385
|
if (lower.endsWith('.xlsx')) return jsonResult(await readXlsx(filePath,args));
|
|
270
386
|
if (/\.(txt|md|csv|json|xml|yaml|yml)$/i.test(lower)) return text(data.toString('utf8'));
|
|
271
387
|
throw new Error('read_document supports PDF, DOCX, XLSX, TXT, Markdown, CSV, JSON, XML, YAML');
|
|
@@ -351,10 +467,11 @@ export async function editSpreadsheet(args) {
|
|
|
351
467
|
const output = args.output ? await resolveSafePath(args.output,'output') : filePath;
|
|
352
468
|
const dir = await tempDir(create ? 'remcp-xlsx-create-' : 'remcp-xlsx-edit-');
|
|
353
469
|
let outputStage = '';
|
|
470
|
+
let outputStageDirectory = null;
|
|
354
471
|
let tempOut = '';
|
|
355
472
|
try {
|
|
356
|
-
await
|
|
357
|
-
outputStage =
|
|
473
|
+
outputStageDirectory = await createAnchoredTempDirectory(path.dirname(output), '.remcp-xlsx-output-');
|
|
474
|
+
outputStage = outputStageDirectory.path;
|
|
358
475
|
tempOut = path.join(outputStage, path.basename(output));
|
|
359
476
|
if (create) {
|
|
360
477
|
await ensureCreateTargetIsNew(filePath);
|
|
@@ -370,11 +487,13 @@ export async function editSpreadsheet(args) {
|
|
|
370
487
|
for (const edit of edits) xml = setWorksheetCell(xml,String(edit.cell || '').toUpperCase(),edit.value,edit.formula);
|
|
371
488
|
await writeFile(worksheetPath,xml,'utf8');
|
|
372
489
|
await createZip(dir,tempOut);
|
|
373
|
-
|
|
374
|
-
|
|
490
|
+
await commitDocumentOutput(tempOut,output,{ noReplace:create });
|
|
491
|
+
return jsonResult({ path:output, sheet:sheet.name, edited_cells:edits.length, bytes:(await stat(output)).size, created:create });
|
|
492
|
+
|
|
375
493
|
} finally {
|
|
376
494
|
if (tempOut) await rm(tempOut,{force:true}).catch(() => {});
|
|
377
495
|
if (outputStage) await rm(outputStage,{recursive:true, force:true}).catch(() => {});
|
|
496
|
+
if (outputStageDirectory) await outputStageDirectory.close().catch(() => {});
|
|
378
497
|
await removeTemp(dir);
|
|
379
498
|
}
|
|
380
499
|
}
|
|
@@ -436,11 +555,12 @@ export async function editDocument(args) {
|
|
|
436
555
|
const output = args.output ? await resolveSafePath(args.output,'output') : filePath;
|
|
437
556
|
const dir = await tempDir(create ? 'remcp-docx-create-' : 'remcp-docx-edit-');
|
|
438
557
|
let outputStage = '';
|
|
558
|
+
let outputStageDirectory = null;
|
|
439
559
|
let tempOut = '';
|
|
440
560
|
let changes = 0;
|
|
441
561
|
try {
|
|
442
|
-
await
|
|
443
|
-
outputStage =
|
|
562
|
+
outputStageDirectory = await createAnchoredTempDirectory(path.dirname(output), '.remcp-docx-output-');
|
|
563
|
+
outputStage = outputStageDirectory.path;
|
|
444
564
|
tempOut = path.join(outputStage, path.basename(output));
|
|
445
565
|
if (create) {
|
|
446
566
|
await ensureCreateTargetIsNew(filePath);
|
|
@@ -467,11 +587,13 @@ export async function editDocument(args) {
|
|
|
467
587
|
}
|
|
468
588
|
await writeFile(documentPath,xml,'utf8');
|
|
469
589
|
await createZip(dir,tempOut);
|
|
470
|
-
|
|
471
|
-
|
|
590
|
+
await commitDocumentOutput(tempOut,output,{ noReplace:create });
|
|
591
|
+
return jsonResult({ path:output, operations:operations.length, changes, bytes:(await stat(output)).size, created:create });
|
|
592
|
+
|
|
472
593
|
} finally {
|
|
473
594
|
if (tempOut) await rm(tempOut,{force:true}).catch(() => {});
|
|
474
595
|
if (outputStage) await rm(outputStage,{recursive:true, force:true}).catch(() => {});
|
|
596
|
+
if (outputStageDirectory) await outputStageDirectory.close().catch(() => {});
|
|
475
597
|
await removeTemp(dir);
|
|
476
598
|
}
|
|
477
599
|
}
|
|
@@ -524,62 +646,110 @@ export async function pdfAction(args) {
|
|
|
524
646
|
}
|
|
525
647
|
if (action === 'info') {
|
|
526
648
|
const source = await resolveSafePath(args.path,'path');
|
|
527
|
-
if (commandExists('pdfinfo'))
|
|
528
|
-
|
|
649
|
+
if (commandExists('pdfinfo')) {
|
|
650
|
+
const snapshot = await snapshotDocumentFile(source);
|
|
651
|
+
try { return text((await runFile('pdfinfo',[snapshot.path],{label:'pdfinfo'})).stdout); }
|
|
652
|
+
finally { await removeTemp(snapshot.dir); }
|
|
653
|
+
}
|
|
654
|
+
const data = await readDocumentFile(source);
|
|
655
|
+
return jsonResult({ path:source, bytes:data.length, annotation_count:pdfAnnotations(data).length });
|
|
529
656
|
}
|
|
530
657
|
if (action === 'merge') {
|
|
531
658
|
const requested = Array.isArray(args.paths) ? args.paths : [];
|
|
532
659
|
if (requested.length < 2 || requested.length > 100) throw new Error('merge requires 2..100 PDFs in paths');
|
|
533
|
-
const sources=[];
|
|
660
|
+
const sources=[];
|
|
661
|
+
for(const value of requested) sources.push(await resolveSafePath(value,'paths'));
|
|
534
662
|
const output=await resolveSafePath(args.output,'output');
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
663
|
+
const inputDir=await tempDir('remcp-pdf-merge-input-');
|
|
664
|
+
const outputStageDirectory=await createAnchoredTempDirectory(path.dirname(output), '.remcp-pdf-output-');
|
|
665
|
+
const outputDir=outputStageDirectory.path;
|
|
666
|
+
const staged=path.join(outputDir,path.basename(output));
|
|
667
|
+
try {
|
|
668
|
+
const snapshots=[];
|
|
669
|
+
for(let index=0;index<sources.length;index+=1){
|
|
670
|
+
const snapshot=path.join(inputDir,`input-${index}.pdf`);
|
|
671
|
+
await copyRegularNoFollow(sources[index],snapshot);
|
|
672
|
+
snapshots.push(snapshot);
|
|
673
|
+
}
|
|
674
|
+
if(commandExists('pdfunite')) await runFile('pdfunite',[...snapshots,staged],{label:'PDF merge',timeout:60_000});
|
|
675
|
+
else if(commandExists('qpdf')) await runFile('qpdf',['--empty','--pages',...snapshots,'--',staged],{label:'PDF merge',timeout:60_000});
|
|
676
|
+
else unavailable('PDF merge','install poppler-utils (pdfunite) or qpdf');
|
|
677
|
+
await commitDocumentOutput(staged,output);
|
|
678
|
+
return jsonResult({action,output,inputs:sources.length,bytes:(await stat(output)).size});
|
|
679
|
+
} finally {
|
|
680
|
+
await removeTemp(inputDir);
|
|
681
|
+
await rm(outputDir,{recursive:true,force:true}).catch(()=>{});
|
|
682
|
+
await outputStageDirectory.close().catch(()=>{});
|
|
683
|
+
}
|
|
539
684
|
}
|
|
540
685
|
const source=await resolveSafePath(args.path,'path');
|
|
541
686
|
if(action==='split'){
|
|
542
687
|
const requestedOutputDir=await resolveSafePath(args.output_dir || path.dirname(source),'output_dir');
|
|
543
|
-
await mkdir(requestedOutputDir,{recursive:true});
|
|
544
688
|
const outputDir=await resolveSafePath(requestedOutputDir,'output_dir');
|
|
545
689
|
const patternName=safePdfPattern(optionalString(args.pattern),`${path.basename(source,path.extname(source))}-%d.pdf`);
|
|
546
|
-
const
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
690
|
+
const snapshot=await snapshotDocumentFile(source);
|
|
691
|
+
const work=await tempDir('remcp-pdf-split-');
|
|
692
|
+
const stageDirectory=await createAnchoredTempDirectory(outputDir, '.remcp-pdf-output-');
|
|
693
|
+
const stageDir=stageDirectory.path;
|
|
694
|
+
try {
|
|
695
|
+
const pattern=path.join(work,patternName);
|
|
696
|
+
if(commandExists('pdfseparate')) await runFile('pdfseparate',[snapshot.path,pattern],{label:'PDF split',timeout:60_000});
|
|
697
|
+
else unavailable('PDF split','install poppler-utils (pdfseparate)');
|
|
698
|
+
for(const name of await readdir(work)){
|
|
699
|
+
if(!name.toLowerCase().endsWith('.pdf')) continue;
|
|
700
|
+
const staged=path.join(stageDir,name);
|
|
701
|
+
await copyFile(path.join(work,name),staged,constants.COPYFILE_EXCL);
|
|
702
|
+
await commitDocumentOutput(staged,path.join(outputDir,name));
|
|
703
|
+
}
|
|
704
|
+
return jsonResult({action,output_dir:outputDir,files:(await readdir(outputDir)).filter(name=>name.toLowerCase().endsWith('.pdf')).sort()});
|
|
705
|
+
} finally {
|
|
706
|
+
await removeTemp(snapshot.dir);
|
|
707
|
+
await removeTemp(work);
|
|
708
|
+
await rm(stageDir,{recursive:true,force:true}).catch(()=>{});
|
|
709
|
+
await stageDirectory.close().catch(()=>{});
|
|
710
|
+
}
|
|
550
711
|
}
|
|
551
712
|
const pages=optionalString(args.pages);
|
|
552
713
|
const selectedPages=expandPdfPages(pages);
|
|
553
714
|
const normalizedPages=selectedPages.join(',');
|
|
554
715
|
const output=await resolveSafePath(args.output,'output');
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
extracted
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
716
|
+
const snapshot=await snapshotDocumentFile(source);
|
|
717
|
+
const stageDirectory=await createAnchoredTempDirectory(path.dirname(output), '.remcp-pdf-output-');
|
|
718
|
+
const stageDir=stageDirectory.path;
|
|
719
|
+
const staged=path.join(stageDir,path.basename(output));
|
|
720
|
+
try {
|
|
721
|
+
if(commandExists('qpdf')) {
|
|
722
|
+
await runFile('qpdf',[snapshot.path,'--pages','.',pages.replace(/\s+/g,''),'--',staged],{label:'PDF extract pages',timeout:60_000});
|
|
723
|
+
} else if(commandExists('pdftk')) {
|
|
724
|
+
await runFile('pdftk',[snapshot.path,'cat',...pages.replace(/\s+/g,'').split(','),'output',staged],{label:'PDF extract pages',timeout:60_000});
|
|
725
|
+
} else if(commandExists('pdfseparate')) {
|
|
726
|
+
const work=await tempDir('remcp-pdf-pages-');
|
|
727
|
+
try {
|
|
728
|
+
const extracted=[];
|
|
729
|
+
for(let index=0;index<selectedPages.length;index+=1){
|
|
730
|
+
const page=selectedPages[index];
|
|
731
|
+
const pattern=path.join(work,`selection-${index+1}-%d.pdf`);
|
|
732
|
+
await runFile('pdfseparate',['-f',String(page),'-l',String(page),snapshot.path,pattern],{label:`PDF extract page ${page}`,timeout:60_000});
|
|
733
|
+
extracted.push(path.join(work,`selection-${index+1}-${page}.pdf`));
|
|
734
|
+
}
|
|
735
|
+
if(extracted.length===1) {
|
|
736
|
+
await copyFile(extracted[0],staged,constants.COPYFILE_EXCL);
|
|
737
|
+
} else if(commandExists('pdfunite')) {
|
|
738
|
+
await runFile('pdfunite',[...extracted,staged],{label:'PDF assemble extracted pages',timeout:60_000});
|
|
739
|
+
} else {
|
|
740
|
+
unavailable('PDF page extraction','pdfunite is required with pdfseparate when extracting multiple pages');
|
|
741
|
+
}
|
|
742
|
+
} finally { await removeTemp(work); }
|
|
743
|
+
} else {
|
|
744
|
+
unavailable('PDF page extraction','install qpdf, pdftk, or poppler-utils (pdfseparate; pdfunite for multiple pages)');
|
|
578
745
|
}
|
|
579
|
-
|
|
580
|
-
|
|
746
|
+
await commitDocumentOutput(staged,output);
|
|
747
|
+
return jsonResult({action,source,pages:normalizedPages,output,bytes:(await stat(output)).size});
|
|
748
|
+
} finally {
|
|
749
|
+
await removeTemp(snapshot.dir);
|
|
750
|
+
await rm(stageDir,{recursive:true,force:true}).catch(()=>{});
|
|
751
|
+
await stageDirectory.close().catch(()=>{});
|
|
581
752
|
}
|
|
582
|
-
return jsonResult({action,source,pages:normalizedPages,output,bytes:(await stat(output)).size});
|
|
583
753
|
}
|
|
584
754
|
|
|
585
755
|
export const documentHandlers = {
|
package/src/index.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import process from 'node:process';
|
|
3
3
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import { advertisedTools as advertisedCoreTools, toolDefinitions as coreToolDefinitions } from './catalog.mjs';
|
|
5
|
+
import { advertisedTools as advertisedCoreTools, supportedCoreTools, toolDefinitions as coreToolDefinitions } from './catalog.mjs';
|
|
6
6
|
import { allExtendedTools, advertisedExtendedTools, extendedToolDefinitions } from './extended/catalog.mjs';
|
|
7
7
|
import { describeConfig, configurationError, runtimeConfigDir } from './config.mjs';
|
|
8
8
|
import { shutdownSessions, startSessionSweeper } from './sessions.mjs';
|
|
@@ -13,7 +13,7 @@ const args = process.argv.slice(2);
|
|
|
13
13
|
const allToolDefinitions = [...coreToolDefinitions, ...extendedToolDefinitions];
|
|
14
14
|
|
|
15
15
|
async function advertisedRuntimeTools() {
|
|
16
|
-
return [...
|
|
16
|
+
return [...supportedCoreTools(), ...await advertisedExtendedTools()];
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
async function writeStdout(value) {
|
package/src/mcp-v2.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import process from 'node:process';
|
|
2
|
-
import { toolDefinitions, TEXT_OUTPUT_SCHEMA } from './catalog.mjs';
|
|
2
|
+
import { supportedCoreTools, toolDefinitions, TEXT_OUTPUT_SCHEMA } from './catalog.mjs';
|
|
3
3
|
import { advertisedExtendedTools, extendedToolDefinitions } from './extended/catalog.mjs';
|
|
4
4
|
import { invokeTool } from './invoke.mjs';
|
|
5
5
|
|
|
@@ -26,7 +26,10 @@ export async function startRuntimeMcpServer({ version, instructions, onError = (
|
|
|
26
26
|
import('@modelcontextprotocol/server/stdio'),
|
|
27
27
|
]);
|
|
28
28
|
|
|
29
|
-
const initiallySupported = new Set(
|
|
29
|
+
const initiallySupported = new Set([
|
|
30
|
+
...supportedCoreTools().map(tool => tool.name),
|
|
31
|
+
...(await advertisedExtendedTools()).map(tool => tool.name),
|
|
32
|
+
]);
|
|
30
33
|
let activeServer = null;
|
|
31
34
|
let capabilityTimer = null;
|
|
32
35
|
let closed = false;
|
|
@@ -56,7 +59,8 @@ export async function startRuntimeMcpServer({ version, instructions, onError = (
|
|
|
56
59
|
async (args, context) => invokeTool(definition.name, args || {}, { signal: context?.mcpReq?.signal }),
|
|
57
60
|
);
|
|
58
61
|
registrations.set(definition.name, registered);
|
|
59
|
-
|
|
62
|
+
if (!initiallySupported.has(definition.name)) registered.disable();
|
|
63
|
+
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
let lastSupported = initiallySupported;
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import process from 'node:process';
|
|
2
|
-
import
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { constants } from 'node:fs';
|
|
5
|
+
import { lstat, open, rename, unlink } from 'node:fs/promises';
|
|
3
6
|
import { fileURLToPath } from 'node:url';
|
|
4
7
|
|
|
5
8
|
const PORTAL_NAME = 'org.freedesktop.portal.Desktop';
|
|
@@ -133,6 +136,115 @@ function createResponseCollector(bus, dbus, initialPath, timeoutMs) {
|
|
|
133
136
|
};
|
|
134
137
|
}
|
|
135
138
|
|
|
139
|
+
async function openDirectoryNoFollow(directory) {
|
|
140
|
+
if (process.platform !== 'linux' || !constants.O_NOFOLLOW || !constants.O_DIRECTORY) {
|
|
141
|
+
const info = await lstat(directory);
|
|
142
|
+
if (info.isSymbolicLink() || !info.isDirectory()) throw new Error('screenshot path parent is not a safe directory');
|
|
143
|
+
return { anchor:directory, close:async() => {} };
|
|
144
|
+
}
|
|
145
|
+
const descriptor = directory.match(/^(\/proc\/(?:self|\d+)\/fd\/\d+)(?:\/(.*))?$/);
|
|
146
|
+
const directories = [];
|
|
147
|
+
let current;
|
|
148
|
+
try {
|
|
149
|
+
if (descriptor) {
|
|
150
|
+
const base = await open(`${descriptor[1]}/.`, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
151
|
+
directories.push(base);
|
|
152
|
+
current = `/proc/self/fd/${base.fd}`;
|
|
153
|
+
} else {
|
|
154
|
+
const root = path.parse(directory).root;
|
|
155
|
+
current = root;
|
|
156
|
+
const parts = directory.slice(root.length).split(path.sep).filter(Boolean);
|
|
157
|
+
if (!parts.length) {
|
|
158
|
+
const handle = await open(root, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
159
|
+
return { anchor:`/proc/self/fd/${handle.fd}`, close:() => handle.close() };
|
|
160
|
+
}
|
|
161
|
+
for (const part of parts) {
|
|
162
|
+
const handle = await open(path.join(current, part), constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
163
|
+
const info = await handle.stat();
|
|
164
|
+
if (!info.isDirectory()) {
|
|
165
|
+
await handle.close().catch(() => {});
|
|
166
|
+
throw new Error('screenshot path parent is not a safe directory');
|
|
167
|
+
}
|
|
168
|
+
directories.push(handle);
|
|
169
|
+
current = `/proc/self/fd/${handle.fd}`;
|
|
170
|
+
}
|
|
171
|
+
const handle = directories.pop();
|
|
172
|
+
return {
|
|
173
|
+
anchor:current,
|
|
174
|
+
close:async() => {
|
|
175
|
+
try { await handle.close(); }
|
|
176
|
+
finally { for (const entry of directories.reverse()) await entry.close().catch(() => {}); }
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
for (const part of (descriptor?.[2] || '').split(path.sep).filter(Boolean)) {
|
|
181
|
+
const handle = await open(path.join(current, part), constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
182
|
+
const info = await handle.stat();
|
|
183
|
+
if (!info.isDirectory()) {
|
|
184
|
+
await handle.close().catch(() => {});
|
|
185
|
+
throw new Error('screenshot path parent is not a safe directory');
|
|
186
|
+
}
|
|
187
|
+
directories.push(handle);
|
|
188
|
+
current = `/proc/self/fd/${handle.fd}`;
|
|
189
|
+
}
|
|
190
|
+
const handle = directories.pop();
|
|
191
|
+
return {
|
|
192
|
+
anchor:current,
|
|
193
|
+
close:async() => {
|
|
194
|
+
try { await handle.close(); }
|
|
195
|
+
finally { for (const entry of directories.reverse()) await entry.close().catch(() => {}); }
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
} catch (error) {
|
|
199
|
+
for (const entry of directories.reverse()) await entry.close().catch(() => {});
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function copyPortalFile(source, destination) {
|
|
205
|
+
let sourceParent;
|
|
206
|
+
let destinationParent;
|
|
207
|
+
let input;
|
|
208
|
+
let output;
|
|
209
|
+
let temporary;
|
|
210
|
+
try {
|
|
211
|
+
sourceParent = await openDirectoryNoFollow(path.dirname(source));
|
|
212
|
+
destinationParent = await openDirectoryNoFollow(path.dirname(destination));
|
|
213
|
+
input = await open(path.join(sourceParent.anchor, path.basename(source)), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
214
|
+
const info = await input.stat();
|
|
215
|
+
if (!info.isFile()) throw new Error('desktop portal returned a non-regular screenshot file');
|
|
216
|
+
const destinationPath = path.join(destinationParent.anchor, path.basename(destination));
|
|
217
|
+
const existing = await lstat(destinationPath).catch(() => null);
|
|
218
|
+
if (existing?.isSymbolicLink()) throw new Error('desktop portal screenshot destination cannot be a symbolic link');
|
|
219
|
+
temporary = path.join(destinationParent.anchor, `.${path.basename(destination)}.${randomUUID()}.tmp`);
|
|
220
|
+
output = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
221
|
+
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
|
222
|
+
let offset = 0;
|
|
223
|
+
for (;;) {
|
|
224
|
+
const { bytesRead } = await input.read(buffer, 0, buffer.length, offset);
|
|
225
|
+
if (!bytesRead) break;
|
|
226
|
+
let written = 0;
|
|
227
|
+
while (written < bytesRead) {
|
|
228
|
+
const result = await output.write(buffer, written, bytesRead - written, offset + written);
|
|
229
|
+
if (!result.bytesWritten) throw new Error('Could not finish copying the desktop portal screenshot');
|
|
230
|
+
written += result.bytesWritten;
|
|
231
|
+
}
|
|
232
|
+
offset += bytesRead;
|
|
233
|
+
}
|
|
234
|
+
await output.sync();
|
|
235
|
+
await output.close();
|
|
236
|
+
output = null;
|
|
237
|
+
await rename(temporary, destinationPath);
|
|
238
|
+
temporary = null;
|
|
239
|
+
} finally {
|
|
240
|
+
if (output) await output.close().catch(() => {});
|
|
241
|
+
if (temporary) await unlink(temporary).catch(() => {});
|
|
242
|
+
if (input) await input.close().catch(() => {});
|
|
243
|
+
if (destinationParent) await destinationParent.close().catch(() => {});
|
|
244
|
+
if (sourceParent) await sourceParent.close().catch(() => {});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
136
248
|
export async function capturePortalScreenshot(destination, {
|
|
137
249
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
138
250
|
loadDbus = () => import('@jellybrick/dbus-next'),
|
|
@@ -184,12 +296,9 @@ export async function capturePortalScreenshot(destination, {
|
|
|
184
296
|
const uri = results?.uri?.value;
|
|
185
297
|
if (typeof uri !== 'string') throw new Error('desktop portal returned no screenshot URI');
|
|
186
298
|
const sourceUrl = new URL(uri);
|
|
187
|
-
if (sourceUrl.protocol !== 'file:') throw new Error(
|
|
299
|
+
if (sourceUrl.protocol !== 'file:' || (sourceUrl.hostname && sourceUrl.hostname !== 'localhost')) throw new Error('desktop portal returned an unsupported file URI');
|
|
188
300
|
const source = fileURLToPath(sourceUrl);
|
|
189
|
-
await
|
|
190
|
-
// GNOME commonly writes the portal result to ~/Pictures. The runtime owns this capture, so remove
|
|
191
|
-
// that portal-created intermediate after copying it to the caller-selected temporary location.
|
|
192
|
-
if (source !== destination) await rm(source, { force: true }).catch(() => {});
|
|
301
|
+
await copyPortalFile(source, destination);
|
|
193
302
|
return destination;
|
|
194
303
|
} catch (error) {
|
|
195
304
|
const wrapped = new Error(`xdg-desktop-portal: ${cleanError(error)}`);
|