@remcp/runtime 0.2.123 → 0.2.124

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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.124 — 2026-10-01
4
+
5
+ - P3 document capability discovery is cross-platform: Windows uses the native .NET OOXML archive backend, while macOS/Linux require both `zip` and `unzip` before advertising the XLSX/DOCX editor tools.
6
+ - `read_document` and `pdf_action` remain discoverable on supported desktop platforms even without an OOXML archive backend; DOCX/XLSX read attempts still fail closed at execution time when extraction is unavailable. `edit_spreadsheet` and `edit_document` explicitly require `ooxml`.
7
+ - Windows and macOS release CI run real candidate XLSX/DOCX create/read round trips before publication. The 83-tool names and schemas are unchanged.
8
+
3
9
  ## 0.2.123 — 2026-10-01
4
10
 
5
11
  - The compact local MCP facade now tracks live capability changes after startup. It advertises `tools.listChanged`, refreshes grouped input/output schemas, and adds or removes domains such as `control_browser` when loopback CDP becomes available or unavailable.
package/README.md CHANGED
@@ -67,7 +67,8 @@ For frontend QA, use semantic data to operate the page and rendered pixels to ve
67
67
  `set_config_value` remains deliberately narrow: it can change only `telemetryEnabled`,
68
68
  `maxReadLines`, `maxBufferedLines`, and `maxOutputBytes`. Access roots, blocked commands,
69
69
  the command guardrail, shell, write limit, runtime name, and unrestricted mode stay with the person
70
- at the computer. DOCX/XLSX edits operate directly on OOXML, while structural PDF writes use existing
70
+ at the computer. DOCX/XLSX edits operate directly on OOXML: Windows uses the built-in .NET ZIP backend,
71
+ while macOS/Linux advertise the OOXML editor tools only when both `zip` and `unzip` are available. `read_document` stays discoverable for PDF/plain-text formats when that backend is absent and fails closed only for DOCX/XLSX reads. Structural PDF writes use existing
71
72
  system tools such as qpdf/poppler when present. PDF text reading uses the built-in parser first and can fall back to local `pdftotext` for embedded-font PDFs; ReMCP still does not bundle Chromium, Puppeteer,
72
73
  `sharp`, or `exceljs`.
73
74
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.123",
3
+ "version": "0.2.124",
4
4
  "description": "First-party ReMCP local device runtime: filesystem, terminal, computer-use, browser, diagnostics and document tools over MCP.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/documents.mjs CHANGED
@@ -40,7 +40,7 @@ function unzipEntry(buffer, wanted) {
40
40
  const extraLength = buffer.readUInt16LE(offset + 30);
41
41
  const commentLength = buffer.readUInt16LE(offset + 32);
42
42
  const localOffset = buffer.readUInt32LE(offset + 42);
43
- const name = buffer.toString('utf8', offset + 46, offset + 46 + nameLength);
43
+ const name = buffer.toString('utf8', offset + 46, offset + 46 + nameLength).replace(/\\/g, '/');
44
44
  if (name === wanted) {
45
45
  const localNameLength = buffer.readUInt16LE(localOffset + 26);
46
46
  const localExtraLength = buffer.readUInt16LE(localOffset + 28);
@@ -116,16 +116,19 @@ function wordXmlText(xml) {
116
116
  return output;
117
117
  }
118
118
 
119
- export function readDocxText(buffer) {
120
- const document = unzipEntry(buffer, 'word/document.xml');
121
- if (!document) throw new Error('This file is not a readable .docx (its word/document.xml is missing or compressed in an unsupported way)');
122
- const xml = document.toString('utf8');
123
- return wordXmlText(xml)
119
+ export function readDocxXmlText(xml) {
120
+ return wordXmlText(String(xml))
124
121
  .replace(/&(amp|lt|gt|quot|apos);/g, match => DOCX_ENTITIES[match])
125
122
  .replace(/\n{3,}/g, '\n\n')
126
123
  .trim();
127
124
  }
128
125
 
126
+ export function readDocxText(buffer) {
127
+ const document = unzipEntry(buffer, 'word/document.xml');
128
+ if (!document) throw new Error('This file is not a readable .docx (its word/document.xml is missing or compressed in an unsupported way)');
129
+ return readDocxXmlText(document.toString('utf8'));
130
+ }
131
+
129
132
  function decodePdfString(raw) {
130
133
  // Literal strings arrive with backslash escapes; hex strings are pairs of hex digits.
131
134
  return raw
@@ -569,8 +569,8 @@ export const extendedToolDefinitions = [
569
569
  define('record_screen', 'Record screen', 'Record a short bounded desktop video to a permitted local file for debugging and return its path and size. Uses native/available recording helpers.', o({ duration_seconds:n('1..120 seconds.'), fps:n('1..60.'), destination:s('Permitted output path. If an extension is supplied, use .webm for native GNOME Wayland recording and .mp4 for the other recorder backends.') }), mutating, diagnosticHandlers.record_screen, ['record']),
570
570
 
571
571
  define('read_document', 'Read document', 'Read PDF, DOCX, XLSX, TXT, Markdown, CSV, JSON, or XML. PDF/DOCX extraction is built in and XLSX is parsed from its OOXML worksheet cells.', o({ path:s('Document path.'), sheet:s('Worksheet name for XLSX.'), max_cells:n() }, ['path']), readOnly, documentHandlers.read_document, ['documents']),
572
- define('edit_spreadsheet', 'Edit spreadsheet', 'Create a new XLSX workbook or edit up to 500 expanded XLSX cells directly in OOXML without launching Excel. For creation set create=true and use path as a new destination; existing files are never overwritten by create mode. Each edit may target one cell or a rectangular range, with a scalar fill, a values matrix, or a formula.', o({ path:s('Existing .xlsx path, or the new workbook destination when create=true.'), create:b('Create a new XLSX at path. Fails if path already exists; do not combine with output.'), output:s('Optional output .xlsx when editing an existing workbook; defaults to replacing input.'), sheet:s('Worksheet name. For create=true, defaults to Sheet1 and becomes the new workbook sheet name.'), edits:{ type:'array', maxItems:500, items:o({ cell:s('Single A1 cell reference.'), range:s('Rectangular range such as A1:C3.'), value:{ description:'Scalar value for one cell or to fill a range.' }, values:{ type:'array', items:{ type:'array', items:{} }, description:'2D matrix matching the range dimensions.' }, formula:s('Optional formula; for a range it is written to each expanded cell.') }) } }, ['path','edits']), mutating, documentHandlers.edit_spreadsheet, ['documents']),
573
- define('edit_document', 'Edit DOCX document', 'Create a new DOCX document or edit DOCX paragraph structure directly in OOXML: replace text, append/prepend paragraphs, insert before/after matching paragraphs, or delete matching paragraphs. For creation set create=true and use path as a new destination; existing files are never overwritten by create mode.', o({ path:s('Existing .docx path, or the new document destination when create=true.'), create:b('Create a new DOCX at path. Fails if path already exists; do not combine with output.'), output:s('Optional output .docx path when editing an existing document.'), operations:{ type:'array', maxItems:100, items:o({ action:e(['replace','append_paragraph','prepend_paragraph','insert_paragraph_before','insert_paragraph_after','delete_paragraph']), search:s('Paragraph text substring for replace/insert/delete operations.'), replacement:s(), all:b('Apply to all matching paragraphs; default true.'), text:s('Paragraph text to append/prepend/insert.') }, ['action']) } }, ['path','operations']), mutating, documentHandlers.edit_document, ['documents']),
572
+ define('edit_spreadsheet', 'Edit spreadsheet', 'Create a new XLSX workbook or edit up to 500 expanded XLSX cells directly in OOXML without launching Excel. For creation set create=true and use path as a new destination; existing files are never overwritten by create mode. Each edit may target one cell or a rectangular range, with a scalar fill, a values matrix, or a formula.', o({ path:s('Existing .xlsx path, or the new workbook destination when create=true.'), create:b('Create a new XLSX at path. Fails if path already exists; do not combine with output.'), output:s('Optional output .xlsx when editing an existing workbook; defaults to replacing input.'), sheet:s('Worksheet name. For create=true, defaults to Sheet1 and becomes the new workbook sheet name.'), edits:{ type:'array', maxItems:500, items:o({ cell:s('Single A1 cell reference.'), range:s('Rectangular range such as A1:C3.'), value:{ description:'Scalar value for one cell or to fill a range.' }, values:{ type:'array', items:{ type:'array', items:{} }, description:'2D matrix matching the range dimensions.' }, formula:s('Optional formula; for a range it is written to each expanded cell.') }) } }, ['path','edits']), mutating, documentHandlers.edit_spreadsheet, ['documents','ooxml']),
573
+ define('edit_document', 'Edit DOCX document', 'Create a new DOCX document or edit DOCX paragraph structure directly in OOXML: replace text, append/prepend paragraphs, insert before/after matching paragraphs, or delete matching paragraphs. For creation set create=true and use path as a new destination; existing files are never overwritten by create mode.', o({ path:s('Existing .docx path, or the new document destination when create=true.'), create:b('Create a new DOCX at path. Fails if path already exists; do not combine with output.'), output:s('Optional output .docx path when editing an existing document.'), operations:{ type:'array', maxItems:100, items:o({ action:e(['replace','append_paragraph','prepend_paragraph','insert_paragraph_before','insert_paragraph_after','delete_paragraph']), search:s('Paragraph text substring for replace/insert/delete operations.'), replacement:s(), all:b('Apply to all matching paragraphs; default true.'), text:s('Paragraph text to append/prepend/insert.') }, ['action']) } }, ['path','operations']), mutating, documentHandlers.edit_document, ['documents','ooxml']),
574
574
  define('pdf_action', 'PDF action', 'Inspect PDF annotations or metadata, merge PDFs, split a PDF into pages, or extract selected page ranges. Structural writes use qpdf/poppler/pdftk when installed.', o({ action:e(['merge','split','extract_pages','annotations','info']), path:s(), paths:{type:'array',items:{type:'string'}}, output:s(), output_dir:s(), pattern:s(), pages:s('Page list/ranges such as 1-3,5.') }, ['action']), mutating, documentHandlers.pdf_action, ['documents']),
575
575
  ];
576
576
 
@@ -598,6 +598,21 @@ async function linuxHasPyAtSpi() {
598
598
  return pyAtSpiCache.value;
599
599
  }
600
600
 
601
+ export function documentCapabilitySnapshot({
602
+ platform = process.platform,
603
+ commandExistsFn = commandExists,
604
+ } = {}) {
605
+ const supportedPlatform = ['win32','darwin','linux'].includes(platform);
606
+ const nativeOoxml = platform === 'win32';
607
+ const archiveOoxml = (platform === 'darwin' || platform === 'linux')
608
+ && commandExistsFn('zip')
609
+ && commandExistsFn('unzip');
610
+ return {
611
+ documents:supportedPlatform,
612
+ ooxml:supportedPlatform && (nativeOoxml || archiveOoxml),
613
+ };
614
+ }
615
+
601
616
  export async function capabilitySnapshot() {
602
617
  const win = process.platform === 'win32';
603
618
  const mac = process.platform === 'darwin';
@@ -608,6 +623,7 @@ export async function capabilitySnapshot() {
608
623
  const portalInput = wayland && waylandPortalCandidate();
609
624
  const x11Input = linux && !wayland && (commandExists('wdotool') || commandExists('xdotool'));
610
625
  const browserCdp = browserRemoteEnabled() && await browserControlAvailable(undefined, 500);
626
+ const documentCapabilities = documentCapabilitySnapshot();
611
627
  return {
612
628
  windows: win || mac || commandExists('wdotool') || commandExists('wmctrl') || (wayland && pyAtSpi),
613
629
  ui: win || mac || pyAtSpi,
@@ -620,7 +636,6 @@ export async function capabilitySnapshot() {
620
636
  notifications: win || mac || commandExists('notify-send'),
621
637
  browser_cdp: browserCdp,
622
638
  browser_evaluate: browserCdp && (process.env.NODE_ENV !== 'production' || ['1', 'true', 'yes', 'on'].includes(String(process.env.REMCP_BROWSER_ALLOW_EVALUATE || '').toLowerCase())),
623
- documents: linux,
624
639
  services: win || mac || commandExists('systemctl'),
625
640
 
626
641
  logs: win || mac || commandExists('journalctl'),
@@ -628,7 +643,7 @@ export async function capabilitySnapshot() {
628
643
  audio: win || mac || commandExists('wpctl') || commandExists('pactl') || commandExists('amixer'),
629
644
  power: win || mac || commandExists('systemctl') || commandExists('loginctl'),
630
645
  record: recordScreenAvailable({ platform:process.platform, wayland, gnomeSupported:gnomeRecord }),
631
- ooxml: linux && (commandExists('zip') && commandExists('unzip')),
646
+ ...documentCapabilities,
632
647
  };
633
648
  }
634
649
 
@@ -3,7 +3,7 @@ import process from 'node:process';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { constants } from 'node:fs';
5
5
  import { copyFile, link, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
6
- import { readDocxText, readPdfText } from '../documents.mjs';
6
+ import { readDocxXmlText, readPdfText } from '../documents.mjs';
7
7
  import { openDirectoryPath } from '../tools/files.mjs';
8
8
  import { resolveSafePath, text } from '../util.mjs';
9
9
  import {
@@ -353,6 +353,25 @@ async function readXlsx(filePath, args) {
353
353
  }
354
354
  }
355
355
 
356
+ async function readDocxDocument(filePath) {
357
+ const dir = await tempDir('remcp-docx-read-');
358
+ try {
359
+ await extractZip(filePath, dir);
360
+ let xml;
361
+ try {
362
+ xml = await readFile(path.join(dir, 'word', 'document.xml'), 'utf8');
363
+ } catch (error) {
364
+ if (error?.code === 'ENOENT') {
365
+ throw new Error('This file is not a readable .docx (word/document.xml is missing)');
366
+ }
367
+ throw error;
368
+ }
369
+ return readDocxXmlText(xml);
370
+ } finally {
371
+ await removeTemp(dir);
372
+ }
373
+ }
374
+
356
375
  async function readPdfDocument(data) {
357
376
  try {
358
377
  return readPdfText(data);
@@ -380,10 +399,10 @@ async function readPdfDocument(data) {
380
399
  export async function readDocument(args) {
381
400
  const filePath = await resolveSafePath(args.path,'path');
382
401
  const lower = filePath.toLowerCase();
402
+ if (lower.endsWith('.docx')) return text(await readDocxDocument(filePath));
403
+ if (lower.endsWith('.xlsx')) return jsonResult(await readXlsx(filePath,args));
383
404
  const data = await readDocumentFile(filePath);
384
- if (lower.endsWith('.docx')) return text(readDocxText(data));
385
405
  if (lower.endsWith('.pdf')) return text(await readPdfDocument(data));
386
- if (lower.endsWith('.xlsx')) return jsonResult(await readXlsx(filePath,args));
387
406
  if (/\.(txt|md|csv|json|xml|yaml|yml)$/i.test(lower)) return text(data.toString('utf8'));
388
407
  throw new Error('read_document supports PDF, DOCX, XLSX, TXT, Markdown, CSV, JSON, XML, YAML');
389
408
  }