@tiwater/office-mcp 0.10.1 → 0.11.1
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 +12 -1
- package/office/index.mjs +96 -58
- package/package.json +1 -1
package/office/README.md
CHANGED
|
@@ -14,6 +14,7 @@ Shared stdio MCP server for Office document workflows.
|
|
|
14
14
|
- `office_render_pdf`
|
|
15
15
|
- `xlsx_inspect`
|
|
16
16
|
- `xlsx_export_json`
|
|
17
|
+
- `xlsx_apply`
|
|
17
18
|
- `xlsx_validate`
|
|
18
19
|
- `pptx_inspect`
|
|
19
20
|
- `pptx_export_json`
|
|
@@ -23,7 +24,7 @@ Shared stdio MCP server for Office document workflows.
|
|
|
23
24
|
Install `@tiwater/office-mcp` together with the runtime versions required by
|
|
24
25
|
the consumer, then run `tiwater-office-mcp` as a stdio MCP server.
|
|
25
26
|
|
|
26
|
-
Office MCP 0.
|
|
27
|
+
Office MCP 0.11 requires these minimum published runtimes on `PATH`:
|
|
27
28
|
|
|
28
29
|
| Command | Package | Minimum version |
|
|
29
30
|
| --- | --- | --- |
|
|
@@ -41,6 +42,16 @@ tool arguments and structured results before they cross the protocol boundary.
|
|
|
41
42
|
Large observations and exports are written to a caller-selected new JSON
|
|
42
43
|
artifact. MCP returns only the artifact path, hash, and byte count.
|
|
43
44
|
|
|
45
|
+
## Workbook editing
|
|
46
|
+
|
|
47
|
+
`xlsx_apply` executes one existing `tiwater.xlsx-edit/v1` artifact against a
|
|
48
|
+
current `.xlsx` workbook. The caller's deterministic builder owns all values,
|
|
49
|
+
coordinates, and operation selection. Office MCP binds the current input,
|
|
50
|
+
operations artifact, and created output by path and content hash and records the
|
|
51
|
+
complete runtime result in a new receipt artifact. It does not interpret
|
|
52
|
+
scenario knowledge or derive workbook edits. Callers independently inspect and
|
|
53
|
+
validate the resulting workbook before delivery.
|
|
54
|
+
|
|
44
55
|
## Template migration
|
|
45
56
|
|
|
46
57
|
Template migration separates business choice from document mechanics:
|
package/office/index.mjs
CHANGED
|
@@ -3,7 +3,6 @@ import { createHash } from 'node:crypto';
|
|
|
3
3
|
import { createReadStream } from 'node:fs';
|
|
4
4
|
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import { spawn } from 'node:child_process';
|
|
7
6
|
import { isDeepStrictEqual } from 'node:util';
|
|
8
7
|
import { McpServer } from '@modelcontextprotocol/server';
|
|
9
8
|
import { serveStdio } from '@modelcontextprotocol/server/stdio';
|
|
@@ -138,6 +137,39 @@ const artifact = z.object({
|
|
|
138
137
|
bytes: z.number().int().nonnegative(),
|
|
139
138
|
}).strict();
|
|
140
139
|
|
|
140
|
+
const xlsxAppliedOperation = z.object({
|
|
141
|
+
type: z.string().min(1),
|
|
142
|
+
applied: z.boolean(),
|
|
143
|
+
detail: z.string(),
|
|
144
|
+
sheet: z.string().nullable().optional(),
|
|
145
|
+
changedRange: z.string().nullable().optional(),
|
|
146
|
+
warnings: z.array(z.string()).nullable().optional(),
|
|
147
|
+
}).strict();
|
|
148
|
+
const xlsxEditResult = z.object({
|
|
149
|
+
input: z.string().min(1),
|
|
150
|
+
output: z.string().min(1),
|
|
151
|
+
appliedOperations: z.array(xlsxAppliedOperation),
|
|
152
|
+
}).strict();
|
|
153
|
+
const xlsxApplyReceipt = z.object({
|
|
154
|
+
schema: z.literal('tiwater.office.xlsx-apply-receipt/v1'),
|
|
155
|
+
pass: z.boolean(),
|
|
156
|
+
input: artifact,
|
|
157
|
+
operations: artifact,
|
|
158
|
+
output: artifact.nullable(),
|
|
159
|
+
appliedOperations: z.array(xlsxAppliedOperation),
|
|
160
|
+
}).strict();
|
|
161
|
+
const xlsxApplyOutput = z.object({
|
|
162
|
+
tool: z.literal('xlsx_apply'),
|
|
163
|
+
runtime: runtimeIdentity,
|
|
164
|
+
receipt: artifact,
|
|
165
|
+
output: artifact.nullable(),
|
|
166
|
+
summary: z.object({
|
|
167
|
+
pass: z.boolean(),
|
|
168
|
+
operationCount: z.number().int().nonnegative(),
|
|
169
|
+
appliedCount: z.number().int().nonnegative(),
|
|
170
|
+
}).strict(),
|
|
171
|
+
}).strict();
|
|
172
|
+
|
|
141
173
|
const renderFileIdentity = z.object({
|
|
142
174
|
sha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
143
175
|
size_bytes: z.number().int().positive(),
|
|
@@ -358,6 +390,18 @@ const tools = [
|
|
|
358
390
|
outputSchema: artifactOutput('xlsx_export_json'),
|
|
359
391
|
handler: xlsxExportJson,
|
|
360
392
|
},
|
|
393
|
+
{
|
|
394
|
+
name: 'xlsx_apply',
|
|
395
|
+
description: 'Apply one deterministic XLSX operations artifact to a current workbook. This tool executes published workbook edits; it does not derive values, coordinates, or business decisions.',
|
|
396
|
+
inputSchema: z.object({
|
|
397
|
+
input: pathInput.describe('Path to the current XLSX workbook.'),
|
|
398
|
+
operations: pathInput.describe('Path to the deterministic tiwater.xlsx-edit/v1 operations artifact.'),
|
|
399
|
+
output: pathInput.describe('New XLSX output path. Existing files are never overwritten.'),
|
|
400
|
+
receiptOutput: pathInput.describe('New JSON receipt path. Existing files are never overwritten.'),
|
|
401
|
+
}).strict(),
|
|
402
|
+
outputSchema: xlsxApplyOutput,
|
|
403
|
+
handler: xlsxApply,
|
|
404
|
+
},
|
|
361
405
|
{
|
|
362
406
|
name: 'xlsx_validate',
|
|
363
407
|
description: 'Validate an XLSX workbook package and return Open XML validation evidence.',
|
|
@@ -961,9 +1005,59 @@ async function xlsxExportJson(args) {
|
|
|
961
1005
|
};
|
|
962
1006
|
}
|
|
963
1007
|
|
|
1008
|
+
async function xlsxApply(args) {
|
|
1009
|
+
const input = path.resolve(requireString(args.input, 'input'));
|
|
1010
|
+
const operations = path.resolve(requireString(args.operations, 'operations'));
|
|
1011
|
+
const output = path.resolve(requireString(args.output, 'output'));
|
|
1012
|
+
const receiptOutput = path.resolve(requireString(args.receiptOutput, 'receiptOutput'));
|
|
1013
|
+
if (path.extname(input).toLowerCase() !== '.xlsx' || path.extname(output).toLowerCase() !== '.xlsx') {
|
|
1014
|
+
throw Object.assign(new Error('XLSX apply input and output must use the .xlsx extension'), { code: -32602 });
|
|
1015
|
+
}
|
|
1016
|
+
await requireNewFile(output, 'output');
|
|
1017
|
+
await requireNewFile(receiptOutput, 'receiptOutput');
|
|
1018
|
+
const inputArtifact = await fileArtifact(input);
|
|
1019
|
+
const operationsArtifact = await fileArtifact(operations);
|
|
1020
|
+
await mkdir(path.dirname(output), { recursive: true });
|
|
1021
|
+
try {
|
|
1022
|
+
const result = await runJsonCandidateChain(
|
|
1023
|
+
xlsxCandidates,
|
|
1024
|
+
['edit', input, operations, output],
|
|
1025
|
+
{ allowedExitCodes: [0, 1] });
|
|
1026
|
+
const edit = xlsxEditResult.parse(result.json);
|
|
1027
|
+
if (path.resolve(edit.input) !== input || path.resolve(edit.output) !== output) {
|
|
1028
|
+
throw new Error('XLSX edit receipt is not bound to the current input and output');
|
|
1029
|
+
}
|
|
1030
|
+
const pass = edit.appliedOperations.every(operation => operation.applied);
|
|
1031
|
+
const outputArtifact = pass ? await fileArtifact(output) : null;
|
|
1032
|
+
if (!pass) await rm(output, { force: true });
|
|
1033
|
+
const receipt = xlsxApplyReceipt.parse({
|
|
1034
|
+
schema: 'tiwater.office.xlsx-apply-receipt/v1',
|
|
1035
|
+
pass,
|
|
1036
|
+
input: inputArtifact,
|
|
1037
|
+
operations: operationsArtifact,
|
|
1038
|
+
output: outputArtifact,
|
|
1039
|
+
appliedOperations: edit.appliedOperations,
|
|
1040
|
+
});
|
|
1041
|
+
return {
|
|
1042
|
+
tool: 'xlsx_apply',
|
|
1043
|
+
runtime: commandRuntime(result),
|
|
1044
|
+
receipt: await writeJsonArtifact(receiptOutput, receipt),
|
|
1045
|
+
output: outputArtifact,
|
|
1046
|
+
summary: {
|
|
1047
|
+
pass,
|
|
1048
|
+
operationCount: edit.appliedOperations.length,
|
|
1049
|
+
appliedCount: edit.appliedOperations.filter(operation => operation.applied).length,
|
|
1050
|
+
},
|
|
1051
|
+
};
|
|
1052
|
+
} catch (error) {
|
|
1053
|
+
await rm(output, { force: true });
|
|
1054
|
+
throw error;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
964
1058
|
async function xlsxValidate(args) {
|
|
965
1059
|
const input = requireString(args.input, 'input');
|
|
966
|
-
const result = await
|
|
1060
|
+
const result = await runJsonCandidateChain(xlsxCandidates, ['validate', input], { allowedExitCodes: [0, 1] });
|
|
967
1061
|
return { tool: 'xlsx_validate', runtime: commandRuntime(result), result: result.json };
|
|
968
1062
|
}
|
|
969
1063
|
|
|
@@ -1030,59 +1124,3 @@ function commandRuntime(result) {
|
|
|
1030
1124
|
}
|
|
1031
1125
|
|
|
1032
1126
|
serveStdio(buildServer);
|
|
1033
|
-
|
|
1034
|
-
async function runXlsxValidateCandidateChain(args) {
|
|
1035
|
-
const errors = [];
|
|
1036
|
-
for (const candidate of xlsxCandidates) {
|
|
1037
|
-
try {
|
|
1038
|
-
const result = await runValidationCommand(candidate, args);
|
|
1039
|
-
const text = result.stdout.trim();
|
|
1040
|
-
if (!text) return { ...result, json: null };
|
|
1041
|
-
try {
|
|
1042
|
-
return { ...result, json: JSON.parse(text) };
|
|
1043
|
-
} catch {
|
|
1044
|
-
if (result.code !== 0) {
|
|
1045
|
-
errors.push(`${candidate.command}: validate did not return JSON`);
|
|
1046
|
-
continue;
|
|
1047
|
-
}
|
|
1048
|
-
throw new Error(`Expected JSON output but received: ${text.slice(0, 300)}${text.length > 300 ? '…' : ''}`);
|
|
1049
|
-
}
|
|
1050
|
-
} catch (error) {
|
|
1051
|
-
if (error?.code === 'ENOENT') {
|
|
1052
|
-
errors.push(`${candidate.command}: not found`);
|
|
1053
|
-
continue;
|
|
1054
|
-
}
|
|
1055
|
-
throw error;
|
|
1056
|
-
}
|
|
1057
|
-
}
|
|
1058
|
-
throw new Error(`No runnable command candidate succeeded. ${errors.join('; ')}`);
|
|
1059
|
-
}
|
|
1060
|
-
|
|
1061
|
-
async function runValidationCommand(candidate, args) {
|
|
1062
|
-
const env = { ...process.env, ...(candidate.env || {}) };
|
|
1063
|
-
const cwd = candidate.cwd || process.cwd();
|
|
1064
|
-
const commandArgs = [...(candidate.argsPrefix || []), ...args];
|
|
1065
|
-
|
|
1066
|
-
return await new Promise((resolve, reject) => {
|
|
1067
|
-
const child = spawn(candidate.command, commandArgs, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
1068
|
-
let stdout = '';
|
|
1069
|
-
let stderr = '';
|
|
1070
|
-
|
|
1071
|
-
child.stdout.on('data', chunk => {
|
|
1072
|
-
stdout += chunk.toString();
|
|
1073
|
-
});
|
|
1074
|
-
|
|
1075
|
-
child.stderr.on('data', chunk => {
|
|
1076
|
-
stderr += chunk.toString();
|
|
1077
|
-
});
|
|
1078
|
-
|
|
1079
|
-
child.on('error', reject);
|
|
1080
|
-
child.on('close', code => {
|
|
1081
|
-
if (code === 0 || code === 1) {
|
|
1082
|
-
resolve({ code, stdout, stderr, command: candidate.command, args: commandArgs, cwd });
|
|
1083
|
-
return;
|
|
1084
|
-
}
|
|
1085
|
-
reject(new Error(`${candidate.command} ${commandArgs.join(' ')} failed with exit code ${code}\n${stderr || stdout}`));
|
|
1086
|
-
});
|
|
1087
|
-
});
|
|
1088
|
-
}
|