@remcp/runtime 0.2.107 → 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.
@@ -2,7 +2,7 @@ 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
7
  import { resolveSafePath, text } from '../util.mjs';
8
8
  import {
@@ -29,28 +29,183 @@ function xmlUnescape(value) {
29
29
  const MAX_OOXML_ENTRIES = 10_000;
30
30
  const MAX_OOXML_UNCOMPRESSED_BYTES = 512 * 1024 * 1024;
31
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
+ }
32
169
 
33
170
  async function readDocumentFile(filePath) {
34
- const info = await lstat(filePath);
35
- if (!info.isFile()) throw new Error('Document path must be a regular file');
36
- if (info.size > MAX_DOCUMENT_BYTES) throw new Error(`Document is too large (maximum ${MAX_DOCUMENT_BYTES} bytes)`);
37
- return readFile(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
+ }
38
180
  }
39
181
 
40
182
  async function copyRegularNoFollow(source, destination) {
41
- const sourceHandle = await open(source, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
183
+ const opened = await openDocumentSource(source);
42
184
  try {
43
- const info = await sourceHandle.stat();
185
+ const info = await opened.handle.stat();
44
186
  if (!info.isFile()) throw new Error('Document path must be a regular file');
45
187
  if (info.size > MAX_DOCUMENT_BYTES) throw new Error(`Document is too large (maximum ${MAX_DOCUMENT_BYTES} bytes)`);
46
188
  const destinationHandle = await open(destination, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
47
189
  try {
48
- await destinationHandle.writeFile(await sourceHandle.readFile());
190
+ const data = await readBounded(opened.handle, MAX_DOCUMENT_BYTES, 'Document');
191
+ await destinationHandle.writeFile(data);
49
192
  } finally {
50
193
  await destinationHandle.close();
51
194
  }
52
195
  } finally {
53
- await sourceHandle.close();
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;
54
209
  }
55
210
  }
56
211
 
@@ -92,6 +247,8 @@ async function validateZipArchive(source) {
92
247
  if (entries.length > MAX_OOXML_ENTRIES) throw new Error(`OOXML archive has too many entries (${entries.length}; max ${MAX_OOXML_ENTRIES})`);
93
248
  const unsafe = entries.find(name => !safeArchiveEntry(name));
94
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');
95
252
  const totals = await runFile('unzip', ['-Z','-t',source], { label:'inspect OOXML archive size', timeout:30_000 });
96
253
  const uncompressed = Number(totals.stdout.match(/(?:^|,\s)([0-9]+) bytes uncompressed/)?.[1] || 0);
97
254
  if (uncompressed > MAX_OOXML_UNCOMPRESSED_BYTES) throw new Error(`OOXML archive expands to ${uncompressed} bytes; max ${MAX_OOXML_UNCOMPRESSED_BYTES}`);
@@ -126,23 +283,35 @@ async function createZip(sourceDir, destination) {
126
283
  await runFile('zip', ['-qr',destination,'.'], { cwd:sourceDir, label:'create OOXML archive', timeout:30_000 });
127
284
  }
128
285
 
129
- async function commitDocumentOutput(staged, destination) {
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));
130
290
  try {
131
- await rename(staged, destination);
132
- return;
133
- } catch (error) {
134
- if (!['EEXIST', 'EPERM', 'ENOTEMPTY'].includes(error?.code)) throw error;
135
- }
136
- const backup = `${destination}.remcp-old-${randomUUID()}`;
137
- let moved = false;
138
- try {
139
- await rename(destination, backup);
140
- moved = true;
141
- await rename(staged, destination);
142
- await rm(backup, { force: true }).catch(() => {});
143
- } catch (error) {
144
- if (moved && !await lstat(destination).catch(() => null)) await rename(backup, destination).catch(() => {});
145
- throw error;
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(() => {});
146
315
  }
147
316
  }
148
317
 
@@ -229,9 +398,10 @@ function readWorksheetCells(xml, sharedStrings, maxCells) {
229
398
  }
230
399
 
231
400
  async function readXlsx(filePath, args) {
401
+ const source = await snapshotDocumentFile(filePath);
232
402
  const dir = await tempDir('remcp-xlsx-read-');
233
403
  try {
234
- await extractZip(filePath, dir);
404
+ await extractZip(source.path, dir);
235
405
  const workbook = await readFile(path.join(dir,'xl','workbook.xml'),'utf8');
236
406
  const rels = await readFile(path.join(dir,'xl','_rels','workbook.xml.rels'),'utf8');
237
407
  const sheet = workbookSheetPath(workbook, rels, optionalString(args.sheet));
@@ -240,21 +410,31 @@ async function readXlsx(filePath, args) {
240
410
  try { shared = readSharedStrings(await readFile(path.join(dir,'xl','sharedStrings.xml'),'utf8')); } catch {}
241
411
  const cells = readWorksheetCells(worksheet, shared, clamp(args.max_cells,5000,1,50_000));
242
412
  return { kind:'xlsx', sheet:sheet.name, count:cells.length, cells };
243
- } finally { await removeTemp(dir); }
413
+ } finally {
414
+ await removeTemp(source.dir);
415
+ await removeTemp(dir);
416
+ }
244
417
  }
245
418
 
246
- async function readPdfDocument(filePath, data) {
419
+ async function readPdfDocument(data) {
247
420
  try {
248
421
  return readPdfText(data);
249
422
  } catch (builtinError) {
250
423
  if (commandExists('pdftotext')) {
251
- const extracted = await runFile('pdftotext', ['-enc','UTF-8',filePath,'-'], {
252
- label:'PDF text extraction',
253
- timeout:60_000,
254
- allowFailure:true,
255
- });
256
- const fallbackText = String(extracted.stdout || '').replace(/\f/g, '\n').trim();
257
- 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
+ }
258
438
  }
259
439
  throw builtinError;
260
440
  }
@@ -265,7 +445,7 @@ export async function readDocument(args) {
265
445
  const lower = filePath.toLowerCase();
266
446
  const data = await readDocumentFile(filePath);
267
447
  if (lower.endsWith('.docx')) return text(readDocxText(data));
268
- if (lower.endsWith('.pdf')) return text(await readPdfDocument(filePath,data));
448
+ if (lower.endsWith('.pdf')) return text(await readPdfDocument(data));
269
449
  if (lower.endsWith('.xlsx')) return jsonResult(await readXlsx(filePath,args));
270
450
  if (/\.(txt|md|csv|json|xml|yaml|yml)$/i.test(lower)) return text(data.toString('utf8'));
271
451
  throw new Error('read_document supports PDF, DOCX, XLSX, TXT, Markdown, CSV, JSON, XML, YAML');
@@ -351,10 +531,11 @@ export async function editSpreadsheet(args) {
351
531
  const output = args.output ? await resolveSafePath(args.output,'output') : filePath;
352
532
  const dir = await tempDir(create ? 'remcp-xlsx-create-' : 'remcp-xlsx-edit-');
353
533
  let outputStage = '';
534
+ let outputStageDirectory = null;
354
535
  let tempOut = '';
355
536
  try {
356
- await mkdir(path.dirname(output), { recursive:true });
357
- outputStage = await mkdtemp(path.join(path.dirname(output), '.remcp-xlsx-output-'));
537
+ outputStageDirectory = await createAnchoredTempDirectory(path.dirname(output), '.remcp-xlsx-output-');
538
+ outputStage = outputStageDirectory.path;
358
539
  tempOut = path.join(outputStage, path.basename(output));
359
540
  if (create) {
360
541
  await ensureCreateTargetIsNew(filePath);
@@ -370,11 +551,13 @@ export async function editSpreadsheet(args) {
370
551
  for (const edit of edits) xml = setWorksheetCell(xml,String(edit.cell || '').toUpperCase(),edit.value,edit.formula);
371
552
  await writeFile(worksheetPath,xml,'utf8');
372
553
  await createZip(dir,tempOut);
373
- await commitDocumentOutput(tempOut,output);
374
- 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
+
375
557
  } finally {
376
558
  if (tempOut) await rm(tempOut,{force:true}).catch(() => {});
377
559
  if (outputStage) await rm(outputStage,{recursive:true, force:true}).catch(() => {});
560
+ if (outputStageDirectory) await outputStageDirectory.close().catch(() => {});
378
561
  await removeTemp(dir);
379
562
  }
380
563
  }
@@ -436,11 +619,12 @@ export async function editDocument(args) {
436
619
  const output = args.output ? await resolveSafePath(args.output,'output') : filePath;
437
620
  const dir = await tempDir(create ? 'remcp-docx-create-' : 'remcp-docx-edit-');
438
621
  let outputStage = '';
622
+ let outputStageDirectory = null;
439
623
  let tempOut = '';
440
624
  let changes = 0;
441
625
  try {
442
- await mkdir(path.dirname(output), { recursive:true });
443
- outputStage = await mkdtemp(path.join(path.dirname(output), '.remcp-docx-output-'));
626
+ outputStageDirectory = await createAnchoredTempDirectory(path.dirname(output), '.remcp-docx-output-');
627
+ outputStage = outputStageDirectory.path;
444
628
  tempOut = path.join(outputStage, path.basename(output));
445
629
  if (create) {
446
630
  await ensureCreateTargetIsNew(filePath);
@@ -467,11 +651,13 @@ export async function editDocument(args) {
467
651
  }
468
652
  await writeFile(documentPath,xml,'utf8');
469
653
  await createZip(dir,tempOut);
470
- await commitDocumentOutput(tempOut,output);
471
- 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
+
472
657
  } finally {
473
658
  if (tempOut) await rm(tempOut,{force:true}).catch(() => {});
474
659
  if (outputStage) await rm(outputStage,{recursive:true, force:true}).catch(() => {});
660
+ if (outputStageDirectory) await outputStageDirectory.close().catch(() => {});
475
661
  await removeTemp(dir);
476
662
  }
477
663
  }
@@ -524,62 +710,110 @@ export async function pdfAction(args) {
524
710
  }
525
711
  if (action === 'info') {
526
712
  const source = await resolveSafePath(args.path,'path');
527
- if (commandExists('pdfinfo')) return text((await runFile('pdfinfo',[source],{label:'pdfinfo'})).stdout);
528
- const data = await readDocumentFile(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 });
529
720
  }
530
721
  if (action === 'merge') {
531
722
  const requested = Array.isArray(args.paths) ? args.paths : [];
532
723
  if (requested.length < 2 || requested.length > 100) throw new Error('merge requires 2..100 PDFs in paths');
533
- 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'));
534
726
  const output=await resolveSafePath(args.output,'output');
535
- if(commandExists('pdfunite')) await runFile('pdfunite',[...sources,output],{label:'PDF merge',timeout:60_000});
536
- else if(commandExists('qpdf')) await runFile('qpdf',['--empty','--pages',...sources,'--',output],{label:'PDF merge',timeout:60_000});
537
- else unavailable('PDF merge','install poppler-utils (pdfunite) or qpdf');
538
- 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
+ }
539
748
  }
540
749
  const source=await resolveSafePath(args.path,'path');
541
750
  if(action==='split'){
542
751
  const requestedOutputDir=await resolveSafePath(args.output_dir || path.dirname(source),'output_dir');
543
- await mkdir(requestedOutputDir,{recursive:true});
544
752
  const outputDir=await resolveSafePath(requestedOutputDir,'output_dir');
545
753
  const patternName=safePdfPattern(optionalString(args.pattern),`${path.basename(source,path.extname(source))}-%d.pdf`);
546
- const pattern=path.join(outputDir,patternName);
547
- if(commandExists('pdfseparate')) await runFile('pdfseparate',[source,pattern],{label:'PDF split',timeout:60_000});
548
- else unavailable('PDF split','install poppler-utils (pdfseparate)');
549
- return jsonResult({action,output_dir:outputDir,files:(await readdir(outputDir)).filter(name=>name.toLowerCase().endsWith('.pdf')).sort()});
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
+ }
550
775
  }
551
776
  const pages=optionalString(args.pages);
552
777
  const selectedPages=expandPdfPages(pages);
553
778
  const normalizedPages=selectedPages.join(',');
554
779
  const output=await resolveSafePath(args.output,'output');
555
- if(commandExists('qpdf')) {
556
- await runFile('qpdf',[source,'--pages','.',pages.replace(/\s+/g,''),'--',output],{label:'PDF extract pages',timeout:60_000});
557
- } else if(commandExists('pdftk')) {
558
- await runFile('pdftk',[source,'cat',...pages.replace(/\s+/g,'').split(','),'output',output],{label:'PDF extract pages',timeout:60_000});
559
- } else if(commandExists('pdfseparate')) {
560
- const dir=await tempDir('remcp-pdf-pages-');
561
- try {
562
- const extracted=[];
563
- for(let index=0;index<selectedPages.length;index+=1){
564
- const page=selectedPages[index];
565
- const pattern=path.join(dir,`selection-${index+1}-%d.pdf`);
566
- await runFile('pdfseparate',['-f',String(page),'-l',String(page),source,pattern],{label:`PDF extract page ${page}`,timeout:60_000});
567
- extracted.push(path.join(dir,`selection-${index+1}-${page}.pdf`));
568
- }
569
- if(extracted.length===1) {
570
- await copyFile(extracted[0],output);
571
- } else if(commandExists('pdfunite')) {
572
- await runFile('pdfunite',[...extracted,output],{label:'PDF assemble extracted pages',timeout:60_000});
573
- } else {
574
- unavailable('PDF page extraction','pdfunite is required with pdfseparate when extracting multiple pages');
575
- }
576
- } finally {
577
- 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)');
578
809
  }
579
- } else {
580
- 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(()=>{});
581
816
  }
582
- return jsonResult({action,source,pages:normalizedPages,output,bytes:(await stat(output)).size});
583
817
  }
584
818
 
585
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;