@remcp/runtime 0.2.83 → 0.2.85
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 +12 -0
- package/package.json +1 -1
- package/src/extended/catalog.mjs +6 -6
- package/src/extended/documents.mjs +63 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.85 — 2026-09-21
|
|
4
|
+
|
|
5
|
+
- Runtime capability remains 83 granular tools; no device-side operation is removed or renamed in this patch.
|
|
6
|
+
- The hosted server now groups those operations into a compact typed discovery façade while dispatch still validates and executes the original per-operation runtime schemas.
|
|
7
|
+
- Lockstep patch release with the ReMCP client/server at `0.2.85`.
|
|
8
|
+
|
|
9
|
+
## 0.2.84 — 2026-09-21
|
|
10
|
+
|
|
11
|
+
- `edit_document` can create a new DOCX with `create=true`, refusing to overwrite an existing destination.
|
|
12
|
+
- `edit_spreadsheet` can create a new XLSX workbook with an optional first-sheet name and existing cell/range/formula edits.
|
|
13
|
+
- Added OOXML creation, overwrite-safety, invalid-sheet-name, schema/output, and read-back regression coverage; the runtime catalog remains 83 tools.
|
|
14
|
+
|
|
3
15
|
## 0.2.83 — 2026-09-21
|
|
4
16
|
|
|
5
17
|
- Linux X11 `record_screen` capability now requires both `ffmpeg` and a real `DISPLAY`; headless hosts no longer advertise a recorder that will fail.
|
package/package.json
CHANGED
package/src/extended/catalog.mjs
CHANGED
|
@@ -64,8 +64,8 @@ const AI_TOOL_DESCRIPTIONS = Object.freeze({
|
|
|
64
64
|
power_action: 'Use this only when the user explicitly asks to lock, sleep, restart or shut down the computer. Prefer it over invoking those system actions through start_process; it is intentionally guarded by device command policy because restart/shutdown can interrupt work.',
|
|
65
65
|
record_screen: 'Use this for a short bounded screen recording when motion/timing is necessary to diagnose a UI issue. Prefer screenshot_region for static visual verification because it is cheaper and easier to inspect.',
|
|
66
66
|
read_document: 'Use this for structured document content, especially PDF, DOCX and XLSX, and for CSV/JSON/XML when document-style parsing is useful. Use read_file for ordinary source code, logs, configuration, Markdown, or other plain line-oriented text. Prefer read_document over shell extraction or launching Office when the goal is document content; PDF reading uses the built-in parser first and may fall back to local pdftotext when embedded fonts prevent direct decoding.',
|
|
67
|
-
edit_spreadsheet: 'Use this
|
|
68
|
-
edit_document: 'Use this
|
|
67
|
+
edit_spreadsheet: 'Use this to create a new XLSX workbook or directly edit XLSX cells/ranges/formulas without launching Excel. Set create=true only for a new path; it fails rather than overwriting an existing workbook. Prefer explicit cells/ranges and write to output when preserving an existing original matters.',
|
|
68
|
+
edit_document: 'Use this to create a new DOCX document or make structured DOCX paragraph edits without launching Word. Set create=true only for a new path; it fails rather than overwriting an existing document. It is best for replace/insert/delete paragraph operations; use native UI automation only when document formatting/layout interaction is required.',
|
|
69
69
|
pdf_action: 'Use this for PDF metadata/annotations inspection or structural merge/split/page extraction. For reading PDF text use read_document; supply an output/output_dir for write actions when preserving originals matters.',
|
|
70
70
|
});
|
|
71
71
|
|
|
@@ -217,8 +217,8 @@ const OUTPUT_FIELDS = Object.freeze({
|
|
|
217
217
|
power_action: { text:outputString(), action:outputString() },
|
|
218
218
|
record_screen: { path:outputString(), bytes:outputNumber(), duration_seconds:outputNumber(), format:outputString() },
|
|
219
219
|
read_document: { text:outputString(), path:outputString(), data:{}, sheets:outputArray(outputObject()), sheet:outputString(), rows:outputArray({}), cells:outputArray({}) },
|
|
220
|
-
edit_spreadsheet: { path:outputString(), sheet:outputString(), edited_cells:outputNumber(), bytes:outputNumber() },
|
|
221
|
-
edit_document: { path:outputString(), operations:outputNumber(), changes:outputNumber(), bytes:outputNumber() },
|
|
220
|
+
edit_spreadsheet: { path:outputString(), sheet:outputString(), edited_cells:outputNumber(), bytes:outputNumber(), created:outputBoolean() },
|
|
221
|
+
edit_document: { path:outputString(), operations:outputNumber(), changes:outputNumber(), bytes:outputNumber(), created:outputBoolean() },
|
|
222
222
|
pdf_action: { action:outputString(), path:outputString(), source:outputString(), output:outputString(), output_dir:outputString(), files:outputArray(outputString()), pages:outputString(), bytes:outputNumber(), annotations:outputArray(outputObject()), inputs:outputNumber() },
|
|
223
223
|
});
|
|
224
224
|
|
|
@@ -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.') }), 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),
|
|
572
|
-
define('edit_spreadsheet', 'Edit spreadsheet', '
|
|
573
|
-
define('edit_document', 'Edit DOCX document', '
|
|
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, ['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, ['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),
|
|
575
575
|
];
|
|
576
576
|
|
|
@@ -71,6 +71,47 @@ async function createZip(sourceDir, destination) {
|
|
|
71
71
|
await runFile('zip', ['-qr',destination,'.'], { cwd:sourceDir, label:'create OOXML archive', timeout:30_000 });
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
async function ensureCreateTargetIsNew(filePath) {
|
|
75
|
+
try {
|
|
76
|
+
await stat(filePath);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (error?.code === 'ENOENT') return;
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
throw new Error(`Cannot create document because path already exists: ${filePath}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function writeOoxmlPart(root, relative, content) {
|
|
85
|
+
const target = path.join(root, ...relative.split('/'));
|
|
86
|
+
await mkdir(path.dirname(target), { recursive:true });
|
|
87
|
+
await writeFile(target, content, 'utf8');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function newWorksheetName(value) {
|
|
91
|
+
const name = optionalString(value) || 'Sheet1';
|
|
92
|
+
if (name.length > 31) throw new Error('Worksheet name must be 31 characters or fewer');
|
|
93
|
+
if (/[\\/*?:\[\]]/.test(name) || name.startsWith("'") || name.endsWith("'")) {
|
|
94
|
+
throw new Error('Worksheet name contains characters Excel does not allow');
|
|
95
|
+
}
|
|
96
|
+
return name;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function createBlankXlsxTree(root, sheetName) {
|
|
100
|
+
const name = newWorksheetName(sheetName);
|
|
101
|
+
await writeOoxmlPart(root, '[Content_Types].xml', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/></Types>');
|
|
102
|
+
await writeOoxmlPart(root, '_rels/.rels', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>');
|
|
103
|
+
await writeOoxmlPart(root, 'xl/workbook.xml', `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="${xmlEscape(name)}" sheetId="1" r:id="rId1"/></sheets></workbook>`);
|
|
104
|
+
await writeOoxmlPart(root, 'xl/_rels/workbook.xml.rels', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/></Relationships>');
|
|
105
|
+
await writeOoxmlPart(root, 'xl/worksheets/sheet1.xml', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData></sheetData></worksheet>');
|
|
106
|
+
return name;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function createBlankDocxTree(root) {
|
|
110
|
+
await writeOoxmlPart(root, '[Content_Types].xml', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>');
|
|
111
|
+
await writeOoxmlPart(root, '_rels/.rels', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>');
|
|
112
|
+
await writeOoxmlPart(root, 'word/document.xml', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:sectPr><w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="708" w:footer="708" w:gutter="0"/></w:sectPr></w:body></w:document>');
|
|
113
|
+
}
|
|
114
|
+
|
|
74
115
|
function workbookSheetPath(workbookXml, relsXml, requestedName) {
|
|
75
116
|
const sheets = [...workbookXml.matchAll(/<sheet\b[^>]*name="([^"]+)"[^>]*(?:r:id|id)="([^"]+)"[^>]*\/?>(?:<\/sheet>)?/g)].map(match => ({ name:xmlUnescape(match[1]), rid:match[2] }));
|
|
76
117
|
const sheet = requestedName ? sheets.find(item => item.name.toLowerCase() === requestedName.toLowerCase()) : sheets[0];
|
|
@@ -227,14 +268,21 @@ function expandSpreadsheetEdits(edits) {
|
|
|
227
268
|
export async function editSpreadsheet(args) {
|
|
228
269
|
const filePath = await resolveSafePath(args.path,'path');
|
|
229
270
|
if (!filePath.toLowerCase().endsWith('.xlsx')) throw new Error('edit_spreadsheet supports .xlsx files');
|
|
271
|
+
const create = args.create === true;
|
|
272
|
+
if (create && args.output) throw new Error('output is not used with create=true; path is the new workbook destination');
|
|
230
273
|
const requestedEdits = Array.isArray(args.edits) ? args.edits : [];
|
|
231
274
|
if (!requestedEdits.length || requestedEdits.length > 500) throw new Error('edits must contain 1..500 cell/range edits');
|
|
232
275
|
const edits = expandSpreadsheetEdits(requestedEdits);
|
|
233
276
|
const output = args.output ? await resolveSafePath(args.output,'output') : filePath;
|
|
234
|
-
const dir = await tempDir('remcp-xlsx-edit-');
|
|
277
|
+
const dir = await tempDir(create ? 'remcp-xlsx-create-' : 'remcp-xlsx-edit-');
|
|
235
278
|
const tempOut = path.join(path.dirname(output), `.remcp-${Date.now()}-${path.basename(output)}`);
|
|
236
279
|
try {
|
|
237
|
-
|
|
280
|
+
if (create) {
|
|
281
|
+
await ensureCreateTargetIsNew(filePath);
|
|
282
|
+
await createBlankXlsxTree(dir, optionalString(args.sheet));
|
|
283
|
+
} else {
|
|
284
|
+
await extractZip(filePath,dir);
|
|
285
|
+
}
|
|
238
286
|
const workbook = await readFile(path.join(dir,'xl','workbook.xml'),'utf8');
|
|
239
287
|
const rels = await readFile(path.join(dir,'xl','_rels','workbook.xml.rels'),'utf8');
|
|
240
288
|
const sheet = workbookSheetPath(workbook,rels,optionalString(args.sheet));
|
|
@@ -242,9 +290,10 @@ export async function editSpreadsheet(args) {
|
|
|
242
290
|
let xml = await readFile(worksheetPath,'utf8');
|
|
243
291
|
for (const edit of edits) xml = setWorksheetCell(xml,String(edit.cell || '').toUpperCase(),edit.value,edit.formula);
|
|
244
292
|
await writeFile(worksheetPath,xml,'utf8');
|
|
293
|
+
await mkdir(path.dirname(output), { recursive:true });
|
|
245
294
|
await createZip(dir,tempOut);
|
|
246
295
|
await copyFile(tempOut,output);
|
|
247
|
-
return jsonResult({ path:output, sheet:sheet.name, edited_cells:edits.length, bytes:(await stat(output)).size });
|
|
296
|
+
return jsonResult({ path:output, sheet:sheet.name, edited_cells:edits.length, bytes:(await stat(output)).size, created:create });
|
|
248
297
|
} finally {
|
|
249
298
|
await rm(tempOut,{force:true}).catch(() => {});
|
|
250
299
|
await removeTemp(dir);
|
|
@@ -301,14 +350,21 @@ function mutateMatchingParagraphs(xml, op) {
|
|
|
301
350
|
export async function editDocument(args) {
|
|
302
351
|
const filePath = await resolveSafePath(args.path,'path');
|
|
303
352
|
if (!filePath.toLowerCase().endsWith('.docx')) throw new Error('edit_document supports .docx files');
|
|
353
|
+
const create = args.create === true;
|
|
354
|
+
if (create && args.output) throw new Error('output is not used with create=true; path is the new document destination');
|
|
304
355
|
const operations = Array.isArray(args.operations) ? args.operations : [];
|
|
305
356
|
if (!operations.length || operations.length > 100) throw new Error('operations must contain 1..100 edits');
|
|
306
357
|
const output = args.output ? await resolveSafePath(args.output,'output') : filePath;
|
|
307
|
-
const dir = await tempDir('remcp-docx-edit-');
|
|
358
|
+
const dir = await tempDir(create ? 'remcp-docx-create-' : 'remcp-docx-edit-');
|
|
308
359
|
const tempOut = path.join(path.dirname(output), `.remcp-${Date.now()}-${path.basename(output)}`);
|
|
309
360
|
let changes = 0;
|
|
310
361
|
try {
|
|
311
|
-
|
|
362
|
+
if (create) {
|
|
363
|
+
await ensureCreateTargetIsNew(filePath);
|
|
364
|
+
await createBlankDocxTree(dir);
|
|
365
|
+
} else {
|
|
366
|
+
await extractZip(filePath,dir);
|
|
367
|
+
}
|
|
312
368
|
const documentPath = path.join(dir,'word','document.xml');
|
|
313
369
|
let xml = await readFile(documentPath,'utf8');
|
|
314
370
|
for (const op of operations) {
|
|
@@ -327,9 +383,10 @@ export async function editDocument(args) {
|
|
|
327
383
|
}
|
|
328
384
|
}
|
|
329
385
|
await writeFile(documentPath,xml,'utf8');
|
|
386
|
+
await mkdir(path.dirname(output), { recursive:true });
|
|
330
387
|
await createZip(dir,tempOut);
|
|
331
388
|
await copyFile(tempOut,output);
|
|
332
|
-
return jsonResult({ path:output, operations:operations.length, changes, bytes:(await stat(output)).size });
|
|
389
|
+
return jsonResult({ path:output, operations:operations.length, changes, bytes:(await stat(output)).size, created:create });
|
|
333
390
|
} finally {
|
|
334
391
|
await rm(tempOut,{force:true}).catch(() => {});
|
|
335
392
|
await removeTemp(dir);
|