@tiwater/office-mcp 0.21.34 → 0.21.35

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.
@@ -70,6 +70,17 @@ export function assertEffectKindToolContract(tool, expectedKind = undefined) {
70
70
  return metadata.kind;
71
71
  }
72
72
 
73
+ export function documentMutationFileArguments(tool, args) {
74
+ assertEffectKindToolContract(tool, 'document-mutation');
75
+ const bindings = boundFileArguments(tool?.inputSchema, args);
76
+ const current = bindings.filter(binding => binding.revisionRole === 'current');
77
+ const effectiveOutput = bindings.filter(binding => binding.role === 'write' && binding.effect);
78
+ if (current.length !== 1 || effectiveOutput.length !== 1) {
79
+ throw new Error(`document-mutation-file-arguments-invalid:${tool?.name || 'unnamed'}`);
80
+ }
81
+ return { current: current[0].value, effectiveOutput: effectiveOutput[0].value };
82
+ }
83
+
73
84
  function fileBindings(schema) {
74
85
  const bindings = [];
75
86
  function visit(node) {
@@ -91,3 +102,35 @@ function fileBindings(schema) {
91
102
  visit(schema);
92
103
  return bindings;
93
104
  }
105
+
106
+ function boundFileArguments(schema, value) {
107
+ const bindings = [];
108
+ function visit(node, currentValue) {
109
+ if (!node || typeof node !== 'object' || Array.isArray(node)) return;
110
+ if (node['x-tiwater-file-role'] === 'read' || node['x-tiwater-file-role'] === 'write') {
111
+ if (typeof currentValue !== 'string' || currentValue.length === 0) {
112
+ throw new Error('provider-file-argument-invalid');
113
+ }
114
+ bindings.push({
115
+ role: node['x-tiwater-file-role'],
116
+ effect: node['x-tiwater-file-role'] === 'write'
117
+ && node['x-tiwater-file-effect'] !== false,
118
+ revisionRole: node[documentRevisionRoleKey],
119
+ value: currentValue,
120
+ });
121
+ return;
122
+ }
123
+ if (Array.isArray(currentValue)) {
124
+ for (const entry of currentValue) visit(node.items, entry);
125
+ } else if (currentValue && typeof currentValue === 'object') {
126
+ for (const [name, child] of Object.entries(node.properties || {})) {
127
+ if (Object.hasOwn(currentValue, name)) visit(child, currentValue[name]);
128
+ }
129
+ }
130
+ for (const keyword of ['allOf', 'anyOf', 'oneOf']) {
131
+ for (const child of node[keyword] || []) visit(child, currentValue);
132
+ }
133
+ }
134
+ visit(schema, value);
135
+ return bindings;
136
+ }
@@ -2,7 +2,7 @@
2
2
  "schema": "tiwater.office-provider-contract-manifest/v1",
3
3
  "provider": {
4
4
  "id": "@tiwater/office-mcp",
5
- "version": "0.21.34"
5
+ "version": "0.21.35"
6
6
  },
7
7
  "tools": [
8
8
  {
package/office/index.mjs CHANGED
@@ -24,7 +24,10 @@ import {
24
24
  } from '../_shared/large-json-result.mjs';
25
25
  import { withOutputWriteLock } from '../_shared/output-write-lock.mjs';
26
26
  import { evidenceRoleMetadata } from '../_shared/evidence-role.mjs';
27
- import { effectKindMetadata } from '../_shared/effect-kind.mjs';
27
+ import {
28
+ documentMutationFileArguments,
29
+ effectKindMetadata,
30
+ } from '../_shared/effect-kind.mjs';
28
31
  import { compactDocxObjectIdentity } from './docx-object-identity.mjs';
29
32
 
30
33
  const packageMetadata = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
@@ -38,14 +41,20 @@ if (inputContractManifest.provider?.id !== packageMetadata.name
38
41
  }
39
42
  const inputContracts = new Map(await Promise.all(inputContractManifest.tools.map(async entry => {
40
43
  const schema = JSON.parse(await readFile(new URL(`./contracts/${entry.name}.schema.json`, import.meta.url), 'utf8'));
41
- return [entry.name, z.fromJSONSchema(schema)];
44
+ return [entry.name, { schema, validator: z.fromJSONSchema(schema) }];
42
45
  })));
43
46
  const invocationCwd = process.cwd();
44
47
 
45
48
  function inputContract(toolName) {
46
49
  const contract = inputContracts.get(toolName);
47
50
  if (!contract) throw new Error(`Missing provider-owned MCP input contract: ${toolName}`);
48
- return contract;
51
+ return contract.validator;
52
+ }
53
+
54
+ function inputContractSchema(toolName) {
55
+ const contract = inputContracts.get(toolName);
56
+ if (!contract) throw new Error(`Missing provider-owned MCP input contract: ${toolName}`);
57
+ return contract.schema;
49
58
  }
50
59
 
51
60
  const docxCandidates = [
@@ -151,14 +160,14 @@ const xlsxFixedTools = [
151
160
  {"name":"xlsx_set_column_width","description":"Set current worksheet column widths."},
152
161
  ];
153
162
 
154
- function fixedToolDefinitions(definitions) {
163
+ function fixedToolDefinitions(definitions, candidates) {
155
164
  return definitions.map(definition => ({
156
165
  name: definition.name,
157
166
  effectKind: 'document-mutation',
158
167
  description: definition.description,
159
168
  inputSchema: inputContract(definition.name),
160
169
  outputSchema: fixedEditOutput(definition.name),
161
- handler: args => fixedEdit(definition.name, args),
170
+ handler: (args, tool) => fixedEdit(tool, args, candidates),
162
171
  }));
163
172
  }
164
173
 
@@ -472,7 +481,7 @@ const tools = [
472
481
  description: 'Replace existing target paragraph or table-cell content from explicitly selected native source cells, paragraphs, runs, text nodes, or exact text ranges while retaining target container formatting and table structure. For a source cell already returned by docx_read_table, select a Unicode-scalar range directly against its returned text; the provider retains every crossed native run, including superscript and subscript, without another descendant read. Consecutive run or text selections from one source paragraph form one target paragraph, and source paragraph boundaries remain paragraph boundaries. Use it after docx_fill_table_from_tables when the target needs selected source content instead of the whole source cell; pass returned addresses unchanged and never retype source-owned text. It does not copy source rows, cells, spans, or merges.',
473
482
  inputSchema: inputContract('docx_replace_content_from_source'),
474
483
  outputSchema: fixedEditOutput('docx_replace_content_from_source'),
475
- handler: args => fixedEdit('docx_replace_content_from_source', args),
484
+ handler: (args, tool) => fixedEdit(tool, args, docxCandidates),
476
485
  },
477
486
  {
478
487
  name: 'docx_set_text',
@@ -480,7 +489,7 @@ const tools = [
480
489
  description: 'Replace the whole text content of paragraph or cell objects observed from this exact input DOCX while retaining target formatting, bookmarks, spans, and vertical merges. For a vertically merged logical cell, write its visible text to the restart cell rather than a continue cell. Tabs and line breaks remain native document text controls; targets containing non-text objects are rejected. Use this only for newly derived text. Content copied or selected from a source DOCX uses docx_replace_content_from_source so native runs such as superscript and subscript are retained. This does not insert objects, change table structure, copy source formatting, or decide business wording.',
481
490
  inputSchema: inputContract('docx_set_text'),
482
491
  outputSchema: fixedEditOutput('docx_set_text'),
483
- handler: args => fixedEdit('docx_set_text', args),
492
+ handler: (args, tool) => fixedEdit(tool, args, docxCandidates),
484
493
  },
485
494
  {
486
495
  name: 'docx_set_paragraph_pagination',
@@ -488,7 +497,7 @@ const tools = [
488
497
  description: 'Set native pagination properties on explicitly selected current DOCX paragraphs. Each change sets at least one pagination property. keepWithNext keeps a paragraph with the immediately following paragraph or table but does not guarantee that a table header remains with its first body row. keepLinesTogether keeps one paragraph on one page; pageBreakBefore starts it on a new page; preventWidowOrphanLines controls isolated first or last lines. Omitted properties remain unchanged. The caller chooses paragraphs from current native addresses; the provider does not decide document layout or business meaning.',
489
498
  inputSchema: inputContract('docx_set_paragraph_pagination'),
490
499
  outputSchema: fixedEditOutput('docx_set_paragraph_pagination'),
491
- handler: args => fixedEdit('docx_set_paragraph_pagination', args),
500
+ handler: (args, tool) => fixedEdit(tool, args, docxCandidates),
492
501
  },
493
502
  {
494
503
  name: 'docx_set_table_body',
@@ -496,7 +505,7 @@ const tools = [
496
505
  description: 'Atomically replace one exact current target-table row range while retaining the table, target styles, grid widths, and all rows and surrounding content outside the range. When retaining leading rows reported with repeatHeader=true, existingRows starts after all of them and never at a verticalMerge=continue row. Name every target grid column in native order and choose one current row inside existingRows as the style prototype for each final row. Horizontal spans use contiguous column IDs. Set rowSpan on one logical cell to occupy multiple rows and omit those columns from the covered rows; the provider writes native vertical merge cells. Set cantSplit on a final physical row when it must remain whole across page boundaries; omit it to preserve the prototype row property. Every other grid column remains explicit. Every explicit cell includes an already-derived value; source-owned content is not retyped here. An empty final row array removes the range when another table row remains. The provider commits once and returns structural readback. It does not read source tables, map source to target, derive text, choose business columns, identify headers, or accept non-text cell content; use docx_fill_table_from_tables and docx_replace_content_from_source for source-owned table content.',
497
506
  inputSchema: inputContract('docx_set_table_body'),
498
507
  outputSchema: fixedEditOutput('docx_set_table_body'),
499
- handler: args => fixedEdit('docx_set_table_body', args),
508
+ handler: (args, tool) => fixedEdit(tool, args, docxCandidates),
500
509
  },
501
510
  {
502
511
  name: 'docx_fill_table_from_tables',
@@ -504,7 +513,7 @@ const tools = [
504
513
  description: 'Fill one current target-table body from one or more explicitly ordered current source-table row ranges. For each source, select an unmerged record column when every physical source row must remain an output row; mapped columns still retain their native vertical merges. Select a merged record column only when every mapping into one target cell has one equal scalar value throughout that merge group. Map source grid columns onto every target prototype cell. The provider concatenates source records in declared order, copies each mapped source cell in full, preserves horizontal spans, rebuilds vertical merges only within each source range, validates the complete target grid, commits once, and returns structural readback. It does not discover source tables, choose source or target business meaning, filter records, translate or rewrite text, or apply business-specific rules. A single source uses the same sources array with one item. If a target needs only selected source descendants, such as one language from a bilingual cell, immediately use the returned target-cell addresses with docx_replace_content_from_source before releasing that source or reading back the completed target.',
505
514
  inputSchema: inputContract('docx_fill_table_from_tables'),
506
515
  outputSchema: fixedEditOutput('docx_fill_table_from_tables'),
507
- handler: args => fixedEdit('docx_fill_table_from_tables', args),
516
+ handler: (args, tool) => fixedEdit(tool, args, docxCandidates),
508
517
  },
509
518
  {
510
519
  name: 'docx_insert_objects',
@@ -512,7 +521,7 @@ const tools = [
512
521
  description: 'Insert selected current DOCX objects under an existing parent. Table rows are objects: expand a target table by copying one contiguous observed row range and use repeat for count; sourceInput may equal input. A row range beginning with vertical-merge continuations may be inserted only inside a target boundary with the same active grid spans, which extends those merges. Individual table cells are not raw insertion targets because that would bypass the table grid.',
513
522
  inputSchema: inputContract('docx_insert_objects'),
514
523
  outputSchema: fixedEditOutput('docx_insert_objects'),
515
- handler: args => fixedEdit('docx_insert_objects', args),
524
+ handler: (args, tool) => fixedEdit(tool, args, docxCandidates),
516
525
  },
517
526
  {
518
527
  name: 'docx_delete_object',
@@ -520,7 +529,7 @@ const tools = [
520
529
  description: 'Delete selected current DOCX objects directly from the current target document. Selected table rows must close every vertical merge and cannot remove the whole table. Individual table cells are not raw deletion targets; use column or merge operations for table structure.',
521
530
  inputSchema: inputContract('docx_delete_object'),
522
531
  outputSchema: fixedEditOutput('docx_delete_object'),
523
- handler: args => fixedEdit('docx_delete_object', args),
532
+ handler: (args, tool) => fixedEdit(tool, args, docxCandidates),
524
533
  },
525
534
  {
526
535
  name: 'docx_merge_cells',
@@ -528,7 +537,7 @@ const tools = [
528
537
  description: 'Merge selected current DOCX cells when they form one closed rectangle. A one-column, multi-row rectangle creates a vertical merge whose first cell is the restart owner and whose later cells are continuations. All selected cell content moves into the top-left owner, so the selected content must already be correct for that one logical cell.',
529
538
  inputSchema: inputContract('docx_merge_cells'),
530
539
  outputSchema: fixedEditOutput('docx_merge_cells'),
531
- handler: args => fixedEdit('docx_merge_cells', args),
540
+ handler: (args, tool) => fixedEdit(tool, args, docxCandidates),
532
541
  },
533
542
  {
534
543
  name: 'docx_split_cells',
@@ -536,7 +545,7 @@ const tools = [
536
545
  description: 'Split selected current DOCX merged cells.',
537
546
  inputSchema: inputContract('docx_split_cells'),
538
547
  outputSchema: fixedEditOutput('docx_split_cells'),
539
- handler: args => fixedEdit('docx_split_cells', args),
548
+ handler: (args, tool) => fixedEdit(tool, args, docxCandidates),
540
549
  },
541
550
  {
542
551
  name: 'docx_insert_table_columns',
@@ -544,7 +553,7 @@ const tools = [
544
553
  description: 'Insert empty template-shaped grid columns into one current main-document table. Select an observed source grid column for width and per-row cell formatting, and optionally a before grid-column address; cells spanning the insertion boundary expand instead of being split. It does not copy business values or decide column meaning.',
545
554
  inputSchema: inputContract('docx_insert_table_columns'),
546
555
  outputSchema: fixedEditOutput('docx_insert_table_columns'),
547
- handler: args => fixedEdit('docx_insert_table_columns', args),
556
+ handler: (args, tool) => fixedEdit(tool, args, docxCandidates),
548
557
  },
549
558
  {
550
559
  name: 'docx_delete_table_columns',
@@ -552,7 +561,7 @@ const tools = [
552
561
  description: 'Delete selected observed grid columns from one current main-document table while shrinking spanning cells and preserving the remaining table grid. It cannot remove every column and does not decide whether a business column is unused.',
553
562
  inputSchema: inputContract('docx_delete_table_columns'),
554
563
  outputSchema: fixedEditOutput('docx_delete_table_columns'),
555
- handler: args => fixedEdit('docx_delete_table_columns', args),
564
+ handler: (args, tool) => fixedEdit(tool, args, docxCandidates),
556
565
  },
557
566
  {
558
567
  name: 'docx_compare',
@@ -593,7 +602,7 @@ const tools = [
593
602
  description: 'Apply one explicit font family and size policy to current main-document body and table text. It does not derive a policy or alter other run semantics.',
594
603
  inputSchema: inputContract('docx_apply_font_policy'),
595
604
  outputSchema: fixedEditOutput('docx_apply_font_policy'),
596
- handler: args => fixedEdit('docx_apply_font_policy', args),
605
+ handler: (args, tool) => fixedEdit(tool, args, docxCandidates),
597
606
  },
598
607
  {
599
608
  name: 'docx_validate_toc_style_policy',
@@ -609,7 +618,7 @@ const tools = [
609
618
  description: 'Apply explicit italic and per-level indentation values to current built-in table-of-contents paragraph styles. It does not change heading text or refresh fields.',
610
619
  inputSchema: inputContract('docx_apply_toc_style_policy'),
611
620
  outputSchema: fixedEditOutput('docx_apply_toc_style_policy'),
612
- handler: args => fixedEdit('docx_apply_toc_style_policy', args),
621
+ handler: (args, tool) => fixedEdit(tool, args, docxCandidates),
613
622
  },
614
623
  {
615
624
  name: 'docx_refresh_fields',
@@ -676,7 +685,7 @@ const tools = [
676
685
  annotations: { readOnlyHint: true, idempotentHint: true },
677
686
  handler: xlsxReadRange,
678
687
  },
679
- ...fixedToolDefinitions(xlsxFixedTools),
688
+ ...fixedToolDefinitions(xlsxFixedTools, xlsxCandidates),
680
689
  {
681
690
  name: 'xlsx_validate',
682
691
  description: 'Validate an XLSX workbook package and produce OpenXML validation evidence. Set returnContent true to return the complete result when it fits the response limit. Provide output to write the complete result to a new JSON file. The two choices are independent and may be used together; at least one is required.',
@@ -709,7 +718,7 @@ const tools = [
709
718
  description: 'Apply one deterministic PPTX template-application plan to a current presentation. This tool executes the published plan; it does not select a template or derive business content, slide mappings, geometry, or formatting decisions.',
710
719
  inputSchema: inputContract('pptx_apply_template'),
711
720
  outputSchema: fixedEditOutput('pptx_apply_template'),
712
- handler: args => fixedEdit('pptx_apply_template', args),
721
+ handler: (args, tool) => fixedEdit(tool, args, pptxCandidates),
713
722
  },
714
723
  {
715
724
  name: 'pptx_apply_format',
@@ -717,7 +726,7 @@ const tools = [
717
726
  description: 'Apply one deterministic PPTX formatting plan to a current presentation. This tool executes published formatting operations; it does not derive values, coordinates, or business decisions.',
718
727
  inputSchema: inputContract('pptx_apply_format'),
719
728
  outputSchema: fixedEditOutput('pptx_apply_format'),
720
- handler: args => fixedEdit('pptx_apply_format', args),
729
+ handler: (args, tool) => fixedEdit(tool, args, pptxCandidates),
721
730
  },
722
731
  {
723
732
  name: 'pptx_set_shape_geometry',
@@ -725,7 +734,7 @@ const tools = [
725
734
  description: 'Set exact native EMU bounds for uniquely identified current-slide PPTX objects. One call batches only this fixed geometry action and does not infer repair coordinates.',
726
735
  inputSchema: inputContract('pptx_set_shape_geometry'),
727
736
  outputSchema: fixedEditOutput('pptx_set_shape_geometry'),
728
- handler: args => fixedEdit('pptx_set_shape_geometry', args),
737
+ handler: (args, tool) => fixedEdit(tool, args, pptxCandidates),
729
738
  },
730
739
  {
731
740
  name: 'pptx_replace_picture_image',
@@ -733,7 +742,7 @@ const tools = [
733
742
  description: 'Replace embedded PNG or JPEG media for uniquely identified current-slide PPTX pictures while preserving the picture object, geometry, crop, and unrelated media. One call batches only this fixed replacement action.',
734
743
  inputSchema: inputContract('pptx_replace_picture_image'),
735
744
  outputSchema: fixedEditOutput('pptx_replace_picture_image'),
736
- handler: args => fixedEdit('pptx_replace_picture_image', args),
745
+ handler: (args, tool) => fixedEdit(tool, args, pptxCandidates),
737
746
  },
738
747
  {
739
748
  name: 'pptx_validate',
@@ -769,8 +778,8 @@ function buildServer() {
769
778
  },
770
779
  async args => {
771
780
  const payload = typeof args.output === 'string'
772
- ? await withOutputWriteLock(args.output, () => tool.handler(args))
773
- : await tool.handler(args);
781
+ ? await withOutputWriteLock(args.output, () => tool.handler(args, tool))
782
+ : await tool.handler(args, tool);
774
783
  return createToolResult(payload, { isError: payload?.summary?.pass === false });
775
784
  },
776
785
  );
@@ -1165,28 +1174,37 @@ async function copyTransform(tool, candidates, command, args, suffix = []) {
1165
1174
  }
1166
1175
  }
1167
1176
 
1168
- async function fixedEdit(tool, args) {
1169
- const input = path.resolve(requireString(args.input, 'input'));
1170
- const output = path.resolve(requireString(args.output, 'output'));
1177
+ async function fixedEdit(tool, args, candidates) {
1178
+ const publishedContract = {
1179
+ name: tool.name,
1180
+ inputSchema: inputContractSchema(tool.name),
1181
+ annotations: tool.annotations,
1182
+ _meta: effectKindMetadata(tool.effectKind),
1183
+ };
1184
+ const bindings = documentMutationFileArguments(publishedContract, args);
1185
+ const input = path.resolve(bindings.current);
1186
+ const output = path.resolve(bindings.effectiveOutput);
1171
1187
  const receiptOutput = path.resolve(requireString(args.receiptOutput, 'receiptOutput'));
1172
- if (!(tool.startsWith('docx_') && input === output)) await requireNewFile(output, 'output');
1188
+ if (input !== output) await requireNewFile(output, 'output');
1173
1189
  await requireNewFile(receiptOutput, 'receiptOutput');
1174
- const candidates = tool.startsWith('docx_') ? docxCandidates
1175
- : tool.startsWith('xlsx_') ? xlsxCandidates
1176
- : pptxCandidates;
1177
1190
  return withTempJsonFile(args, async requestPath => {
1178
- const result = await runJsonCandidateChain(candidates, [tool, requestPath], { allowedExitCodes: [0, 1] });
1191
+ const result = await runJsonCandidateChain(candidates, [tool.name, requestPath], { allowedExitCodes: [0, 1] });
1179
1192
  if (result.code !== 0) {
1180
- const detail = result.stderr.trim() || result.stdout.trim() || `${tool} failed with exit code ${result.code}`;
1193
+ const detail = result.stderr.trim() || result.stdout.trim()
1194
+ || `${tool.name} failed with exit code ${result.code}`;
1181
1195
  throw new Error(detail);
1182
1196
  }
1183
- if (result.json?.tool !== tool) throw new Error(`${tool} returned a mismatched tool identity`);
1197
+ if (result.json?.tool !== tool.name) throw new Error(`${tool.name} returned a mismatched tool identity`);
1184
1198
  await requireReturnedArtifact(result.json.receipt, receiptOutput, 'receipt');
1185
1199
  if (result.json.output === null) {
1186
- if (result.json.summary?.pass !== false) throw new Error(`${tool} omitted output without reporting failure`);
1200
+ if (result.json.summary?.pass !== false) {
1201
+ throw new Error(`${tool.name} omitted output without reporting failure`);
1202
+ }
1187
1203
  } else {
1188
1204
  await requireReturnedArtifact(result.json.output, output, 'output');
1189
- if (result.json.summary?.pass !== true) throw new Error(`${tool} returned output without reporting success`);
1205
+ if (result.json.summary?.pass !== true) {
1206
+ throw new Error(`${tool.name} returned output without reporting success`);
1207
+ }
1190
1208
  }
1191
1209
  return { ...result.json, runtime: commandRuntime(result) };
1192
1210
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiwater/office-mcp",
3
- "version": "0.21.34",
3
+ "version": "0.21.35",
4
4
  "description": "Published MCP distribution for independent Tiwater Office, PDF, and Text document capabilities",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -2,7 +2,7 @@
2
2
  "schema": "tiwater.pdf-provider-contract-manifest/v1",
3
3
  "provider": {
4
4
  "id": "@tiwater/office-mcp",
5
- "version": "0.21.34"
5
+ "version": "0.21.35"
6
6
  },
7
7
  "runtime": {
8
8
  "command": "tiwater-pdf"
@@ -2,7 +2,7 @@
2
2
  "schema": "tiwater.text-provider-contract-manifest/v1",
3
3
  "provider": {
4
4
  "id": "@tiwater/office-mcp",
5
- "version": "0.21.34"
5
+ "version": "0.21.35"
6
6
  },
7
7
  "tools": [
8
8
  {