@tiwater/office-mcp 0.7.0 → 0.9.0

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/office/README.md CHANGED
@@ -10,8 +10,8 @@ Shared stdio MCP server for Office document workflows.
10
10
  - `docx_migrate_template`
11
11
  - `docx_verify_migration`
12
12
  - `docx_compare`
13
- - `docx_validate_template_transform`
14
13
  - `docx_export_json`
14
+ - `office_render_pdf`
15
15
  - `xlsx_inspect`
16
16
  - `xlsx_export_json`
17
17
  - `xlsx_validate`
@@ -23,9 +23,9 @@ Shared stdio MCP server for Office document workflows.
23
23
  Install `@tiwater/office-mcp` together with the runtime versions required by
24
24
  the consumer, then run `tiwater-office-mcp` as a stdio MCP server.
25
25
 
26
- The server invokes published `tiwater-docx`, `tiwater-xlsx`, and
27
- `tiwater-pptx` commands from `PATH`. It does not require a source checkout or
28
- fall back to local projects.
26
+ The server invokes published `tiwater-docx`, `tiwater-xlsx`,
27
+ `tiwater-pptx`, and `tiwater-convert` commands from `PATH`. It does not require
28
+ a source checkout or fall back to local projects.
29
29
 
30
30
  The official MCP SDK derives the schemas advertised to clients and validates
31
31
  tool arguments and structured results before they cross the protocol boundary.
package/office/index.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { createHash } from 'node:crypto';
3
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { createReadStream } from 'node:fs';
4
+ import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
4
5
  import path from 'node:path';
5
6
  import { spawn } from 'node:child_process';
6
7
  import { isDeepStrictEqual } from 'node:util';
@@ -30,6 +31,10 @@ const pptxCandidates = [
30
31
  commandCandidate('tiwater-pptx', [], { cwd: invocationCwd }),
31
32
  ];
32
33
 
34
+ const convertCandidates = [
35
+ commandCandidate('tiwater-convert', [], { cwd: invocationCwd }),
36
+ ];
37
+
33
38
  const pathInput = z.string().trim().min(1);
34
39
  const migrationAction = z.enum([
35
40
  'place-content',
@@ -51,7 +56,6 @@ const migrationChoiceInput = z.object({
51
56
  sourceChoiceId: z.string().trim().min(1),
52
57
  action: migrationAction,
53
58
  targetChoiceId: z.string().trim().min(1).optional(),
54
- cardinality: z.enum(['one', 'all']).optional(),
55
59
  }).strict().superRefine((choice, context) => {
56
60
  if (targetActions.has(choice.action) && !choice.targetChoiceId) {
57
61
  context.addIssue({ code: 'custom', path: ['targetChoiceId'], message: `${choice.action} requires targetChoiceId` });
@@ -59,9 +63,6 @@ const migrationChoiceInput = z.object({
59
63
  if (terminalActions.has(choice.action) && choice.targetChoiceId) {
60
64
  context.addIssue({ code: 'custom', path: ['targetChoiceId'], message: `${choice.action} forbids targetChoiceId` });
61
65
  }
62
- if (choice.cardinality === 'all' && !terminalActions.has(choice.action)) {
63
- context.addIssue({ code: 'custom', path: ['cardinality'], message: 'cardinality all is limited to terminal actions' });
64
- }
65
66
  });
66
67
 
67
68
  const templateCleanupInput = z.object({
@@ -136,6 +137,42 @@ const artifact = z.object({
136
137
  bytes: z.number().int().nonnegative(),
137
138
  }).strict();
138
139
 
140
+ const renderFileIdentity = z.object({
141
+ sha256: z.string().regex(/^[a-f0-9]{64}$/),
142
+ size_bytes: z.number().int().positive(),
143
+ }).strict();
144
+ const nativeRenderReceipt = z.object({
145
+ status: z.literal('ok'),
146
+ input: z.string(),
147
+ input_sha256: z.string().regex(/^[a-f0-9]{64}$/),
148
+ output: z.string(),
149
+ output_sha256: z.string().regex(/^[a-f0-9]{64}$/),
150
+ source_format: z.enum(['doc', 'docx', 'odt', 'rtf', 'xls', 'xlsx', 'ods', 'ppt', 'pptx', 'odp']),
151
+ target_format: z.literal('pdf'),
152
+ version: z.string(),
153
+ backend: z.enum(['wps', 'et', 'wpp']),
154
+ fallback_reason: z.null(),
155
+ page_count: z.number().int().positive(),
156
+ native_render_provenance: z.object({
157
+ schema: z.literal('tiwater.convert-native-render-provenance/v1'),
158
+ backend: z.enum(['wps', 'et', 'wpp']),
159
+ input: renderFileIdentity,
160
+ output: renderFileIdentity,
161
+ page_count: z.number().int().positive(),
162
+ }).passthrough(),
163
+ }).passthrough();
164
+ const nativeRenderOutput = z.object({
165
+ tool: z.literal('office_render_pdf'),
166
+ runtime: runtimeIdentity,
167
+ pdf: artifact,
168
+ receipt: artifact,
169
+ summary: z.object({
170
+ sourceFormat: z.string(),
171
+ backend: z.enum(['wps', 'et', 'wpp']),
172
+ pageCount: z.number().int().positive(),
173
+ }).strict(),
174
+ }).strict();
175
+
139
176
  const migrationCatalogOutput = z.object({
140
177
  tool: z.literal('docx_list_migration_choices'),
141
178
  runtime: runtimeIdentity,
@@ -291,13 +328,6 @@ const tools = [
291
328
  annotations: { readOnlyHint: true, idempotentHint: true },
292
329
  handler: docxCompare,
293
330
  },
294
- {
295
- name: 'docx_validate_template_transform',
296
- description: 'Validate whether a source DOCX template and target DOCX template are structurally compatible.',
297
- inputSchema: z.object({ sourceTemplate: pathInput, targetTemplate: pathInput }).strict(),
298
- annotations: { readOnlyHint: true, idempotentHint: true },
299
- handler: docxValidateTemplateTransform,
300
- },
301
331
  {
302
332
  name: 'docx_export_json',
303
333
  description: 'Export DOCX body content to a new JSON artifact without returning the full document through MCP.',
@@ -305,6 +335,17 @@ const tools = [
305
335
  outputSchema: artifactOutput('docx_export_json'),
306
336
  handler: docxExportJson,
307
337
  },
338
+ {
339
+ name: 'office_render_pdf',
340
+ description: 'Render a current Office document to PDF with its required native WPS backend and write the complete provider receipt as evidence. The input extension selects Writer, Spreadsheets, or Presentation; fallback rendering is rejected.',
341
+ inputSchema: z.object({
342
+ input: pathInput.describe('Path to the current Office document.'),
343
+ output: pathInput.describe('New PDF output path. Existing files are never overwritten.'),
344
+ receiptOutput: pathInput.describe('New JSON receipt path. Existing files are never overwritten.'),
345
+ }).strict(),
346
+ outputSchema: nativeRenderOutput,
347
+ handler: officeRenderPdf,
348
+ },
308
349
  {
309
350
  name: 'xlsx_inspect',
310
351
  description: 'Inspect an XLSX workbook and write one JSON observation containing workbook structure, exported values, formulas, styles, merged ranges, and conversion evidence.',
@@ -614,6 +655,32 @@ async function docxVerifyMigration(args) {
614
655
  return runTemplateMigrationCommand('docx_verify_migration', 'verify-template-migration', args);
615
656
  }
616
657
 
658
+ function invalidMigrationInput(message) {
659
+ return Object.assign(new Error(message), { code: -32602 });
660
+ }
661
+
662
+ function completeMigrationChoices(catalog, choices) {
663
+ const sources = new Map(catalog.sources.map(source => [source.id, source]));
664
+ const seen = new Set();
665
+ const completed = choices.map(choice => {
666
+ const source = sources.get(choice.sourceChoiceId);
667
+ if (!source) throw invalidMigrationInput(`unknown migration source id: ${choice.sourceChoiceId}`);
668
+ if (seen.has(choice.sourceChoiceId)) throw invalidMigrationInput(`duplicate migration source id: ${choice.sourceChoiceId}`);
669
+ seen.add(choice.sourceChoiceId);
670
+ if (!source.allowedActions.includes(choice.action)) {
671
+ throw invalidMigrationInput(`migration action ${choice.action} is not allowed for source id: ${choice.sourceChoiceId}`);
672
+ }
673
+ return source.requiredCardinality === 'all'
674
+ ? { ...choice, cardinality: 'all' }
675
+ : choice;
676
+ });
677
+ const missing = [...sources.keys()].filter(sourceChoiceId => !seen.has(sourceChoiceId));
678
+ if (missing.length > 0) {
679
+ throw invalidMigrationInput(`migration choices must cover every source id; missing ${missing.length}`);
680
+ }
681
+ return completed;
682
+ }
683
+
617
684
  async function runTemplateMigrationCommand(tool, command, args) {
618
685
  const source = requireString(args.source, 'source');
619
686
  const baseline = requireString(args.baseline, 'baseline');
@@ -621,9 +688,11 @@ async function runTemplateMigrationCommand(tool, command, args) {
621
688
  if (!Array.isArray(args.choices)) {
622
689
  throw Object.assign(new Error('choices must be an array'), { code: -32602 });
623
690
  }
691
+ const catalogResult = await runJsonCandidateChain(docxCandidates, ['list-template-migration-choices', source, baseline]);
692
+ const catalog = migrationCatalog.parse(catalogResult.json);
624
693
  const payload = {
625
694
  schema: 'tiwater.docx.template-migration-business-choices/v1',
626
- choices: args.choices,
695
+ choices: completeMigrationChoices(catalog, args.choices),
627
696
  ...(Array.isArray(args.templateCleanup) ? { templateCleanup: args.templateCleanup } : {}),
628
697
  };
629
698
  return withTempJsonFile(payload, async choicesPath => {
@@ -631,6 +700,10 @@ async function runTemplateMigrationCommand(tool, command, args) {
631
700
  docxCandidates,
632
701
  [command, source, baseline, choicesPath, output],
633
702
  { allowedExitCodes: [0, 1] });
703
+ if (result.json === null) {
704
+ const detail = result.stderr.trim() || result.stdout.trim() || 'no diagnostic output';
705
+ throw new Error(`${result.command} ${command} returned no JSON receipt (exit ${result.code}): ${detail}`);
706
+ }
634
707
  const receipt = migrationReceipt.parse(result.json);
635
708
  return {
636
709
  tool,
@@ -658,13 +731,6 @@ async function docxCompare(args) {
658
731
  return { tool: 'docx_compare', runtime: commandRuntime(result), report: result.json };
659
732
  }
660
733
 
661
- async function docxValidateTemplateTransform(args) {
662
- const sourceTemplate = requireString(args.sourceTemplate, 'sourceTemplate');
663
- const targetTemplate = requireString(args.targetTemplate, 'targetTemplate');
664
- const result = await runJsonCandidateChain(docxCandidates, ['validate-template-transform', sourceTemplate, targetTemplate, '--json']);
665
- return { tool: 'docx_validate_template_transform', runtime: commandRuntime(result), report: result.json };
666
- }
667
-
668
734
  async function docxExportJson(args) {
669
735
  const input = requireString(args.input, 'input');
670
736
  const result = await runJsonCandidateChain(docxCandidates, ['export-json', input]);
@@ -675,6 +741,64 @@ async function docxExportJson(args) {
675
741
  };
676
742
  }
677
743
 
744
+ async function officeRenderPdf(args) {
745
+ const input = path.resolve(requireString(args.input, 'input'));
746
+ const output = path.resolve(requireString(args.output, 'output'));
747
+ const receiptOutput = path.resolve(requireString(args.receiptOutput, 'receiptOutput'));
748
+ const sourceFormat = path.extname(input).slice(1).toLowerCase();
749
+ const backend = nativeRenderBackend(sourceFormat);
750
+ if (path.extname(output).toLowerCase() !== '.pdf') {
751
+ throw Object.assign(new Error(`Office render output must be a PDF: ${output}`), { code: -32602 });
752
+ }
753
+ const inputArtifact = await fileArtifact(input);
754
+ await requireNewFile(output, 'output');
755
+ await requireNewFile(receiptOutput, 'receiptOutput');
756
+ await mkdir(path.dirname(output), { recursive: true });
757
+ try {
758
+ const result = await runJsonCandidateChain(
759
+ convertCandidates,
760
+ [`${sourceFormat}-to-pdf`, input, output],
761
+ { env: { TIWATER_OFFICE_PDF_BACKEND: backend } });
762
+ const receipt = nativeRenderReceipt.parse(result.json);
763
+ const pdf = await fileArtifact(output);
764
+ if (path.resolve(receipt.input) !== input
765
+ || path.resolve(receipt.output) !== output
766
+ || receipt.source_format !== sourceFormat
767
+ || receipt.backend !== backend
768
+ || receipt.native_render_provenance.backend !== backend
769
+ || receipt.page_count !== receipt.native_render_provenance.page_count
770
+ || receipt.input_sha256 !== inputArtifact.sha256
771
+ || receipt.native_render_provenance.input.sha256 !== inputArtifact.sha256
772
+ || receipt.native_render_provenance.input.size_bytes !== inputArtifact.bytes
773
+ || receipt.output_sha256 !== pdf.sha256
774
+ || receipt.native_render_provenance.output.sha256 !== pdf.sha256
775
+ || receipt.native_render_provenance.output.size_bytes !== pdf.bytes) {
776
+ throw new Error('Native Office render receipt is not bound to the current input and output');
777
+ }
778
+ return {
779
+ tool: 'office_render_pdf',
780
+ runtime: commandRuntime(result),
781
+ pdf,
782
+ receipt: await writeJsonArtifact(receiptOutput, receipt),
783
+ summary: {
784
+ sourceFormat,
785
+ backend,
786
+ pageCount: receipt.page_count,
787
+ },
788
+ };
789
+ } catch (error) {
790
+ await rm(output, { force: true });
791
+ throw error;
792
+ }
793
+ }
794
+
795
+ function nativeRenderBackend(sourceFormat) {
796
+ if (['doc', 'docx', 'odt', 'rtf'].includes(sourceFormat)) return 'wps';
797
+ if (['xls', 'xlsx', 'ods'].includes(sourceFormat)) return 'et';
798
+ if (['ppt', 'pptx', 'odp'].includes(sourceFormat)) return 'wpp';
799
+ throw Object.assign(new Error(`Unsupported Office render input: .${sourceFormat || '(none)'}`), { code: -32602 });
800
+ }
801
+
678
802
  async function xlsxInspect(args) {
679
803
  const input = requireString(args.input, 'input');
680
804
  const result = await runJsonCandidateChain(xlsxCandidates, ['inspect', input, '--json']);
@@ -737,6 +861,29 @@ async function writeJsonArtifact(output, payload) {
737
861
  };
738
862
  }
739
863
 
864
+ async function fileArtifact(filePath) {
865
+ const hash = createHash('sha256');
866
+ for await (const chunk of createReadStream(filePath)) {
867
+ hash.update(chunk);
868
+ }
869
+ const file = await stat(filePath);
870
+ return {
871
+ path: path.resolve(filePath),
872
+ sha256: hash.digest('hex'),
873
+ bytes: file.size,
874
+ };
875
+ }
876
+
877
+ async function requireNewFile(filePath, label) {
878
+ try {
879
+ await stat(filePath);
880
+ } catch (error) {
881
+ if (error?.code === 'ENOENT') return;
882
+ throw error;
883
+ }
884
+ throw Object.assign(new Error(`${label} already exists: ${filePath}`), { code: -32602 });
885
+ }
886
+
740
887
  function commandRuntime(result) {
741
888
  return {
742
889
  command: result.command,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiwater/office-mcp",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Published MCP server for Tiwater Office document capabilities",
5
5
  "type": "module",
6
6
  "license": "MIT",