@tiwater/office-mcp 0.7.0 → 0.8.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',
@@ -136,6 +141,42 @@ const artifact = z.object({
136
141
  bytes: z.number().int().nonnegative(),
137
142
  }).strict();
138
143
 
144
+ const renderFileIdentity = z.object({
145
+ sha256: z.string().regex(/^[a-f0-9]{64}$/),
146
+ size_bytes: z.number().int().positive(),
147
+ }).strict();
148
+ const nativeRenderReceipt = z.object({
149
+ status: z.literal('ok'),
150
+ input: z.string(),
151
+ input_sha256: z.string().regex(/^[a-f0-9]{64}$/),
152
+ output: z.string(),
153
+ output_sha256: z.string().regex(/^[a-f0-9]{64}$/),
154
+ source_format: z.enum(['doc', 'docx', 'odt', 'rtf', 'xls', 'xlsx', 'ods', 'ppt', 'pptx', 'odp']),
155
+ target_format: z.literal('pdf'),
156
+ version: z.string(),
157
+ backend: z.enum(['wps', 'et', 'wpp']),
158
+ fallback_reason: z.null(),
159
+ page_count: z.number().int().positive(),
160
+ native_render_provenance: z.object({
161
+ schema: z.literal('tiwater.convert-native-render-provenance/v1'),
162
+ backend: z.enum(['wps', 'et', 'wpp']),
163
+ input: renderFileIdentity,
164
+ output: renderFileIdentity,
165
+ page_count: z.number().int().positive(),
166
+ }).passthrough(),
167
+ }).passthrough();
168
+ const nativeRenderOutput = z.object({
169
+ tool: z.literal('office_render_pdf'),
170
+ runtime: runtimeIdentity,
171
+ pdf: artifact,
172
+ receipt: artifact,
173
+ summary: z.object({
174
+ sourceFormat: z.string(),
175
+ backend: z.enum(['wps', 'et', 'wpp']),
176
+ pageCount: z.number().int().positive(),
177
+ }).strict(),
178
+ }).strict();
179
+
139
180
  const migrationCatalogOutput = z.object({
140
181
  tool: z.literal('docx_list_migration_choices'),
141
182
  runtime: runtimeIdentity,
@@ -291,13 +332,6 @@ const tools = [
291
332
  annotations: { readOnlyHint: true, idempotentHint: true },
292
333
  handler: docxCompare,
293
334
  },
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
335
  {
302
336
  name: 'docx_export_json',
303
337
  description: 'Export DOCX body content to a new JSON artifact without returning the full document through MCP.',
@@ -305,6 +339,17 @@ const tools = [
305
339
  outputSchema: artifactOutput('docx_export_json'),
306
340
  handler: docxExportJson,
307
341
  },
342
+ {
343
+ name: 'office_render_pdf',
344
+ 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.',
345
+ inputSchema: z.object({
346
+ input: pathInput.describe('Path to the current Office document.'),
347
+ output: pathInput.describe('New PDF output path. Existing files are never overwritten.'),
348
+ receiptOutput: pathInput.describe('New JSON receipt path. Existing files are never overwritten.'),
349
+ }).strict(),
350
+ outputSchema: nativeRenderOutput,
351
+ handler: officeRenderPdf,
352
+ },
308
353
  {
309
354
  name: 'xlsx_inspect',
310
355
  description: 'Inspect an XLSX workbook and write one JSON observation containing workbook structure, exported values, formulas, styles, merged ranges, and conversion evidence.',
@@ -658,13 +703,6 @@ async function docxCompare(args) {
658
703
  return { tool: 'docx_compare', runtime: commandRuntime(result), report: result.json };
659
704
  }
660
705
 
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
706
  async function docxExportJson(args) {
669
707
  const input = requireString(args.input, 'input');
670
708
  const result = await runJsonCandidateChain(docxCandidates, ['export-json', input]);
@@ -675,6 +713,64 @@ async function docxExportJson(args) {
675
713
  };
676
714
  }
677
715
 
716
+ async function officeRenderPdf(args) {
717
+ const input = path.resolve(requireString(args.input, 'input'));
718
+ const output = path.resolve(requireString(args.output, 'output'));
719
+ const receiptOutput = path.resolve(requireString(args.receiptOutput, 'receiptOutput'));
720
+ const sourceFormat = path.extname(input).slice(1).toLowerCase();
721
+ const backend = nativeRenderBackend(sourceFormat);
722
+ if (path.extname(output).toLowerCase() !== '.pdf') {
723
+ throw Object.assign(new Error(`Office render output must be a PDF: ${output}`), { code: -32602 });
724
+ }
725
+ const inputArtifact = await fileArtifact(input);
726
+ await requireNewFile(output, 'output');
727
+ await requireNewFile(receiptOutput, 'receiptOutput');
728
+ await mkdir(path.dirname(output), { recursive: true });
729
+ try {
730
+ const result = await runJsonCandidateChain(
731
+ convertCandidates,
732
+ [`${sourceFormat}-to-pdf`, input, output],
733
+ { env: { TIWATER_OFFICE_PDF_BACKEND: backend } });
734
+ const receipt = nativeRenderReceipt.parse(result.json);
735
+ const pdf = await fileArtifact(output);
736
+ if (path.resolve(receipt.input) !== input
737
+ || path.resolve(receipt.output) !== output
738
+ || receipt.source_format !== sourceFormat
739
+ || receipt.backend !== backend
740
+ || receipt.native_render_provenance.backend !== backend
741
+ || receipt.page_count !== receipt.native_render_provenance.page_count
742
+ || receipt.input_sha256 !== inputArtifact.sha256
743
+ || receipt.native_render_provenance.input.sha256 !== inputArtifact.sha256
744
+ || receipt.native_render_provenance.input.size_bytes !== inputArtifact.bytes
745
+ || receipt.output_sha256 !== pdf.sha256
746
+ || receipt.native_render_provenance.output.sha256 !== pdf.sha256
747
+ || receipt.native_render_provenance.output.size_bytes !== pdf.bytes) {
748
+ throw new Error('Native Office render receipt is not bound to the current input and output');
749
+ }
750
+ return {
751
+ tool: 'office_render_pdf',
752
+ runtime: commandRuntime(result),
753
+ pdf,
754
+ receipt: await writeJsonArtifact(receiptOutput, receipt),
755
+ summary: {
756
+ sourceFormat,
757
+ backend,
758
+ pageCount: receipt.page_count,
759
+ },
760
+ };
761
+ } catch (error) {
762
+ await rm(output, { force: true });
763
+ throw error;
764
+ }
765
+ }
766
+
767
+ function nativeRenderBackend(sourceFormat) {
768
+ if (['doc', 'docx', 'odt', 'rtf'].includes(sourceFormat)) return 'wps';
769
+ if (['xls', 'xlsx', 'ods'].includes(sourceFormat)) return 'et';
770
+ if (['ppt', 'pptx', 'odp'].includes(sourceFormat)) return 'wpp';
771
+ throw Object.assign(new Error(`Unsupported Office render input: .${sourceFormat || '(none)'}`), { code: -32602 });
772
+ }
773
+
678
774
  async function xlsxInspect(args) {
679
775
  const input = requireString(args.input, 'input');
680
776
  const result = await runJsonCandidateChain(xlsxCandidates, ['inspect', input, '--json']);
@@ -737,6 +833,29 @@ async function writeJsonArtifact(output, payload) {
737
833
  };
738
834
  }
739
835
 
836
+ async function fileArtifact(filePath) {
837
+ const hash = createHash('sha256');
838
+ for await (const chunk of createReadStream(filePath)) {
839
+ hash.update(chunk);
840
+ }
841
+ const file = await stat(filePath);
842
+ return {
843
+ path: path.resolve(filePath),
844
+ sha256: hash.digest('hex'),
845
+ bytes: file.size,
846
+ };
847
+ }
848
+
849
+ async function requireNewFile(filePath, label) {
850
+ try {
851
+ await stat(filePath);
852
+ } catch (error) {
853
+ if (error?.code === 'ENOENT') return;
854
+ throw error;
855
+ }
856
+ throw Object.assign(new Error(`${label} already exists: ${filePath}`), { code: -32602 });
857
+ }
858
+
740
859
  function commandRuntime(result) {
741
860
  return {
742
861
  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.8.0",
4
4
  "description": "Published MCP server for Tiwater Office document capabilities",
5
5
  "type": "module",
6
6
  "license": "MIT",