@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 +4 -4
- package/office/index.mjs +167 -20
- package/package.json +1 -1
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`,
|
|
27
|
-
`tiwater-pptx` commands from `PATH`. It does not require
|
|
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 {
|
|
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,
|