@tiwater/office-mcp 0.4.0 → 0.6.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
@@ -6,6 +6,7 @@ Shared stdio MCP server for Office document workflows.
6
6
 
7
7
  - `docx_inspect`
8
8
  - `docx_list_migration_choices`
9
+ - `docx_query_migration_choices`
9
10
  - `docx_migrate_template`
10
11
  - `docx_verify_migration`
11
12
  - `docx_compare`
@@ -30,3 +31,7 @@ The official MCP SDK derives the schemas advertised to clients and validates
30
31
  tool arguments and structured results before they cross the protocol boundary.
31
32
  Large observations and exports are written to a caller-selected new JSON
32
33
  artifact. MCP returns only the artifact path, hash, and byte count.
34
+ Template-migration choice artifacts are opaque evidence. Query the same current
35
+ source and baseline through `docx_query_migration_choices` to page unresolved
36
+ sources, request targets compatible with one business action, or inspect cleanup
37
+ targets.
package/office/index.mjs CHANGED
@@ -3,6 +3,7 @@ import { createHash } from 'node:crypto';
3
3
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { spawn } from 'node:child_process';
6
+ import { isDeepStrictEqual } from 'node:util';
6
7
  import { McpServer } from '@modelcontextprotocol/server';
7
8
  import { serveStdio } from '@modelcontextprotocol/server/stdio';
8
9
  import * as z from 'zod/v4';
@@ -100,7 +101,17 @@ const migrationCatalog = z.object({
100
101
  baselineSha256: z.string(),
101
102
  sources: z.array(migrationChoiceOutput),
102
103
  targets: z.array(migrationChoiceOutput),
103
- }).strict();
104
+ }).strict().superRefine((catalog, context) => {
105
+ for (const key of ['sources', 'targets']) {
106
+ const seen = new Set();
107
+ for (const [index, choice] of catalog[key].entries()) {
108
+ if (seen.has(choice.id)) {
109
+ context.addIssue({ code: 'custom', path: [key, index, 'id'], message: `duplicate ${key} choice id: ${choice.id}` });
110
+ }
111
+ seen.add(choice.id);
112
+ }
113
+ }
114
+ });
104
115
 
105
116
  const migrationReceipt = z.object({
106
117
  schema: z.string(),
@@ -139,6 +150,76 @@ const migrationCatalogOutput = z.object({
139
150
  }).strict(),
140
151
  }).strict();
141
152
 
153
+ const migrationQueryPage = z.object({
154
+ offset: z.number().int().nonnegative(),
155
+ returned: z.number().int().nonnegative(),
156
+ total: z.number().int().nonnegative(),
157
+ hasMore: z.boolean(),
158
+ }).strict();
159
+
160
+ const migrationTargetPage = z.object({
161
+ schema: z.string(),
162
+ pass: z.boolean(),
163
+ sourceChoiceId: z.string().nullable(),
164
+ branch: z.string(),
165
+ offset: z.number().int().nonnegative(),
166
+ limit: z.number().int().positive(),
167
+ total: z.number().int().nonnegative(),
168
+ targets: z.array(migrationChoiceOutput),
169
+ }).strict();
170
+
171
+ const migrationTargetAction = z.enum([
172
+ 'place-content',
173
+ 'keep-template-content',
174
+ 'keep-template-label',
175
+ 'select-template-option',
176
+ ]);
177
+
178
+ const migrationQueryDocuments = {
179
+ source: pathInput.describe('Path to the current source DOCX used by docx_list_migration_choices.'),
180
+ baseline: pathInput.describe('Path to the same selected current baseline DOCX used by docx_list_migration_choices.'),
181
+ };
182
+
183
+ const boundedOffset = z.number().int().nonnegative().optional().describe('Zero-based result offset. Defaults to 0.');
184
+ const boundedLimit = z.number().int().min(1).max(10).optional().describe('Maximum results to return. Defaults to 10 and cannot exceed 10.');
185
+
186
+ const migrationChoiceQueryInput = z.discriminatedUnion('view', [
187
+ z.object({
188
+ ...migrationQueryDocuments,
189
+ view: z.literal('sources'),
190
+ offset: boundedOffset,
191
+ limit: boundedLimit,
192
+ }).strict(),
193
+ z.object({
194
+ ...migrationQueryDocuments,
195
+ view: z.literal('targets'),
196
+ sourceChoiceId: z.string().trim().min(1).describe('Opaque current source id returned by the sources view.'),
197
+ action: migrationTargetAction.describe('Business action whose technically compatible current baseline targets are requested.'),
198
+ text: z.string().trim().min(1).optional().describe('Optional literal case-insensitive text to find in target visible text or context.'),
199
+ offset: boundedOffset,
200
+ limit: boundedLimit,
201
+ }).strict(),
202
+ z.object({
203
+ ...migrationQueryDocuments,
204
+ view: z.literal('cleanup'),
205
+ text: z.string().trim().min(1).optional().describe('Optional literal case-insensitive text to find in cleanup target visible text or context.'),
206
+ offset: boundedOffset,
207
+ limit: boundedLimit,
208
+ }).strict(),
209
+ ]);
210
+
211
+ const migrationChoiceQueryOutput = z.object({
212
+ tool: z.literal('docx_query_migration_choices'),
213
+ runtime: runtimeIdentity,
214
+ sourceSha256: z.string(),
215
+ baselineSha256: z.string(),
216
+ view: z.enum(['sources', 'targets', 'cleanup']),
217
+ action: migrationTargetAction.nullable(),
218
+ source: migrationChoiceOutput.nullable(),
219
+ items: z.array(migrationChoiceOutput),
220
+ page: migrationQueryPage,
221
+ }).strict();
222
+
142
223
  function migrationReceiptOutput(tool) {
143
224
  return z.object({
144
225
  tool: z.literal(tool),
@@ -172,7 +253,7 @@ const tools = [
172
253
  },
173
254
  {
174
255
  name: 'docx_list_migration_choices',
175
- description: 'Write every current source item that still needs a business choice and the selectable current baseline targets to a run-local JSON artifact. Returns only artifact metadata and counts; it does not recommend a choice.',
256
+ description: 'Write every current source item that still needs a business choice and the selectable current baseline targets to an opaque run-local evidence artifact. Use docx_query_migration_choices with the same source and baseline to inspect bounded alternatives; do not parse the artifact.',
176
257
  inputSchema: z.object({
177
258
  source: pathInput.describe('Path to the current source DOCX.'),
178
259
  baseline: pathInput.describe('Path to the selected current baseline DOCX.'),
@@ -181,6 +262,14 @@ const tools = [
181
262
  outputSchema: migrationCatalogOutput,
182
263
  handler: docxListMigrationChoices,
183
264
  },
265
+ {
266
+ name: 'docx_query_migration_choices',
267
+ description: 'Query current template-migration alternatives without reading the catalog artifact. Page unresolved sources, request provider-compatible targets for one source and business action, or inspect cleanup targets. This tool does not rank, recommend, or make a business choice.',
268
+ inputSchema: migrationChoiceQueryInput,
269
+ outputSchema: migrationChoiceQueryOutput,
270
+ annotations: { readOnlyHint: true, idempotentHint: true },
271
+ handler: docxQueryMigrationChoices,
272
+ },
184
273
  {
185
274
  name: 'docx_migrate_template',
186
275
  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.',
@@ -309,6 +398,106 @@ async function docxListMigrationChoices(args) {
309
398
  };
310
399
  }
311
400
 
401
+ async function docxQueryMigrationChoices(args) {
402
+ const sourcePath = requireString(args.source, 'source');
403
+ const baselinePath = requireString(args.baseline, 'baseline');
404
+ const offset = args.offset ?? 0;
405
+ const limit = args.limit ?? 10;
406
+ const catalogResult = await runJsonCandidateChain(docxCandidates, ['list-template-migration-choices', sourcePath, baselinePath]);
407
+ const catalog = migrationCatalog.parse(catalogResult.json);
408
+
409
+ if (args.view === 'sources') {
410
+ return migrationQueryResult({
411
+ runtime: commandRuntime(catalogResult),
412
+ catalog,
413
+ view: 'sources',
414
+ action: null,
415
+ source: null,
416
+ items: catalog.sources.slice(offset, offset + limit),
417
+ offset,
418
+ total: catalog.sources.length,
419
+ });
420
+ }
421
+
422
+ let source = null;
423
+ let action = null;
424
+ let branch;
425
+ let sourceChoiceId = '-';
426
+ if (args.view === 'cleanup') {
427
+ branch = 'baseline-clear';
428
+ } else {
429
+ source = catalog.sources.find(item => item.id === args.sourceChoiceId) ?? null;
430
+ if (!source) {
431
+ throw Object.assign(new Error(`Unknown sourceChoiceId: ${args.sourceChoiceId}`), { code: -32602 });
432
+ }
433
+ action = args.action;
434
+ if (!source.allowedActions.includes(action)) {
435
+ throw Object.assign(new Error(`Action ${action} is not allowed for ${source.id}`), { code: -32602 });
436
+ }
437
+ sourceChoiceId = source.id;
438
+ branch = migrationTargetBranch(action, source.kind);
439
+ }
440
+
441
+ const targetResult = await runJsonCandidateChain(docxCandidates, [
442
+ 'find-template-migration-targets',
443
+ sourcePath,
444
+ baselinePath,
445
+ sourceChoiceId,
446
+ branch,
447
+ args.text ?? '-',
448
+ String(offset),
449
+ String(limit),
450
+ ]);
451
+ const targetPage = migrationTargetPage.parse(targetResult.json);
452
+ if (targetPage.sourceChoiceId !== (args.view === 'cleanup' ? null : sourceChoiceId) || targetPage.branch !== branch) {
453
+ throw new Error('Migration target page identity does not match the requested current source and action');
454
+ }
455
+ const catalogTargets = new Map(catalog.targets.map(item => [item.id, item]));
456
+ for (const target of targetPage.targets) {
457
+ const current = catalogTargets.get(target.id);
458
+ if (!current || !isDeepStrictEqual(current, target)) {
459
+ throw new Error(`Migration target ${target.id} is not bound to the current catalog`);
460
+ }
461
+ }
462
+ return migrationQueryResult({
463
+ runtime: commandRuntime(targetResult),
464
+ catalog,
465
+ view: args.view,
466
+ action,
467
+ source,
468
+ items: targetPage.targets,
469
+ offset: targetPage.offset,
470
+ total: targetPage.total,
471
+ });
472
+ }
473
+
474
+ function migrationTargetBranch(action, sourceKind) {
475
+ if (action === 'place-content') return sourceKind === 'media' ? 'copy-media' : 'copy-text';
476
+ if (action === 'keep-template-content') return 'retain-target';
477
+ if (action === 'keep-template-label') return 'retain-target-label';
478
+ if (action === 'select-template-option') return 'choice-selection';
479
+ throw new Error(`Unsupported migration target action: ${action}`);
480
+ }
481
+
482
+ function migrationQueryResult({ runtime, catalog, view, action, source, items, offset, total }) {
483
+ return {
484
+ tool: 'docx_query_migration_choices',
485
+ runtime,
486
+ sourceSha256: catalog.sourceSha256,
487
+ baselineSha256: catalog.baselineSha256,
488
+ view,
489
+ action,
490
+ source,
491
+ items,
492
+ page: {
493
+ offset,
494
+ returned: items.length,
495
+ total,
496
+ hasMore: offset + items.length < total,
497
+ },
498
+ };
499
+ }
500
+
312
501
  async function docxMigrateTemplate(args) {
313
502
  return runTemplateMigrationCommand('docx_migrate_template', 'migrate-template', args);
314
503
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiwater/office-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Published MCP server for Tiwater Office document capabilities",
5
5
  "type": "module",
6
6
  "license": "MIT",