@tiwater/office-mcp 0.2.0 → 0.3.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.
@@ -99,7 +99,7 @@ async function runCommand(candidate, args, options) {
99
99
  child.on('close', code => {
100
100
  const allowedExitCodes = options.allowedExitCodes ?? [0];
101
101
  if (allowedExitCodes.includes(code)) {
102
- resolve({ code, stdout, stderr, command: candidate.command, args: commandArgs });
102
+ resolve({ code, stdout, stderr, command: candidate.command, args: commandArgs, cwd });
103
103
  return;
104
104
  }
105
105
  reject(new Error(`${candidate.command} ${commandArgs.join(' ')} failed with exit code ${code}\n${stderr || stdout}`));
package/office/README.md CHANGED
@@ -15,7 +15,6 @@ Shared stdio MCP server for Office document workflows.
15
15
  - `xlsx_export_json`
16
16
  - `xlsx_validate`
17
17
  - `pptx_inspect`
18
- - `pptx_inspect_detail`
19
18
  - `pptx_export_json`
20
19
 
21
20
  ## Run
@@ -26,3 +25,8 @@ the consumer, then run `tiwater-office-mcp` as a stdio MCP server.
26
25
  The server invokes published `tiwater-docx`, `tiwater-xlsx`, and
27
26
  `tiwater-pptx` commands from `PATH`. It does not require a source checkout or
28
27
  fall back to local projects.
28
+
29
+ The official MCP SDK derives the schemas advertised to clients and validates
30
+ tool arguments and structured results before they cross the protocol boundary.
31
+ Large observations and exports are written to a caller-selected new JSON
32
+ artifact. MCP returns only the artifact path, hash, and byte count.
package/office/index.mjs CHANGED
@@ -1,340 +1,269 @@
1
1
  #!/usr/bin/env node
2
+ import { createHash } from 'node:crypto';
3
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
4
  import path from 'node:path';
3
5
  import { spawn } from 'node:child_process';
4
- import { McpStdioServer } from '../_shared/mcp-stdio.mjs';
6
+ import { McpServer } from '@modelcontextprotocol/server';
7
+ import { serveStdio } from '@modelcontextprotocol/server/stdio';
8
+ import * as z from 'zod/v4';
5
9
  import {
6
10
  commandCandidate,
7
11
  createToolResult,
8
- maybeReadJson,
9
12
  requireString,
10
- resolveRepoPath,
11
- runCandidateChain,
12
13
  runJsonCandidateChain,
13
14
  withTempJsonFile,
14
15
  } from '../_shared/tool-runtime.mjs';
15
16
 
17
+ const packageMetadata = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
18
+ const invocationCwd = process.cwd();
19
+
16
20
  const docxCandidates = [
17
- commandCandidate('tiwater-docx'),
21
+ commandCandidate('tiwater-docx', [], { cwd: invocationCwd }),
18
22
  ];
19
23
 
20
24
  const xlsxCandidates = [
21
- commandCandidate('tiwater-xlsx'),
25
+ commandCandidate('tiwater-xlsx', [], { cwd: invocationCwd }),
22
26
  ];
23
27
 
24
28
  const pptxCandidates = [
25
- commandCandidate('tiwater-pptx'),
29
+ commandCandidate('tiwater-pptx', [], { cwd: invocationCwd }),
26
30
  ];
27
31
 
28
- function templateMigrationInputSchema() {
29
- return {
30
- type: 'object',
31
- properties: {
32
- source: { type: 'string', description: 'Path to the current source DOCX.' },
33
- baseline: { type: 'string', description: 'Path to the selected current baseline DOCX.' },
34
- output: { type: 'string', description: 'Path to the migrated output DOCX.' },
35
- choices: {
36
- type: 'array',
37
- description: 'Exactly one business choice for every source id returned by docx_list_migration_choices.',
38
- items: {
39
- type: 'object',
40
- properties: {
41
- sourceChoiceId: { type: 'string' },
42
- action: {
43
- type: 'string',
44
- enum: ['place-content', 'keep-template-content', 'keep-template-label', 'select-template-option', 'exclude-source', 'review-source'],
45
- },
46
- targetChoiceId: { type: 'string', description: 'Required only when the selected action uses a baseline target.' },
47
- cardinality: { type: 'string', enum: ['one', 'all'] },
48
- },
49
- required: ['sourceChoiceId', 'action'],
50
- additionalProperties: false,
51
- },
52
- },
53
- templateCleanup: {
54
- type: 'array',
55
- description: 'Optional baseline-owned placeholders or example rows to clear.',
56
- items: {
57
- type: 'object',
58
- properties: {
59
- targetChoiceId: { type: 'string' },
60
- scope: { type: 'string', enum: ['cell', 'row'] },
61
- },
62
- required: ['targetChoiceId', 'scope'],
63
- additionalProperties: false,
64
- },
65
- },
66
- },
67
- required: ['source', 'baseline', 'output', 'choices'],
68
- additionalProperties: false,
69
- };
32
+ const pathInput = z.string().trim().min(1);
33
+ const migrationAction = z.enum([
34
+ 'place-content',
35
+ 'keep-template-content',
36
+ 'keep-template-label',
37
+ 'select-template-option',
38
+ 'exclude-source',
39
+ 'review-source',
40
+ ]);
41
+ const targetActions = new Set([
42
+ 'place-content',
43
+ 'keep-template-content',
44
+ 'keep-template-label',
45
+ 'select-template-option',
46
+ ]);
47
+ const terminalActions = new Set(['exclude-source', 'review-source']);
48
+
49
+ const migrationChoiceInput = z.object({
50
+ sourceChoiceId: z.string().trim().min(1),
51
+ action: migrationAction,
52
+ targetChoiceId: z.string().trim().min(1).optional(),
53
+ cardinality: z.enum(['one', 'all']).optional(),
54
+ }).strict().superRefine((choice, context) => {
55
+ if (targetActions.has(choice.action) && !choice.targetChoiceId) {
56
+ context.addIssue({ code: 'custom', path: ['targetChoiceId'], message: `${choice.action} requires targetChoiceId` });
57
+ }
58
+ if (terminalActions.has(choice.action) && choice.targetChoiceId) {
59
+ context.addIssue({ code: 'custom', path: ['targetChoiceId'], message: `${choice.action} forbids targetChoiceId` });
60
+ }
61
+ if (choice.cardinality === 'all' && !terminalActions.has(choice.action)) {
62
+ context.addIssue({ code: 'custom', path: ['cardinality'], message: 'cardinality all is limited to terminal actions' });
63
+ }
64
+ });
65
+
66
+ const templateCleanupInput = z.object({
67
+ targetChoiceId: z.string().trim().min(1),
68
+ scope: z.enum(['cell', 'row']),
69
+ }).strict();
70
+
71
+ const templateMigrationInput = z.object({
72
+ source: pathInput.describe('Path to the current source DOCX.'),
73
+ baseline: pathInput.describe('Path to the selected current baseline DOCX.'),
74
+ output: pathInput.describe('Path to the migrated output DOCX.'),
75
+ choices: z.array(migrationChoiceInput).describe('Exactly one business choice for every source id returned by docx_list_migration_choices.'),
76
+ templateCleanup: z.array(templateCleanupInput).optional().describe('Optional baseline-owned placeholders or example rows to clear.'),
77
+ }).strict();
78
+
79
+ const runtimeIdentity = z.object({
80
+ command: z.string(),
81
+ cwd: z.string(),
82
+ }).strict();
83
+
84
+ const migrationChoiceOutput = z.object({
85
+ id: z.string(),
86
+ kind: z.string(),
87
+ scope: z.string(),
88
+ text: z.string().nullable(),
89
+ count: z.number().int(),
90
+ requiredCardinality: z.string().nullable(),
91
+ context: z.record(z.string(), z.unknown()).nullable(),
92
+ allowedActions: z.array(z.string()),
93
+ }).strict();
94
+
95
+ const migrationCatalogOutput = z.object({
96
+ tool: z.literal('docx_list_migration_choices'),
97
+ runtime: runtimeIdentity,
98
+ catalog: z.object({
99
+ schema: z.string(),
100
+ pass: z.boolean(),
101
+ sourceSha256: z.string(),
102
+ baselineSha256: z.string(),
103
+ sources: z.array(migrationChoiceOutput),
104
+ targets: z.array(migrationChoiceOutput),
105
+ }).strict(),
106
+ }).strict();
107
+
108
+ function migrationReceiptOutput(tool) {
109
+ return z.object({
110
+ tool: z.literal(tool),
111
+ runtime: runtimeIdentity,
112
+ receipt: z.object({
113
+ schema: z.string(),
114
+ toolVersion: z.string(),
115
+ status: z.enum(['pass', 'review-required', 'failed']),
116
+ pass: z.boolean(),
117
+ reviewRequired: z.boolean(),
118
+ outputVerified: z.boolean(),
119
+ output: z.string().nullable(),
120
+ plan: z.string().nullable(),
121
+ failures: z.array(z.unknown()),
122
+ }).passthrough(),
123
+ }).strict();
70
124
  }
71
125
 
72
- const runtimeIdentitySchema = {
73
- type: 'object',
74
- properties: {
75
- command: { type: 'string' },
76
- cwd: { type: 'string' },
77
- },
78
- required: ['command', 'cwd'],
79
- additionalProperties: false,
80
- };
81
-
82
- const migrationChoiceSchema = {
83
- type: 'object',
84
- properties: {
85
- id: { type: 'string' },
86
- kind: { type: 'string' },
87
- scope: { type: 'string' },
88
- text: { type: ['string', 'null'] },
89
- count: { type: 'integer' },
90
- requiredCardinality: { type: ['string', 'null'] },
91
- context: { type: ['object', 'null'] },
92
- allowedActions: { type: 'array', items: { type: 'string' } },
93
- },
94
- required: ['id', 'kind', 'scope', 'text', 'count', 'requiredCardinality', 'context', 'allowedActions'],
95
- additionalProperties: false,
96
- };
97
-
98
- const migrationCatalogOutputSchema = {
99
- type: 'object',
100
- properties: {
101
- tool: { const: 'docx_list_migration_choices' },
102
- runtime: runtimeIdentitySchema,
103
- catalog: {
104
- type: 'object',
105
- properties: {
106
- schema: { type: 'string' },
107
- pass: { type: 'boolean' },
108
- sourceSha256: { type: 'string' },
109
- baselineSha256: { type: 'string' },
110
- sources: { type: 'array', items: migrationChoiceSchema },
111
- targets: { type: 'array', items: migrationChoiceSchema },
112
- },
113
- required: ['schema', 'pass', 'sourceSha256', 'baselineSha256', 'sources', 'targets'],
114
- additionalProperties: false,
115
- },
116
- },
117
- required: ['tool', 'runtime', 'catalog'],
118
- additionalProperties: false,
119
- };
120
-
121
- function migrationReceiptOutputSchema(tool) {
122
- return {
123
- type: 'object',
124
- properties: {
125
- tool: { const: tool },
126
- runtime: runtimeIdentitySchema,
127
- receipt: {
128
- type: 'object',
129
- properties: {
130
- schema: { type: 'string' },
131
- toolVersion: { type: 'string' },
132
- status: { type: 'string', enum: ['pass', 'review-required', 'failed'] },
133
- pass: { type: 'boolean' },
134
- reviewRequired: { type: 'boolean' },
135
- outputVerified: { type: 'boolean' },
136
- output: { type: ['string', 'null'] },
137
- plan: { type: ['string', 'null'] },
138
- failures: { type: 'array', items: { type: 'object' } },
139
- },
140
- required: ['schema', 'toolVersion', 'status', 'pass', 'reviewRequired', 'outputVerified', 'output', 'plan', 'failures'],
141
- additionalProperties: true,
142
- },
143
- },
144
- required: ['tool', 'runtime', 'receipt'],
145
- additionalProperties: false,
146
- };
126
+ const inputOnly = z.object({ input: pathInput }).strict();
127
+ const artifactInput = z.object({
128
+ input: pathInput,
129
+ output: pathInput.describe('New JSON artifact path. Existing files are never overwritten.'),
130
+ }).strict();
131
+ const artifact = z.object({
132
+ path: z.string(),
133
+ sha256: z.string().regex(/^[0-9a-f]{64}$/),
134
+ bytes: z.number().int().nonnegative(),
135
+ }).strict();
136
+
137
+ function artifactOutput(tool) {
138
+ return z.object({ tool: z.literal(tool), runtime: runtimeIdentity, artifact }).strict();
147
139
  }
148
140
 
149
141
  const tools = [
150
142
  {
151
143
  name: 'docx_inspect',
152
- description: 'Inspect a DOCX document and return a unified structural report including placeholders, comments, anchors, tables, fields, and formatting metrics.',
153
- inputSchema: {
154
- type: 'object',
155
- properties: { input: { type: 'string', description: 'Absolute or relative path to a .docx file.' } },
156
- required: ['input'],
157
- },
158
- },
159
- {
160
- name: 'docx_inspect_tables',
161
- description: 'Inspect DOCX body tables with row, cell, merge, paragraph alignment, run font, color, underline, and text-fill details.',
162
- inputSchema: {
163
- type: 'object',
164
- properties: { input: { type: 'string', description: 'Absolute or relative path to a .docx file.' } },
165
- required: ['input'],
166
- },
144
+ description: 'Inspect a DOCX document and write one unified JSON observation containing placeholders, comments, anchors, tables, fields, flow, fonts, and formatting metrics.',
145
+ inputSchema: artifactInput,
146
+ outputSchema: artifactOutput('docx_inspect'),
147
+ handler: docxInspect,
167
148
  },
168
149
  {
169
150
  name: 'docx_list_migration_choices',
170
151
  description: 'List every current source item that still needs a business choice and the selectable current baseline targets. Returns opaque ids and context; it does not recommend a choice.',
171
- inputSchema: {
172
- type: 'object',
173
- properties: {
174
- source: { type: 'string', description: 'Path to the current source DOCX.' },
175
- baseline: { type: 'string', description: 'Path to the selected current baseline DOCX.' },
176
- },
177
- required: ['source', 'baseline'],
178
- additionalProperties: false,
179
- },
180
- outputSchema: migrationCatalogOutputSchema,
152
+ inputSchema: z.object({
153
+ source: pathInput.describe('Path to the current source DOCX.'),
154
+ baseline: pathInput.describe('Path to the selected current baseline DOCX.'),
155
+ }).strict(),
156
+ outputSchema: migrationCatalogOutput,
157
+ annotations: { readOnlyHint: true, idempotentHint: true },
158
+ handler: docxListMigrationChoices,
181
159
  },
182
160
  {
183
161
  name: 'docx_migrate_template',
184
162
  description: 'Migrate a current DOCX into the selected baseline from one complete batch of business choices. Choices reference only opaque ids returned by docx_list_migration_choices; the tool derives all document values, coordinates, plans, and edits.',
185
- inputSchema: templateMigrationInputSchema(),
186
- outputSchema: migrationReceiptOutputSchema('docx_migrate_template'),
163
+ inputSchema: templateMigrationInput,
164
+ outputSchema: migrationReceiptOutput('docx_migrate_template'),
165
+ handler: docxMigrateTemplate,
187
166
  },
188
167
  {
189
168
  name: 'docx_verify_migration',
190
169
  description: 'Independently re-resolve the same business choices and verify a migrated DOCX against the current source and baseline. This does not trust the migration receipt.',
191
- inputSchema: templateMigrationInputSchema(),
192
- outputSchema: migrationReceiptOutputSchema('docx_verify_migration'),
170
+ inputSchema: templateMigrationInput,
171
+ outputSchema: migrationReceiptOutput('docx_verify_migration'),
172
+ annotations: { readOnlyHint: true, idempotentHint: true },
173
+ handler: docxVerifyMigration,
193
174
  },
194
175
  {
195
176
  name: 'docx_compare',
196
177
  description: 'Compare two DOCX files and report package, metric, and style differences.',
197
- inputSchema: {
198
- type: 'object',
199
- properties: {
200
- baseline: { type: 'string' },
201
- updated: { type: 'string' },
202
- },
203
- required: ['baseline', 'updated'],
204
- },
178
+ inputSchema: z.object({ baseline: pathInput, updated: pathInput }).strict(),
179
+ annotations: { readOnlyHint: true, idempotentHint: true },
180
+ handler: docxCompare,
205
181
  },
206
182
  {
207
183
  name: 'docx_validate_template_transform',
208
184
  description: 'Validate whether a source DOCX template and target DOCX template are structurally compatible.',
209
- inputSchema: {
210
- type: 'object',
211
- properties: {
212
- sourceTemplate: { type: 'string' },
213
- targetTemplate: { type: 'string' },
214
- },
215
- required: ['sourceTemplate', 'targetTemplate'],
216
- },
185
+ inputSchema: z.object({ sourceTemplate: pathInput, targetTemplate: pathInput }).strict(),
186
+ annotations: { readOnlyHint: true, idempotentHint: true },
187
+ handler: docxValidateTemplateTransform,
217
188
  },
218
189
  {
219
190
  name: 'docx_export_json',
220
- description: 'Export the body content of a DOCX document as structured JSON.',
221
- inputSchema: {
222
- type: 'object',
223
- properties: {
224
- input: { type: 'string' },
225
- output: { type: 'string' },
226
- },
227
- required: ['input'],
228
- },
191
+ description: 'Export DOCX body content to a new JSON artifact without returning the full document through MCP.',
192
+ inputSchema: artifactInput,
193
+ outputSchema: artifactOutput('docx_export_json'),
194
+ handler: docxExportJson,
229
195
  },
230
196
  {
231
197
  name: 'xlsx_inspect',
232
- description: 'Inspect an XLSX workbook and return sheet-level metrics, used ranges, formula counts, and merged ranges.',
233
- inputSchema: {
234
- type: 'object',
235
- properties: { input: { type: 'string' } },
236
- required: ['input'],
237
- },
198
+ description: 'Inspect an XLSX workbook and write one JSON observation containing workbook structure, exported values, formulas, styles, merged ranges, and conversion evidence.',
199
+ inputSchema: artifactInput,
200
+ outputSchema: artifactOutput('xlsx_inspect'),
201
+ handler: xlsxInspect,
238
202
  },
239
203
  {
240
204
  name: 'xlsx_export_json',
241
205
  description: 'Export workbook sheet data from XLSX as structured JSON.',
242
- inputSchema: {
243
- type: 'object',
244
- properties: {
245
- input: { type: 'string' },
246
- output: { type: 'string' },
247
- resolveMergedCells: { type: 'boolean', description: 'Resolve merged cells to project values' }
248
- },
249
- required: ['input'],
250
- },
206
+ inputSchema: z.object({
207
+ input: pathInput,
208
+ output: pathInput.describe('New JSON artifact path. Existing files are never overwritten.'),
209
+ resolveMergedCells: z.boolean().optional().describe('Resolve merged cells to project values.'),
210
+ }).strict(),
211
+ outputSchema: artifactOutput('xlsx_export_json'),
212
+ handler: xlsxExportJson,
251
213
  },
252
214
  {
253
215
  name: 'xlsx_validate',
254
216
  description: 'Validate an XLSX workbook package and return Open XML validation evidence.',
255
- inputSchema: {
256
- type: 'object',
257
- properties: { input: { type: 'string', description: 'Absolute or relative path to a .xlsx file.' } },
258
- required: ['input'],
259
- },
217
+ inputSchema: inputOnly,
218
+ annotations: { readOnlyHint: true, idempotentHint: true },
219
+ handler: xlsxValidate,
260
220
  },
261
221
  {
262
222
  name: 'pptx_inspect',
263
- description: 'Inspect a PPTX file and return slide metrics and discovered placeholders.',
264
- inputSchema: {
265
- type: 'object',
266
- properties: { input: { type: 'string' } },
267
- required: ['input'],
268
- },
269
- },
270
- {
271
- name: 'pptx_inspect_detail',
272
- description: 'Inspect a PPTX file and return detailed slide, shape, transform, paragraph, and run-format evidence.',
273
- inputSchema: {
274
- type: 'object',
275
- properties: { input: { type: 'string' } },
276
- required: ['input'],
277
- },
223
+ description: 'Inspect a PPTX file and write one detailed JSON observation containing slides, masters, layouts, shapes, transforms, paragraphs, runs, and placeholders.',
224
+ inputSchema: artifactInput,
225
+ outputSchema: artifactOutput('pptx_inspect'),
226
+ handler: pptxInspect,
278
227
  },
279
228
  {
280
229
  name: 'pptx_export_json',
281
- description: 'Export PPTX slide text and placeholder hints as structured JSON.',
282
- inputSchema: {
283
- type: 'object',
284
- properties: {
285
- input: { type: 'string' },
286
- output: { type: 'string' },
287
- },
288
- required: ['input'],
289
- },
230
+ description: 'Export PPTX slide text, notes, and placeholder hints to a new JSON artifact without returning the full presentation through MCP.',
231
+ inputSchema: artifactInput,
232
+ outputSchema: artifactOutput('pptx_export_json'),
233
+ handler: pptxExportJson,
290
234
  },
291
235
  ];
292
236
 
293
- async function callTool(name, args) {
294
- switch (name) {
295
- case 'docx_inspect':
296
- return createToolResult(await docxInspect(args));
297
- case 'docx_inspect_tables':
298
- return createToolResult(await docxInspectTables(args));
299
- case 'docx_list_migration_choices':
300
- return createToolResult(await docxListMigrationChoices(args));
301
- case 'docx_migrate_template':
302
- return createToolResult(await docxMigrateTemplate(args));
303
- case 'docx_verify_migration':
304
- return createToolResult(await docxVerifyMigration(args));
305
- case 'docx_compare':
306
- return createToolResult(await docxCompare(args));
307
- case 'docx_validate_template_transform':
308
- return createToolResult(await docxValidateTemplateTransform(args));
309
- case 'docx_export_json':
310
- return createToolResult(await docxExportJson(args));
311
- case 'xlsx_inspect':
312
- return createToolResult(await xlsxInspect(args));
313
- case 'xlsx_export_json':
314
- return createToolResult(await xlsxExportJson(args));
315
- case 'xlsx_validate':
316
- return createToolResult(await xlsxValidate(args));
317
- case 'pptx_inspect':
318
- return createToolResult(await pptxInspect(args));
319
- case 'pptx_inspect_detail':
320
- return createToolResult(await pptxInspectDetail(args));
321
- case 'pptx_export_json':
322
- return createToolResult(await pptxExportJson(args));
323
- default:
324
- throw Object.assign(new Error(`Unknown tool: ${name}`), { code: -32601 });
237
+ function buildServer() {
238
+ const server = new McpServer(
239
+ { name: 'tiwater-office', version: packageMetadata.version },
240
+ {
241
+ instructions: 'Use the Office tools for technical document observation. For template migration, list the current choices, select only allowed business actions, migrate once, and independently verify the result. Never invent document values, identities, coordinates, plans, or edit operations.',
242
+ },
243
+ );
244
+ for (const tool of tools) {
245
+ server.registerTool(
246
+ tool.name,
247
+ {
248
+ description: tool.description,
249
+ inputSchema: tool.inputSchema,
250
+ ...(tool.outputSchema ? { outputSchema: tool.outputSchema } : {}),
251
+ ...(tool.annotations ? { annotations: tool.annotations } : {}),
252
+ },
253
+ async args => createToolResult(await tool.handler(args)),
254
+ );
325
255
  }
256
+ return server;
326
257
  }
327
258
 
328
259
  async function docxInspect(args) {
329
260
  const input = requireString(args.input, 'input');
330
261
  const result = await runJsonCandidateChain(docxCandidates, ['inspect', input, '--json']);
331
- return { tool: 'docx_inspect', runtime: commandRuntime(result), report: result.json };
332
- }
333
-
334
- async function docxInspectTables(args) {
335
- const input = requireString(args.input, 'input');
336
- const result = await runJsonCandidateChain(docxCandidates, ['inspect-tables', input, '--json']);
337
- return { tool: 'docx_inspect_tables', runtime: commandRuntime(result), report: result.json };
262
+ return {
263
+ tool: 'docx_inspect',
264
+ runtime: commandRuntime(result),
265
+ artifact: await writeJsonArtifact(requireString(args.output, 'output'), result.json),
266
+ };
338
267
  }
339
268
 
340
269
  async function docxListMigrationChoices(args) {
@@ -389,19 +318,22 @@ async function docxValidateTemplateTransform(args) {
389
318
 
390
319
  async function docxExportJson(args) {
391
320
  const input = requireString(args.input, 'input');
392
- if (args.output) {
393
- const output = requireString(args.output, 'output');
394
- const result = await runCandidateChain(docxCandidates, ['export-json', input, output]);
395
- return { tool: 'docx_export_json', runtime: commandRuntime(result), outputPath: output, document: await maybeReadJson(output) };
396
- }
397
- const result = await runCandidateChain(docxCandidates, ['export-json', input]);
398
- return { tool: 'docx_export_json', runtime: commandRuntime(result), document: JSON.parse(result.stdout) };
321
+ const result = await runJsonCandidateChain(docxCandidates, ['export-json', input]);
322
+ return {
323
+ tool: 'docx_export_json',
324
+ runtime: commandRuntime(result),
325
+ artifact: await writeJsonArtifact(requireString(args.output, 'output'), result.json),
326
+ };
399
327
  }
400
328
 
401
329
  async function xlsxInspect(args) {
402
330
  const input = requireString(args.input, 'input');
403
331
  const result = await runJsonCandidateChain(xlsxCandidates, ['inspect', input, '--json']);
404
- return { tool: 'xlsx_inspect', runtime: commandRuntime(result), report: result.json };
332
+ return {
333
+ tool: 'xlsx_inspect',
334
+ runtime: commandRuntime(result),
335
+ artifact: await writeJsonArtifact(requireString(args.output, 'output'), result.json),
336
+ };
405
337
  }
406
338
 
407
339
  async function xlsxExportJson(args) {
@@ -410,14 +342,12 @@ async function xlsxExportJson(args) {
410
342
  if (args.resolveMergedCells) {
411
343
  cmdArgs.push('--resolve-merged-cells');
412
344
  }
413
- if (args.output) {
414
- const output = requireString(args.output, 'output');
415
- cmdArgs.push(output);
416
- const result = await runCandidateChain(xlsxCandidates, cmdArgs);
417
- return { tool: 'xlsx_export_json', runtime: commandRuntime(result), outputPath: output, workbook: await maybeReadJson(output) };
418
- }
419
- const result = await runCandidateChain(xlsxCandidates, cmdArgs);
420
- return { tool: 'xlsx_export_json', runtime: commandRuntime(result), workbook: JSON.parse(result.stdout) };
345
+ const result = await runJsonCandidateChain(xlsxCandidates, cmdArgs);
346
+ return {
347
+ tool: 'xlsx_export_json',
348
+ runtime: commandRuntime(result),
349
+ artifact: await writeJsonArtifact(requireString(args.output, 'output'), result.json),
350
+ };
421
351
  }
422
352
 
423
353
  async function xlsxValidate(args) {
@@ -429,24 +359,33 @@ async function xlsxValidate(args) {
429
359
  async function pptxInspect(args) {
430
360
  const input = requireString(args.input, 'input');
431
361
  const result = await runJsonCandidateChain(pptxCandidates, ['inspect', input, '--json']);
432
- return { tool: 'pptx_inspect', runtime: commandRuntime(result), report: result.json };
362
+ return {
363
+ tool: 'pptx_inspect',
364
+ runtime: commandRuntime(result),
365
+ artifact: await writeJsonArtifact(requireString(args.output, 'output'), result.json),
366
+ };
433
367
  }
434
368
 
435
- async function pptxInspectDetail(args) {
369
+ async function pptxExportJson(args) {
436
370
  const input = requireString(args.input, 'input');
437
- const result = await runJsonCandidateChain(pptxCandidates, ['inspect', input, '--json', '--detail']);
438
- return { tool: 'pptx_inspect_detail', runtime: commandRuntime(result), report: result.json };
371
+ const result = await runJsonCandidateChain(pptxCandidates, ['export-json', input]);
372
+ return {
373
+ tool: 'pptx_export_json',
374
+ runtime: commandRuntime(result),
375
+ artifact: await writeJsonArtifact(requireString(args.output, 'output'), result.json),
376
+ };
439
377
  }
440
378
 
441
- async function pptxExportJson(args) {
442
- const input = requireString(args.input, 'input');
443
- if (args.output) {
444
- const output = requireString(args.output, 'output');
445
- const result = await runCandidateChain(pptxCandidates, ['export-json', input, output]);
446
- return { tool: 'pptx_export_json', runtime: commandRuntime(result), outputPath: output, document: await maybeReadJson(output) };
447
- }
448
- const result = await runCandidateChain(pptxCandidates, ['export-json', input]);
449
- return { tool: 'pptx_export_json', runtime: commandRuntime(result), document: JSON.parse(result.stdout) };
379
+ async function writeJsonArtifact(output, payload) {
380
+ const fullPath = path.resolve(output);
381
+ await mkdir(path.dirname(fullPath), { recursive: true });
382
+ const bytes = Buffer.from(`${JSON.stringify(payload, null, 2)}\n`, 'utf8');
383
+ await writeFile(fullPath, bytes, { flag: 'wx' });
384
+ return {
385
+ path: fullPath,
386
+ sha256: createHash('sha256').update(bytes).digest('hex'),
387
+ bytes: bytes.length,
388
+ };
450
389
  }
451
390
 
452
391
  function commandRuntime(result) {
@@ -456,7 +395,7 @@ function commandRuntime(result) {
456
395
  };
457
396
  }
458
397
 
459
- await new McpStdioServer({ name: 'tiwater-office', version: '0.2.0', tools, callTool }).start();
398
+ serveStdio(buildServer);
460
399
 
461
400
  async function runXlsxValidateCandidateChain(args) {
462
401
  const errors = [];
@@ -487,7 +426,7 @@ async function runXlsxValidateCandidateChain(args) {
487
426
 
488
427
  async function runValidationCommand(candidate, args) {
489
428
  const env = { ...process.env, ...(candidate.env || {}) };
490
- const cwd = candidate.cwd || resolveRepoPath();
429
+ const cwd = candidate.cwd || process.cwd();
491
430
  const commandArgs = [...(candidate.argsPrefix || []), ...args];
492
431
 
493
432
  return await new Promise((resolve, reject) => {
@@ -506,7 +445,7 @@ async function runValidationCommand(candidate, args) {
506
445
  child.on('error', reject);
507
446
  child.on('close', code => {
508
447
  if (code === 0 || code === 1) {
509
- resolve({ code, stdout, stderr, command: candidate.command, args: commandArgs });
448
+ resolve({ code, stdout, stderr, command: candidate.command, args: commandArgs, cwd });
510
449
  return;
511
450
  }
512
451
  reject(new Error(`${candidate.command} ${commandArgs.join(' ')} failed with exit code ${code}\n${stderr || stdout}`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiwater/office-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Published MCP server for Tiwater Office document capabilities",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -12,8 +12,11 @@
12
12
  "bin": {
13
13
  "tiwater-office-mcp": "office/index.mjs"
14
14
  },
15
+ "engines": {
16
+ "node": ">=20"
17
+ },
15
18
  "files": [
16
- "_shared/*.mjs",
19
+ "_shared/tool-runtime.mjs",
17
20
  "office/index.mjs",
18
21
  "office/README.md"
19
22
  ],
@@ -22,5 +25,9 @@
22
25
  },
23
26
  "publishConfig": {
24
27
  "access": "public"
28
+ },
29
+ "dependencies": {
30
+ "@modelcontextprotocol/server": "2.0.0",
31
+ "zod": "4.4.3"
25
32
  }
26
33
  }
@@ -1,152 +0,0 @@
1
- import process from 'node:process';
2
-
3
- const JSONRPC_VERSION = '2.0';
4
- const SUPPORTED_PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05', '2024-10-07'];
5
- const FALLBACK_PROTOCOL_VERSION = '2025-06-18';
6
-
7
- function writeMessage(message) {
8
- process.stdout.write(`${JSON.stringify(message)}\n`);
9
- }
10
-
11
- function toError(code, message, data) {
12
- return { code, message, ...(data === undefined ? {} : { data }) };
13
- }
14
-
15
- function normalizeToolCallError(error) {
16
- if (!error) return toError(-32603, 'Unknown error');
17
- if (error.code && error.message) return error;
18
- return toError(-32603, error instanceof Error ? error.message : String(error));
19
- }
20
-
21
- export class McpStdioServer {
22
- constructor({ name, version, instructions, tools, callTool, logger = console.error }) {
23
- this.serverInfo = { name, version };
24
- this.instructions = instructions;
25
- this.tools = tools;
26
- this.callTool = callTool;
27
- this.logger = logger;
28
- this.lineBuffer = '';
29
- this.binaryBuffer = Buffer.alloc(0);
30
- this.initialized = false;
31
- }
32
-
33
- start() {
34
- process.stdin.on('data', chunk => this.#onData(chunk));
35
- process.stdin.on('end', () => process.exit(0));
36
- }
37
-
38
- #onData(chunk) {
39
- const text = chunk.toString('utf8');
40
-
41
- if (this.binaryBuffer.length > 0 || text.includes('Content-Length:')) {
42
- this.binaryBuffer = Buffer.concat([this.binaryBuffer, chunk]);
43
- this.#drainContentLengthBuffer();
44
- return;
45
- }
46
-
47
- this.lineBuffer += text;
48
- while (true) {
49
- const newlineIndex = this.lineBuffer.indexOf('\n');
50
- if (newlineIndex === -1) return;
51
- const line = this.lineBuffer.slice(0, newlineIndex).replace(/\r$/, '').trim();
52
- this.lineBuffer = this.lineBuffer.slice(newlineIndex + 1);
53
- if (!line) continue;
54
- this.#parseAndHandle(line, null);
55
- }
56
- }
57
-
58
- #drainContentLengthBuffer() {
59
- while (true) {
60
- const headerEnd = this.binaryBuffer.indexOf('\r\n\r\n');
61
- if (headerEnd === -1) return;
62
-
63
- const headerText = this.binaryBuffer.subarray(0, headerEnd).toString('utf8');
64
- const lengthMatch = headerText.match(/Content-Length:\s*(\d+)/i);
65
- if (!lengthMatch) {
66
- this.logger('Missing Content-Length header');
67
- this.binaryBuffer = Buffer.alloc(0);
68
- return;
69
- }
70
-
71
- const contentLength = Number(lengthMatch[1]);
72
- const messageStart = headerEnd + 4;
73
- const messageEnd = messageStart + contentLength;
74
- if (this.binaryBuffer.length < messageEnd) return;
75
-
76
- const body = this.binaryBuffer.subarray(messageStart, messageEnd).toString('utf8');
77
- this.binaryBuffer = this.binaryBuffer.subarray(messageEnd);
78
- this.#parseAndHandle(body, null);
79
- }
80
- }
81
-
82
- #parseAndHandle(body, idHint) {
83
- let message;
84
- try {
85
- message = JSON.parse(body);
86
- } catch (error) {
87
- writeMessage({ jsonrpc: JSONRPC_VERSION, id: idHint, error: toError(-32700, 'Parse error', String(error)) });
88
- return;
89
- }
90
-
91
- void this.#handleMessage(message);
92
- }
93
-
94
- async #handleMessage(message) {
95
- if (!message || message.jsonrpc !== JSONRPC_VERSION || typeof message.method !== 'string') {
96
- if ('id' in (message || {})) {
97
- writeMessage({ jsonrpc: JSONRPC_VERSION, id: message.id ?? null, error: toError(-32600, 'Invalid Request') });
98
- }
99
- return;
100
- }
101
-
102
- const { id, method, params = {} } = message;
103
- const isNotification = id === undefined;
104
-
105
- try {
106
- switch (method) {
107
- case 'initialize': {
108
- const requested = params.protocolVersion;
109
- const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.includes(requested) ? requested : FALLBACK_PROTOCOL_VERSION;
110
- const result = {
111
- protocolVersion,
112
- capabilities: { tools: {} },
113
- serverInfo: this.serverInfo,
114
- ...(this.instructions ? { instructions: this.instructions } : {}),
115
- };
116
- if (!isNotification) writeMessage({ jsonrpc: JSONRPC_VERSION, id, result });
117
- return;
118
- }
119
- case 'notifications/initialized': {
120
- this.initialized = true;
121
- return;
122
- }
123
- case 'ping': {
124
- if (!isNotification) writeMessage({ jsonrpc: JSONRPC_VERSION, id, result: {} });
125
- return;
126
- }
127
- case 'tools/list': {
128
- if (!isNotification) writeMessage({ jsonrpc: JSONRPC_VERSION, id, result: { tools: this.tools } });
129
- return;
130
- }
131
- case 'tools/call': {
132
- const name = params?.name;
133
- const args = params?.arguments ?? {};
134
- if (typeof name !== 'string' || !name) {
135
- if (!isNotification) writeMessage({ jsonrpc: JSONRPC_VERSION, id, error: toError(-32602, 'Invalid params: missing tool name') });
136
- return;
137
- }
138
- const result = await this.callTool(name, args);
139
- if (!isNotification) writeMessage({ jsonrpc: JSONRPC_VERSION, id, result });
140
- return;
141
- }
142
- default: {
143
- if (!isNotification) writeMessage({ jsonrpc: JSONRPC_VERSION, id, error: toError(-32601, `Method not found: ${method}`) });
144
- }
145
- }
146
- } catch (error) {
147
- if (!isNotification) {
148
- writeMessage({ jsonrpc: JSONRPC_VERSION, id, error: normalizeToolCallError(error) });
149
- }
150
- }
151
- }
152
- }