@remcp/runtime 0.2.106 → 0.2.108

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.
@@ -1,6 +1,8 @@
1
1
  import path from 'node:path';
2
2
  import process from 'node:process';
3
- import { copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { constants } from 'node:fs';
5
+ import { copyFile, link, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
4
6
  import { readDocxText, readPdfText } from '../documents.mjs';
5
7
  import { resolveSafePath, text } from '../util.mjs';
6
8
  import {
@@ -26,6 +28,206 @@ function xmlUnescape(value) {
26
28
 
27
29
  const MAX_OOXML_ENTRIES = 10_000;
28
30
  const MAX_OOXML_UNCOMPRESSED_BYTES = 512 * 1024 * 1024;
31
+ const MAX_DOCUMENT_BYTES = 128 * 1024 * 1024;
32
+ const DOCUMENT_READ_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0);
33
+ const DOCUMENT_DIRECTORY_FLAGS = constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (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 root = path.parse(filePath).root;
52
+ const parts = filePath.slice(root.length).split(path.sep).filter(Boolean);
53
+ if (!parts.length) throw new Error('Document path must be a regular file');
54
+ const directories = [];
55
+ let current = root;
56
+ const closeDirectories = async () => {
57
+ for (const handle of directories.reverse()) await handle.close().catch(() => {});
58
+ };
59
+ try {
60
+ for (const part of parts.slice(0, -1)) {
61
+ const handle = await open(path.join(current, part), DOCUMENT_DIRECTORY_FLAGS);
62
+ const info = await handle.stat();
63
+ if (!info.isDirectory()) {
64
+ await handle.close().catch(() => {});
65
+ throw new Error('Document path parent is not a directory');
66
+ }
67
+ directories.push(handle);
68
+ current = `/proc/self/fd/${handle.fd}`;
69
+ }
70
+ const handle = await open(path.join(current, parts.at(-1)), DOCUMENT_READ_FLAGS);
71
+ return {
72
+ handle,
73
+ close: async () => {
74
+ try { await handle.close(); }
75
+ finally { await closeDirectories(); }
76
+ },
77
+ };
78
+ } catch (error) {
79
+ await closeDirectories();
80
+ throw error;
81
+ }
82
+ }
83
+
84
+ async function openDocumentDirectoryPath(directory, { create = false } = {}) {
85
+ if (!descriptorAnchorsAvailable()) {
86
+ if (create) await mkdir(directory, { recursive:true, mode:0o700 });
87
+ const handle = await open(directory, DOCUMENT_DIRECTORY_FLAGS);
88
+ try {
89
+ const info = await handle.stat();
90
+ if (!info.isDirectory()) throw new Error('Document path parent is not a directory');
91
+ return { handle, anchor:directory, descriptorBound:false, close: () => handle.close() };
92
+ } catch (error) {
93
+ await handle.close().catch(() => {});
94
+ throw error;
95
+ }
96
+ }
97
+ const root = path.parse(directory).root;
98
+ const parts = directory.slice(root.length).split(path.sep).filter(Boolean);
99
+ if (!parts.length) {
100
+ const handle = await open(root, DOCUMENT_DIRECTORY_FLAGS);
101
+ return { anchor: `/proc/self/fd/${handle.fd}`, close: () => handle.close() };
102
+ }
103
+ const directories = [];
104
+ let current = root;
105
+ const closeDirectories = async () => {
106
+ for (const handle of directories.reverse()) await handle.close().catch(() => {});
107
+ };
108
+ try {
109
+ for (const part of parts) {
110
+ const candidate = path.join(current, part);
111
+ if (create) {
112
+ await mkdir(candidate, { mode:0o700 }).catch(error => {
113
+ if (error?.code !== 'EEXIST') throw error;
114
+ });
115
+ }
116
+ const handle = await open(candidate, DOCUMENT_DIRECTORY_FLAGS);
117
+ const info = await handle.stat();
118
+ if (!info.isDirectory()) {
119
+ await handle.close().catch(() => {});
120
+ throw new Error('Document path parent is not a directory');
121
+ }
122
+ directories.push(handle);
123
+ current = `/proc/self/fd/${handle.fd}`;
124
+ }
125
+ const handle = directories.pop();
126
+ if (!handle) throw new Error('Document directory path is empty');
127
+ return {
128
+ anchor: current,
129
+ close: async () => {
130
+ try { await handle.close(); }
131
+ finally { await closeDirectories(); }
132
+ },
133
+ };
134
+ } catch (error) {
135
+ await closeDirectories();
136
+ throw error;
137
+ }
138
+ }
139
+
140
+ async function createAnchoredTempDirectory(parent, prefix) {
141
+ const directory = await openDocumentDirectoryPath(parent, { create:true });
142
+ try {
143
+ const childAnchor = directory.descriptorBound === false
144
+ ? directory.anchor
145
+ : directory.anchor.replace('/proc/self/fd/', `/proc/${process.pid}/fd/`);
146
+ const directoryPath = await mkdtemp(path.join(childAnchor, prefix));
147
+ return { path:directoryPath, close:directory.close };
148
+ } catch (error) {
149
+ await directory.close().catch(() => {});
150
+ throw error;
151
+ }
152
+ }
153
+
154
+ async function readBounded(handle, maximum, label) {
155
+ const chunks = [];
156
+ const buffer = Buffer.allocUnsafe(1024 * 1024);
157
+ let offset = 0;
158
+ for (;;) {
159
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
160
+ if (!bytesRead) break;
161
+ if (!Number.isSafeInteger(offset + bytesRead) || offset + bytesRead > maximum) {
162
+ throw new Error(`${label} exceeds the ${maximum}-byte safety limit`);
163
+ }
164
+ chunks.push(Buffer.from(buffer.subarray(0, bytesRead)));
165
+ offset += bytesRead;
166
+ }
167
+ return Buffer.concat(chunks, offset);
168
+ }
169
+
170
+ async function readDocumentFile(filePath) {
171
+ const opened = await openDocumentSource(filePath);
172
+ try {
173
+ const info = await opened.handle.stat();
174
+ if (!info.isFile()) throw new Error('Document path must be a regular file');
175
+ if (info.size > MAX_DOCUMENT_BYTES) throw new Error(`Document is too large (maximum ${MAX_DOCUMENT_BYTES} bytes)`);
176
+ return await readBounded(opened.handle, MAX_DOCUMENT_BYTES, 'Document');
177
+ } finally {
178
+ await opened.close();
179
+ }
180
+ }
181
+
182
+ async function copyRegularNoFollow(source, destination) {
183
+ const opened = await openDocumentSource(source);
184
+ try {
185
+ const info = await opened.handle.stat();
186
+ if (!info.isFile()) throw new Error('Document path must be a regular file');
187
+ if (info.size > MAX_DOCUMENT_BYTES) throw new Error(`Document is too large (maximum ${MAX_DOCUMENT_BYTES} bytes)`);
188
+ const destinationHandle = await open(destination, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
189
+ try {
190
+ const data = await readBounded(opened.handle, MAX_DOCUMENT_BYTES, 'Document');
191
+ await destinationHandle.writeFile(data);
192
+ } finally {
193
+ await destinationHandle.close();
194
+ }
195
+ } finally {
196
+ await opened.close();
197
+ }
198
+ }
199
+
200
+ async function snapshotDocumentFile(filePath) {
201
+ const dir = await tempDir('remcp-pdf-input-');
202
+ const snapshot = path.join(dir, `input-${randomUUID()}.pdf`);
203
+ try {
204
+ await copyRegularNoFollow(filePath, snapshot);
205
+ return { dir, path: snapshot };
206
+ } catch (error) {
207
+ await removeTemp(dir);
208
+ throw error;
209
+ }
210
+ }
211
+
212
+ async function assertSafeExtractedTree(root, state = { entries: 0 }) {
213
+ const info = await lstat(root);
214
+ if (info.isSymbolicLink()) throw new Error('OOXML archive contains a symbolic link');
215
+ if (!info.isDirectory() && !info.isFile()) throw new Error('OOXML archive contains a special file');
216
+ state.entries += 1;
217
+ if (state.entries > MAX_OOXML_ENTRIES) throw new Error('OOXML archive has too many extracted entries');
218
+ if (!info.isDirectory()) return;
219
+ for (const entry of await readdir(root, { withFileTypes: true })) {
220
+ await assertSafeExtractedTree(path.join(root, entry.name), state);
221
+ }
222
+ }
223
+
224
+ function safePdfPattern(value, fallback) {
225
+ const pattern = String(value || fallback);
226
+ if (!pattern || pattern.length > 240 || pattern.includes('\0') || path.isAbsolute(pattern) || /[\\/]/.test(pattern) || pattern.split(/[\\/]/).includes('..')) {
227
+ throw new Error('PDF split pattern must be a filename without path separators');
228
+ }
229
+ return pattern;
230
+ }
29
231
 
30
232
  function safeArchiveEntry(name) {
31
233
  const normalized = String(name || '').replace(/\\/g, '/');
@@ -45,20 +247,30 @@ async function validateZipArchive(source) {
45
247
  if (entries.length > MAX_OOXML_ENTRIES) throw new Error(`OOXML archive has too many entries (${entries.length}; max ${MAX_OOXML_ENTRIES})`);
46
248
  const unsafe = entries.find(name => !safeArchiveEntry(name));
47
249
  if (unsafe) throw new Error(`OOXML archive contains an unsafe path: ${unsafe}`);
250
+ const details = await runFile('unzip', ['-Z','-v',source], { label:'inspect OOXML entry types', timeout:30_000, maxBuffer:16 * 1024 * 1024 });
251
+ if (/symbolic link|Unix file attributes \([^)]*\b12\d{4}/i.test(details.stdout)) throw new Error('OOXML archive contains a symbolic link');
48
252
  const totals = await runFile('unzip', ['-Z','-t',source], { label:'inspect OOXML archive size', timeout:30_000 });
49
253
  const uncompressed = Number(totals.stdout.match(/(?:^|,\s)([0-9]+) bytes uncompressed/)?.[1] || 0);
50
254
  if (uncompressed > MAX_OOXML_UNCOMPRESSED_BYTES) throw new Error(`OOXML archive expands to ${uncompressed} bytes; max ${MAX_OOXML_UNCOMPRESSED_BYTES}`);
51
255
  }
52
256
 
53
257
  async function extractZip(source, destination) {
54
- await validateZipArchive(source);
55
- await mkdir(destination, { recursive:true });
56
- if (process.platform === 'win32') {
57
- await runPowerShell(`Add-Type -AssemblyName System.IO.Compression.FileSystem;[IO.Compression.ZipFile]::ExtractToDirectory('${escapePowerShellSingle(source)}','${escapePowerShellSingle(destination)}')`, { label:'extract OOXML archive', timeout:30_000 });
58
- return;
258
+ const sourceDirectory = await tempDir('remcp-ooxml-source-');
259
+ const sourceCopy = path.join(sourceDirectory, 'source.zip');
260
+ try {
261
+ await copyRegularNoFollow(source, sourceCopy);
262
+ await validateZipArchive(sourceCopy);
263
+ await mkdir(destination, { recursive:true });
264
+ if (process.platform === 'win32') {
265
+ await runPowerShell(`Add-Type -AssemblyName System.IO.Compression.FileSystem;[IO.Compression.ZipFile]::ExtractToDirectory('${escapePowerShellSingle(sourceCopy)}','${escapePowerShellSingle(destination)}')`, { label:'extract OOXML archive', timeout:30_000 });
266
+ } else {
267
+ if (!commandExists('unzip')) unavailable('OOXML document editing', 'unzip is required');
268
+ await runFile('unzip', ['-qq',sourceCopy,'-d',destination], { label:'extract OOXML archive', timeout:30_000 });
269
+ }
270
+ await assertSafeExtractedTree(destination);
271
+ } finally {
272
+ await removeTemp(sourceDirectory);
59
273
  }
60
- if (!commandExists('unzip')) unavailable('OOXML document editing', 'unzip is required');
61
- await runFile('unzip', ['-qq',source,'-d',destination], { label:'extract OOXML archive', timeout:30_000 });
62
274
  }
63
275
 
64
276
  async function createZip(sourceDir, destination) {
@@ -71,6 +283,38 @@ async function createZip(sourceDir, destination) {
71
283
  await runFile('zip', ['-qr',destination,'.'], { cwd:sourceDir, label:'create OOXML archive', timeout:30_000 });
72
284
  }
73
285
 
286
+ async function commitDocumentOutput(staged, destination, { noReplace = false } = {}) {
287
+ const parentDirectory = await openDocumentDirectoryPath(path.dirname(destination));
288
+ const parentAnchor = parentDirectory.anchor;
289
+ const destinationPath = path.join(parentAnchor, path.basename(destination));
290
+ try {
291
+ if (noReplace) {
292
+ await link(staged, destinationPath);
293
+ await unlink(staged);
294
+ return;
295
+ }
296
+ try {
297
+ await rename(staged, destinationPath);
298
+ return;
299
+ } catch (error) {
300
+ if (!['EEXIST', 'EPERM', 'ENOTEMPTY'].includes(error?.code)) throw error;
301
+ }
302
+ const backup = path.join(parentAnchor, `.${path.basename(destination)}.remcp-old-${randomUUID()}`);
303
+ let moved = false;
304
+ try {
305
+ await rename(destinationPath, backup);
306
+ moved = true;
307
+ await rename(staged, destinationPath);
308
+ await rm(backup, { force: true }).catch(() => {});
309
+ } catch (error) {
310
+ if (moved && !await lstat(destinationPath).catch(() => null)) await rename(backup, destinationPath).catch(() => {});
311
+ throw error;
312
+ }
313
+ } finally {
314
+ await parentDirectory.close().catch(() => {});
315
+ }
316
+ }
317
+
74
318
  async function ensureCreateTargetIsNew(filePath) {
75
319
  try {
76
320
  await stat(filePath);
@@ -154,9 +398,10 @@ function readWorksheetCells(xml, sharedStrings, maxCells) {
154
398
  }
155
399
 
156
400
  async function readXlsx(filePath, args) {
401
+ const source = await snapshotDocumentFile(filePath);
157
402
  const dir = await tempDir('remcp-xlsx-read-');
158
403
  try {
159
- await extractZip(filePath, dir);
404
+ await extractZip(source.path, dir);
160
405
  const workbook = await readFile(path.join(dir,'xl','workbook.xml'),'utf8');
161
406
  const rels = await readFile(path.join(dir,'xl','_rels','workbook.xml.rels'),'utf8');
162
407
  const sheet = workbookSheetPath(workbook, rels, optionalString(args.sheet));
@@ -165,21 +410,31 @@ async function readXlsx(filePath, args) {
165
410
  try { shared = readSharedStrings(await readFile(path.join(dir,'xl','sharedStrings.xml'),'utf8')); } catch {}
166
411
  const cells = readWorksheetCells(worksheet, shared, clamp(args.max_cells,5000,1,50_000));
167
412
  return { kind:'xlsx', sheet:sheet.name, count:cells.length, cells };
168
- } finally { await removeTemp(dir); }
413
+ } finally {
414
+ await removeTemp(source.dir);
415
+ await removeTemp(dir);
416
+ }
169
417
  }
170
418
 
171
- async function readPdfDocument(filePath, data) {
419
+ async function readPdfDocument(data) {
172
420
  try {
173
421
  return readPdfText(data);
174
422
  } catch (builtinError) {
175
423
  if (commandExists('pdftotext')) {
176
- const extracted = await runFile('pdftotext', ['-enc','UTF-8',filePath,'-'], {
177
- label:'PDF text extraction',
178
- timeout:60_000,
179
- allowFailure:true,
180
- });
181
- const fallbackText = String(extracted.stdout || '').replace(/\f/g, '\n').trim();
182
- if (extracted.code === 0 && fallbackText) return fallbackText;
424
+ const dir = await tempDir('remcp-pdf-read-');
425
+ try {
426
+ const snapshot = path.join(dir, 'input.pdf');
427
+ await writeFile(snapshot, data, { mode: 0o600 });
428
+ const extracted = await runFile('pdftotext', ['-enc','UTF-8',snapshot,'-'], {
429
+ label:'PDF text extraction',
430
+ timeout:60_000,
431
+ allowFailure:true,
432
+ });
433
+ const fallbackText = String(extracted.stdout || '').replace(/\f/g, '\n').trim();
434
+ if (extracted.code === 0 && fallbackText) return fallbackText;
435
+ } finally {
436
+ await removeTemp(dir);
437
+ }
183
438
  }
184
439
  throw builtinError;
185
440
  }
@@ -188,9 +443,9 @@ async function readPdfDocument(filePath, data) {
188
443
  export async function readDocument(args) {
189
444
  const filePath = await resolveSafePath(args.path,'path');
190
445
  const lower = filePath.toLowerCase();
191
- const data = await readFile(filePath);
446
+ const data = await readDocumentFile(filePath);
192
447
  if (lower.endsWith('.docx')) return text(readDocxText(data));
193
- if (lower.endsWith('.pdf')) return text(await readPdfDocument(filePath,data));
448
+ if (lower.endsWith('.pdf')) return text(await readPdfDocument(data));
194
449
  if (lower.endsWith('.xlsx')) return jsonResult(await readXlsx(filePath,args));
195
450
  if (/\.(txt|md|csv|json|xml|yaml|yml)$/i.test(lower)) return text(data.toString('utf8'));
196
451
  throw new Error('read_document supports PDF, DOCX, XLSX, TXT, Markdown, CSV, JSON, XML, YAML');
@@ -275,8 +530,13 @@ export async function editSpreadsheet(args) {
275
530
  const edits = expandSpreadsheetEdits(requestedEdits);
276
531
  const output = args.output ? await resolveSafePath(args.output,'output') : filePath;
277
532
  const dir = await tempDir(create ? 'remcp-xlsx-create-' : 'remcp-xlsx-edit-');
278
- const tempOut = path.join(path.dirname(output), `.remcp-${Date.now()}-${path.basename(output)}`);
533
+ let outputStage = '';
534
+ let outputStageDirectory = null;
535
+ let tempOut = '';
279
536
  try {
537
+ outputStageDirectory = await createAnchoredTempDirectory(path.dirname(output), '.remcp-xlsx-output-');
538
+ outputStage = outputStageDirectory.path;
539
+ tempOut = path.join(outputStage, path.basename(output));
280
540
  if (create) {
281
541
  await ensureCreateTargetIsNew(filePath);
282
542
  await createBlankXlsxTree(dir, optionalString(args.sheet));
@@ -290,12 +550,14 @@ export async function editSpreadsheet(args) {
290
550
  let xml = await readFile(worksheetPath,'utf8');
291
551
  for (const edit of edits) xml = setWorksheetCell(xml,String(edit.cell || '').toUpperCase(),edit.value,edit.formula);
292
552
  await writeFile(worksheetPath,xml,'utf8');
293
- await mkdir(path.dirname(output), { recursive:true });
294
553
  await createZip(dir,tempOut);
295
- await copyFile(tempOut,output);
296
- return jsonResult({ path:output, sheet:sheet.name, edited_cells:edits.length, bytes:(await stat(output)).size, created:create });
554
+ await commitDocumentOutput(tempOut,output,{ noReplace:create });
555
+ return jsonResult({ path:output, sheet:sheet.name, edited_cells:edits.length, bytes:(await stat(output)).size, created:create });
556
+
297
557
  } finally {
298
- await rm(tempOut,{force:true}).catch(() => {});
558
+ if (tempOut) await rm(tempOut,{force:true}).catch(() => {});
559
+ if (outputStage) await rm(outputStage,{recursive:true, force:true}).catch(() => {});
560
+ if (outputStageDirectory) await outputStageDirectory.close().catch(() => {});
299
561
  await removeTemp(dir);
300
562
  }
301
563
  }
@@ -356,9 +618,14 @@ export async function editDocument(args) {
356
618
  if (!operations.length || operations.length > 100) throw new Error('operations must contain 1..100 edits');
357
619
  const output = args.output ? await resolveSafePath(args.output,'output') : filePath;
358
620
  const dir = await tempDir(create ? 'remcp-docx-create-' : 'remcp-docx-edit-');
359
- const tempOut = path.join(path.dirname(output), `.remcp-${Date.now()}-${path.basename(output)}`);
621
+ let outputStage = '';
622
+ let outputStageDirectory = null;
623
+ let tempOut = '';
360
624
  let changes = 0;
361
625
  try {
626
+ outputStageDirectory = await createAnchoredTempDirectory(path.dirname(output), '.remcp-docx-output-');
627
+ outputStage = outputStageDirectory.path;
628
+ tempOut = path.join(outputStage, path.basename(output));
362
629
  if (create) {
363
630
  await ensureCreateTargetIsNew(filePath);
364
631
  await createBlankDocxTree(dir);
@@ -383,12 +650,14 @@ export async function editDocument(args) {
383
650
  }
384
651
  }
385
652
  await writeFile(documentPath,xml,'utf8');
386
- await mkdir(path.dirname(output), { recursive:true });
387
653
  await createZip(dir,tempOut);
388
- await copyFile(tempOut,output);
389
- return jsonResult({ path:output, operations:operations.length, changes, bytes:(await stat(output)).size, created:create });
654
+ await commitDocumentOutput(tempOut,output,{ noReplace:create });
655
+ return jsonResult({ path:output, operations:operations.length, changes, bytes:(await stat(output)).size, created:create });
656
+
390
657
  } finally {
391
- await rm(tempOut,{force:true}).catch(() => {});
658
+ if (tempOut) await rm(tempOut,{force:true}).catch(() => {});
659
+ if (outputStage) await rm(outputStage,{recursive:true, force:true}).catch(() => {});
660
+ if (outputStageDirectory) await outputStageDirectory.close().catch(() => {});
392
661
  await removeTemp(dir);
393
662
  }
394
663
  }
@@ -437,63 +706,114 @@ export async function pdfAction(args) {
437
706
  const action = requireEnum(args.action,'action',['merge','split','extract_pages','annotations','info']);
438
707
  if (action === 'annotations') {
439
708
  const source = await resolveSafePath(args.path,'path');
440
- return jsonResult({ path:source, annotations:pdfAnnotations(await readFile(source)) });
709
+ return jsonResult({ path:source, annotations:pdfAnnotations(await readDocumentFile(source)) });
441
710
  }
442
711
  if (action === 'info') {
443
712
  const source = await resolveSafePath(args.path,'path');
444
- if (commandExists('pdfinfo')) return text((await runFile('pdfinfo',[source],{label:'pdfinfo'})).stdout);
445
- const data = await readFile(source); return jsonResult({ path:source, bytes:data.length, annotation_count:pdfAnnotations(data).length });
713
+ if (commandExists('pdfinfo')) {
714
+ const snapshot = await snapshotDocumentFile(source);
715
+ try { return text((await runFile('pdfinfo',[snapshot.path],{label:'pdfinfo'})).stdout); }
716
+ finally { await removeTemp(snapshot.dir); }
717
+ }
718
+ const data = await readDocumentFile(source);
719
+ return jsonResult({ path:source, bytes:data.length, annotation_count:pdfAnnotations(data).length });
446
720
  }
447
721
  if (action === 'merge') {
448
722
  const requested = Array.isArray(args.paths) ? args.paths : [];
449
723
  if (requested.length < 2 || requested.length > 100) throw new Error('merge requires 2..100 PDFs in paths');
450
- const sources=[]; for(const value of requested) sources.push(await resolveSafePath(value,'paths'));
724
+ const sources=[];
725
+ for(const value of requested) sources.push(await resolveSafePath(value,'paths'));
451
726
  const output=await resolveSafePath(args.output,'output');
452
- if(commandExists('pdfunite')) await runFile('pdfunite',[...sources,output],{label:'PDF merge',timeout:60_000});
453
- else if(commandExists('qpdf')) await runFile('qpdf',['--empty','--pages',...sources,'--',output],{label:'PDF merge',timeout:60_000});
454
- else unavailable('PDF merge','install poppler-utils (pdfunite) or qpdf');
455
- return jsonResult({action,output,inputs:sources.length,bytes:(await stat(output)).size});
727
+ const inputDir=await tempDir('remcp-pdf-merge-input-');
728
+ const outputStageDirectory=await createAnchoredTempDirectory(path.dirname(output), '.remcp-pdf-output-');
729
+ const outputDir=outputStageDirectory.path;
730
+ const staged=path.join(outputDir,path.basename(output));
731
+ try {
732
+ const snapshots=[];
733
+ for(let index=0;index<sources.length;index+=1){
734
+ const snapshot=path.join(inputDir,`input-${index}.pdf`);
735
+ await copyRegularNoFollow(sources[index],snapshot);
736
+ snapshots.push(snapshot);
737
+ }
738
+ if(commandExists('pdfunite')) await runFile('pdfunite',[...snapshots,staged],{label:'PDF merge',timeout:60_000});
739
+ else if(commandExists('qpdf')) await runFile('qpdf',['--empty','--pages',...snapshots,'--',staged],{label:'PDF merge',timeout:60_000});
740
+ else unavailable('PDF merge','install poppler-utils (pdfunite) or qpdf');
741
+ await commitDocumentOutput(staged,output);
742
+ return jsonResult({action,output,inputs:sources.length,bytes:(await stat(output)).size});
743
+ } finally {
744
+ await removeTemp(inputDir);
745
+ await rm(outputDir,{recursive:true,force:true}).catch(()=>{});
746
+ await outputStageDirectory.close().catch(()=>{});
747
+ }
456
748
  }
457
749
  const source=await resolveSafePath(args.path,'path');
458
750
  if(action==='split'){
459
- const outputDir=await resolveSafePath(args.output_dir || path.dirname(source),'output_dir'); await mkdir(outputDir,{recursive:true});
460
- const pattern=path.join(outputDir,optionalString(args.pattern)||`${path.basename(source,path.extname(source))}-%d.pdf`);
461
- if(commandExists('pdfseparate')) await runFile('pdfseparate',[source,pattern],{label:'PDF split',timeout:60_000});
462
- else unavailable('PDF split','install poppler-utils (pdfseparate)');
463
- return jsonResult({action,output_dir:outputDir,files:(await readdir(outputDir)).filter(name=>name.toLowerCase().endsWith('.pdf')).sort()});
751
+ const requestedOutputDir=await resolveSafePath(args.output_dir || path.dirname(source),'output_dir');
752
+ const outputDir=await resolveSafePath(requestedOutputDir,'output_dir');
753
+ const patternName=safePdfPattern(optionalString(args.pattern),`${path.basename(source,path.extname(source))}-%d.pdf`);
754
+ const snapshot=await snapshotDocumentFile(source);
755
+ const work=await tempDir('remcp-pdf-split-');
756
+ const stageDirectory=await createAnchoredTempDirectory(outputDir, '.remcp-pdf-output-');
757
+ const stageDir=stageDirectory.path;
758
+ try {
759
+ const pattern=path.join(work,patternName);
760
+ if(commandExists('pdfseparate')) await runFile('pdfseparate',[snapshot.path,pattern],{label:'PDF split',timeout:60_000});
761
+ else unavailable('PDF split','install poppler-utils (pdfseparate)');
762
+ for(const name of await readdir(work)){
763
+ if(!name.toLowerCase().endsWith('.pdf')) continue;
764
+ const staged=path.join(stageDir,name);
765
+ await copyFile(path.join(work,name),staged,constants.COPYFILE_EXCL);
766
+ await commitDocumentOutput(staged,path.join(outputDir,name));
767
+ }
768
+ return jsonResult({action,output_dir:outputDir,files:(await readdir(outputDir)).filter(name=>name.toLowerCase().endsWith('.pdf')).sort()});
769
+ } finally {
770
+ await removeTemp(snapshot.dir);
771
+ await removeTemp(work);
772
+ await rm(stageDir,{recursive:true,force:true}).catch(()=>{});
773
+ await stageDirectory.close().catch(()=>{});
774
+ }
464
775
  }
465
776
  const pages=optionalString(args.pages);
466
777
  const selectedPages=expandPdfPages(pages);
467
778
  const normalizedPages=selectedPages.join(',');
468
779
  const output=await resolveSafePath(args.output,'output');
469
- if(commandExists('qpdf')) {
470
- await runFile('qpdf',[source,'--pages','.',pages.replace(/\s+/g,''),'--',output],{label:'PDF extract pages',timeout:60_000});
471
- } else if(commandExists('pdftk')) {
472
- await runFile('pdftk',[source,'cat',...pages.replace(/\s+/g,'').split(','),'output',output],{label:'PDF extract pages',timeout:60_000});
473
- } else if(commandExists('pdfseparate')) {
474
- const dir=await tempDir('remcp-pdf-pages-');
475
- try {
476
- const extracted=[];
477
- for(let index=0;index<selectedPages.length;index+=1){
478
- const page=selectedPages[index];
479
- const pattern=path.join(dir,`selection-${index+1}-%d.pdf`);
480
- await runFile('pdfseparate',['-f',String(page),'-l',String(page),source,pattern],{label:`PDF extract page ${page}`,timeout:60_000});
481
- extracted.push(path.join(dir,`selection-${index+1}-${page}.pdf`));
482
- }
483
- if(extracted.length===1) {
484
- await copyFile(extracted[0],output);
485
- } else if(commandExists('pdfunite')) {
486
- await runFile('pdfunite',[...extracted,output],{label:'PDF assemble extracted pages',timeout:60_000});
487
- } else {
488
- unavailable('PDF page extraction','pdfunite is required with pdfseparate when extracting multiple pages');
489
- }
490
- } finally {
491
- await removeTemp(dir);
780
+ const snapshot=await snapshotDocumentFile(source);
781
+ const stageDirectory=await createAnchoredTempDirectory(path.dirname(output), '.remcp-pdf-output-');
782
+ const stageDir=stageDirectory.path;
783
+ const staged=path.join(stageDir,path.basename(output));
784
+ try {
785
+ if(commandExists('qpdf')) {
786
+ await runFile('qpdf',[snapshot.path,'--pages','.',pages.replace(/\s+/g,''),'--',staged],{label:'PDF extract pages',timeout:60_000});
787
+ } else if(commandExists('pdftk')) {
788
+ await runFile('pdftk',[snapshot.path,'cat',...pages.replace(/\s+/g,'').split(','),'output',staged],{label:'PDF extract pages',timeout:60_000});
789
+ } else if(commandExists('pdfseparate')) {
790
+ const work=await tempDir('remcp-pdf-pages-');
791
+ try {
792
+ const extracted=[];
793
+ for(let index=0;index<selectedPages.length;index+=1){
794
+ const page=selectedPages[index];
795
+ const pattern=path.join(work,`selection-${index+1}-%d.pdf`);
796
+ await runFile('pdfseparate',['-f',String(page),'-l',String(page),snapshot.path,pattern],{label:`PDF extract page ${page}`,timeout:60_000});
797
+ extracted.push(path.join(work,`selection-${index+1}-${page}.pdf`));
798
+ }
799
+ if(extracted.length===1) {
800
+ await copyFile(extracted[0],staged,constants.COPYFILE_EXCL);
801
+ } else if(commandExists('pdfunite')) {
802
+ await runFile('pdfunite',[...extracted,staged],{label:'PDF assemble extracted pages',timeout:60_000});
803
+ } else {
804
+ unavailable('PDF page extraction','pdfunite is required with pdfseparate when extracting multiple pages');
805
+ }
806
+ } finally { await removeTemp(work); }
807
+ } else {
808
+ unavailable('PDF page extraction','install qpdf, pdftk, or poppler-utils (pdfseparate; pdfunite for multiple pages)');
492
809
  }
493
- } else {
494
- unavailable('PDF page extraction','install qpdf, pdftk, or poppler-utils (pdfseparate; pdfunite for multiple pages)');
810
+ await commitDocumentOutput(staged,output);
811
+ return jsonResult({action,source,pages:normalizedPages,output,bytes:(await stat(output)).size});
812
+ } finally {
813
+ await removeTemp(snapshot.dir);
814
+ await rm(stageDir,{recursive:true,force:true}).catch(()=>{});
815
+ await stageDirectory.close().catch(()=>{});
495
816
  }
496
- return jsonResult({action,source,pages:normalizedPages,output,bytes:(await stat(output)).size});
497
817
  }
498
818
 
499
819
  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 [...advertisedCoreTools(), ...await advertisedExtendedTools()];
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((await advertisedExtendedTools()).map(tool => tool.name));
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
- if (extendedToolDefinitions.includes(definition) && !initiallySupported.has(definition.name)) registered.disable();
62
+ if (!initiallySupported.has(definition.name)) registered.disable();
63
+
60
64
  }
61
65
 
62
66
  let lastSupported = initiallySupported;