@tiwater/office-mcp 0.8.0 → 0.10.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
@@ -31,8 +31,48 @@ 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.
32
32
  Large observations and exports are written to a caller-selected new JSON
33
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. Compatible targets stay pageable and are shown in current text and
38
- local document-context order; the tool does not choose the business mapping.
34
+
35
+ ## Template migration
36
+
37
+ Template migration separates business choice from document mechanics:
38
+
39
+ 1. `docx_list_migration_choices` records the complete current source and target
40
+ catalog in an opaque run-local artifact.
41
+ 2. `docx_query_migration_choices` pages source items and returns bounded,
42
+ document-compatible alternatives for one source item.
43
+ 3. `docx_migrate_template` accepts one complete batch and derives the plan,
44
+ edits, and readback receipt.
45
+ 4. `docx_verify_migration` independently verifies the output from the same
46
+ source, baseline, and batch.
47
+
48
+ The scenario supplies the business meaning. The query tool exposes three
49
+ orthogonal target actions:
50
+
51
+ - `place-content` moves current content into a target content position.
52
+ - `keep-template-label` keeps the target label and structure while migrating a
53
+ uniquely identified current field value.
54
+ - `select-template-option` marks a target option represented by the current
55
+ source fact.
56
+
57
+ Choose the action first, then query targets with that action filter. Returned
58
+ `alternativeRef` values bind the action and target together. Source exclusion
59
+ and genuine local review are target-free terminal choices. The caller never
60
+ supplies document text, selectors, coordinates, plans, or edit operations.
61
+
62
+ Template-migration choice artifacts are opaque evidence. List the choices once,
63
+ then query the same current source and baseline to page sources, request targets
64
+ for a source, or inspect cleanup targets. Target queries return complete
65
+ provider-compatible action-and-target alternatives under short catalog-bound
66
+ references. Submit selected alternatives and target-free terminal choices as
67
+ one batch, then verify the output independently from the same inputs and batch.
68
+ The tool does not choose the business mapping.
69
+
70
+ ## Version 0.10 migration
71
+
72
+ Version 0.10 replaces the 0.9 template-migration identity form. Targeted
73
+ choices now use one `alternativeRef` returned by
74
+ `docx_query_migration_choices`; terminal choices use `sourceRef` plus
75
+ `exclude-source` or `review-source`. The server rejects the old combination of
76
+ raw source id, action, and raw target id so an action cannot be paired with a
77
+ target from a different alternative. Other Office tools keep their existing
78
+ inputs and outputs.
package/office/index.mjs CHANGED
@@ -36,41 +36,28 @@ const convertCandidates = [
36
36
  ];
37
37
 
38
38
  const pathInput = z.string().trim().min(1);
39
- const migrationAction = z.enum([
39
+ const migrationQueryTargetAction = z.enum([
40
40
  'place-content',
41
- 'keep-template-content',
42
- 'keep-template-label',
43
- 'select-template-option',
44
- 'exclude-source',
45
- 'review-source',
46
- ]);
47
- const targetActions = new Set([
48
- 'place-content',
49
- 'keep-template-content',
50
41
  'keep-template-label',
51
42
  'select-template-option',
43
+ ]).describe('Business action: place-content moves the current source content into a target content position; keep-template-label preserves the target label and structure while migrating a uniquely identified current field value; select-template-option marks the target option represented by the current source fact.');
44
+ const targetActions = new Set(migrationQueryTargetAction.options);
45
+
46
+ const choiceReference = z.string().trim().regex(/^[ST][1-9][0-9]*-[0-9a-f]{8}$/);
47
+ const alternativeReference = z.string().trim().regex(/^S[1-9][0-9]*-[PLO][1-9][0-9]*-[0-9a-f]{8}$/);
48
+
49
+ const terminalMigrationChoiceInput = z.object({
50
+ sourceRef: choiceReference,
51
+ action: z.enum(['exclude-source', 'review-source']),
52
+ }).strict();
53
+
54
+ const migrationChoiceInput = z.union([
55
+ z.object({ alternativeRef: alternativeReference }).strict(),
56
+ terminalMigrationChoiceInput,
52
57
  ]);
53
- const terminalActions = new Set(['exclude-source', 'review-source']);
54
-
55
- const migrationChoiceInput = z.object({
56
- sourceChoiceId: z.string().trim().min(1),
57
- action: migrationAction,
58
- targetChoiceId: z.string().trim().min(1).optional(),
59
- cardinality: z.enum(['one', 'all']).optional(),
60
- }).strict().superRefine((choice, context) => {
61
- if (targetActions.has(choice.action) && !choice.targetChoiceId) {
62
- context.addIssue({ code: 'custom', path: ['targetChoiceId'], message: `${choice.action} requires targetChoiceId` });
63
- }
64
- if (terminalActions.has(choice.action) && choice.targetChoiceId) {
65
- context.addIssue({ code: 'custom', path: ['targetChoiceId'], message: `${choice.action} forbids targetChoiceId` });
66
- }
67
- if (choice.cardinality === 'all' && !terminalActions.has(choice.action)) {
68
- context.addIssue({ code: 'custom', path: ['cardinality'], message: 'cardinality all is limited to terminal actions' });
69
- }
70
- });
71
58
 
72
59
  const templateCleanupInput = z.object({
73
- targetChoiceId: z.string().trim().min(1),
60
+ targetRef: choiceReference,
74
61
  scope: z.enum(['cell', 'row']),
75
62
  }).strict();
76
63
 
@@ -79,7 +66,7 @@ const templateMigrationInput = z.object({
79
66
  baseline: pathInput.describe('Path to the selected current baseline DOCX.'),
80
67
  output: pathInput.describe('Path to the migrated output DOCX.'),
81
68
  receiptOutput: pathInput.describe('New JSON receipt artifact path. Existing files are never overwritten.'),
82
- choices: z.array(migrationChoiceInput).describe('Exactly one business choice for every source id: place-content writes the source fact; keep-template-content or keep-template-label preserves the matching baseline-owned content or label; select-template-option carries an option identity; exclude-source requires declared exclusion; review-source is limited to genuine local ambiguity.'),
69
+ choices: z.array(migrationChoiceInput).describe('Exactly one business choice for every source ref. Targeted choices use an alternativeRef returned by the query tool. Terminal exclusions and genuine local review use a sourceRef.'),
83
70
  templateCleanup: z.array(templateCleanupInput).optional().describe('Optional baseline-owned placeholders or example rows to clear.'),
84
71
  }).strict();
85
72
 
@@ -99,6 +86,16 @@ const migrationChoiceOutput = z.object({
99
86
  allowedActions: z.array(z.string()),
100
87
  }).strict();
101
88
 
89
+ const migrationPublicChoiceOutput = migrationChoiceOutput.omit({ id: true }).extend({
90
+ ref: choiceReference,
91
+ }).strict();
92
+
93
+ const migrationAlternativeOutput = z.object({
94
+ ref: alternativeReference,
95
+ action: migrationQueryTargetAction,
96
+ target: migrationPublicChoiceOutput,
97
+ }).strict();
98
+
102
99
  const migrationCatalog = z.object({
103
100
  schema: z.string(),
104
101
  pass: z.boolean(),
@@ -209,13 +206,6 @@ const migrationTargetPage = z.object({
209
206
  targets: z.array(migrationChoiceOutput),
210
207
  }).strict();
211
208
 
212
- const migrationTargetAction = z.enum([
213
- 'place-content',
214
- 'keep-template-content',
215
- 'keep-template-label',
216
- 'select-template-option',
217
- ]);
218
-
219
209
  const migrationQueryDocuments = {
220
210
  source: pathInput.describe('Path to the current source DOCX used by docx_list_migration_choices.'),
221
211
  baseline: pathInput.describe('Path to the same selected current baseline DOCX used by docx_list_migration_choices.'),
@@ -224,7 +214,7 @@ const migrationQueryDocuments = {
224
214
  const boundedOffset = z.number().int().nonnegative().optional().describe('Zero-based result offset. Defaults to 0.');
225
215
  const boundedLimit = z.number().int().min(1).max(10).optional().describe('Maximum results to return. Defaults to 10 and cannot exceed 10.');
226
216
 
227
- const migrationChoiceQueryInput = z.discriminatedUnion('view', [
217
+ const migrationChoiceQueryInput = z.union([
228
218
  z.object({
229
219
  ...migrationQueryDocuments,
230
220
  view: z.literal('sources'),
@@ -234,8 +224,8 @@ const migrationChoiceQueryInput = z.discriminatedUnion('view', [
234
224
  z.object({
235
225
  ...migrationQueryDocuments,
236
226
  view: z.literal('targets'),
237
- sourceChoiceId: z.string().trim().min(1).describe('Opaque current source id returned by the sources view.'),
238
- action: migrationTargetAction.describe('Business action: place-content writes the current source fact; keep-template-content keeps baseline-owned fixed content; keep-template-label keeps the baseline label for the same source meaning; select-template-option carries a selected source option into the matching baseline option.'),
227
+ sourceRef: choiceReference.describe('Short source reference returned by the sources view.'),
228
+ action: migrationQueryTargetAction.optional().describe('Optional business-action filter. Choose the action from scenario meaning, then use this filter to inspect only compatible targets. Omit it only when comparing the three documented action meanings.'),
239
229
  text: z.string().trim().min(1).optional().describe('Optional literal case-insensitive text to find in target visible text or context.'),
240
230
  offset: boundedOffset,
241
231
  limit: boundedLimit,
@@ -255,9 +245,9 @@ const migrationChoiceQueryOutput = z.object({
255
245
  sourceSha256: z.string(),
256
246
  baselineSha256: z.string(),
257
247
  view: z.enum(['sources', 'targets', 'cleanup']),
258
- action: migrationTargetAction.nullable(),
259
- source: migrationChoiceOutput.nullable(),
260
- items: z.array(migrationChoiceOutput),
248
+ action: migrationQueryTargetAction.nullable(),
249
+ source: migrationPublicChoiceOutput.nullable(),
250
+ items: z.array(z.union([migrationPublicChoiceOutput, migrationAlternativeOutput])),
261
251
  page: migrationQueryPage,
262
252
  }).strict();
263
253
 
@@ -305,7 +295,7 @@ const tools = [
305
295
  },
306
296
  {
307
297
  name: 'docx_query_migration_choices',
308
- 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. Target results are ordered by literal and local document-context relevance; that order helps discovery but does not make the business choice.',
298
+ description: 'Query current template-migration alternatives without reading the catalog artifact. place-content moves current content into a target content position; keep-template-label preserves the target label and structure while migrating a uniquely identified current field value; select-template-option marks a target option represented by the current source fact. Choose the action from scenario meaning and filter by it before paging compatible targets. Target results bind the action and target into one alternativeRef; result order helps discovery but never makes the business choice.',
309
299
  inputSchema: migrationChoiceQueryInput,
310
300
  outputSchema: migrationChoiceQueryOutput,
311
301
  annotations: { readOnlyHint: true, idempotentHint: true },
@@ -313,7 +303,7 @@ const tools = [
313
303
  },
314
304
  {
315
305
  name: 'docx_migrate_template',
316
- 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.',
306
+ description: 'Migrate a current DOCX into the selected baseline from one complete batch of business choices. For targeted choices, submit one alternativeRef returned by docx_query_migration_choices. Exclusion and local review use a sourceRef plus their terminal action. The tool derives all document values, coordinates, plans, and edits.',
317
307
  inputSchema: templateMigrationInput,
318
308
  outputSchema: migrationReceiptOutput('docx_migrate_template'),
319
309
  handler: docxMigrateTemplate,
@@ -466,57 +456,99 @@ async function docxQueryMigrationChoices(args) {
466
456
 
467
457
  let source = null;
468
458
  let action = null;
469
- let branch;
470
- let sourceChoiceId = '-';
471
459
  if (args.view === 'cleanup') {
472
- branch = 'baseline-clear';
473
- } else {
474
- source = catalog.sources.find(item => item.id === args.sourceChoiceId) ?? null;
475
- if (!source) {
476
- throw Object.assign(new Error(`Unknown sourceChoiceId: ${args.sourceChoiceId}`), { code: -32602 });
477
- }
478
- action = args.action;
479
- if (!source.allowedActions.includes(action)) {
480
- throw Object.assign(new Error(`Action ${action} is not allowed for ${source.id}`), { code: -32602 });
481
- }
482
- sourceChoiceId = source.id;
483
- branch = migrationTargetBranch(action, source.kind);
460
+ const targetResult = await loadMigrationTargets({
461
+ sourcePath,
462
+ baselinePath,
463
+ sourceChoiceId: '-',
464
+ branch: 'baseline-clear',
465
+ text: args.text,
466
+ });
467
+ const orderedTargets = validateAndOrderMigrationTargets(catalog, null, targetResult.targets);
468
+ return migrationQueryResult({
469
+ runtime: targetResult.runtime,
470
+ catalog,
471
+ view: 'cleanup',
472
+ action: null,
473
+ source: null,
474
+ items: orderedTargets.slice(offset, offset + limit),
475
+ offset,
476
+ total: orderedTargets.length,
477
+ });
484
478
  }
485
479
 
486
- const targetResult = await loadMigrationTargets({
487
- sourcePath,
488
- baselinePath,
489
- sourceChoiceId,
490
- branch,
491
- text: args.text,
480
+ const requestedSourceId = choiceIdFromRef(catalog, catalog.sources, args.sourceRef, 'S', 'source');
481
+ source = catalog.sources.find(item => item.id === requestedSourceId) ?? null;
482
+ if (!source) {
483
+ throw Object.assign(new Error(`Unknown migration source: ${args.sourceRef}`), { code: -32602 });
484
+ }
485
+ action = args.action ?? null;
486
+ const actions = action
487
+ ? [action]
488
+ : [...targetActions].filter(candidate => source.allowedActions.includes(candidate));
489
+ const pages = await Promise.all(actions.map(async candidate => {
490
+ if (!source.allowedActions.includes(candidate)) {
491
+ throw Object.assign(new Error(`Action ${candidate} is not allowed for ${source.id}`), { code: -32602 });
492
+ }
493
+ const result = await loadMigrationTargets({
494
+ sourcePath,
495
+ baselinePath,
496
+ sourceChoiceId: source.id,
497
+ branch: migrationTargetBranch(candidate, source.kind),
498
+ text: args.text,
499
+ });
500
+ return {
501
+ action: candidate,
502
+ runtime: result.runtime,
503
+ targets: validateAndOrderMigrationTargets(catalog, source, result.targets),
504
+ };
505
+ }));
506
+ const catalogOrder = new Map(catalog.targets.map((item, index) => [item.id, index]));
507
+ const actionOrder = new Map([...targetActions].map((item, index) => [item, index]));
508
+ const alternatives = pages.flatMap(page => page.targets.map(target => ({ action: page.action, target })))
509
+ .sort((left, right) => {
510
+ const relevance = compareRelevance(
511
+ migrationChoiceRelevance(source, right.target),
512
+ migrationChoiceRelevance(source, left.target));
513
+ return relevance
514
+ || catalogOrder.get(left.target.id) - catalogOrder.get(right.target.id)
515
+ || actionOrder.get(left.action) - actionOrder.get(right.action);
516
+ })
517
+ .map(item => ({
518
+ ref: migrationAlternativeRef(catalog, source, item.action, item.target),
519
+ action: item.action,
520
+ target: publicMigrationChoice(catalog, item.target, 'T'),
521
+ }));
522
+ return migrationQueryResult({
523
+ runtime: pages[0]?.runtime ?? commandRuntime(catalogResult),
524
+ catalog,
525
+ view: 'targets',
526
+ action,
527
+ source,
528
+ items: alternatives.slice(offset, offset + limit),
529
+ offset,
530
+ total: alternatives.length,
531
+ alternatives: true,
492
532
  });
533
+ }
534
+
535
+ function validateAndOrderMigrationTargets(catalog, source, targets) {
493
536
  const catalogTargets = new Map(catalog.targets.map(item => [item.id, item]));
494
- for (const target of targetResult.targets) {
537
+ for (const target of targets) {
495
538
  const current = catalogTargets.get(target.id);
496
539
  if (!current || !isDeepStrictEqual(current, target)) {
497
540
  throw new Error(`Migration target ${target.id} is not bound to the current catalog`);
498
541
  }
499
542
  }
500
543
  const catalogOrder = new Map(catalog.targets.map((item, index) => [item.id, index]));
501
- const orderedTargets = source
502
- ? [...targetResult.targets].sort((left, right) => {
544
+ return source
545
+ ? [...targets].sort((left, right) => {
503
546
  const relevance = compareRelevance(
504
547
  migrationChoiceRelevance(source, right),
505
548
  migrationChoiceRelevance(source, left));
506
549
  return relevance || catalogOrder.get(left.id) - catalogOrder.get(right.id);
507
550
  })
508
- : [...targetResult.targets].sort((left, right) => catalogOrder.get(left.id) - catalogOrder.get(right.id));
509
- const items = orderedTargets.slice(offset, offset + limit);
510
- return migrationQueryResult({
511
- runtime: targetResult.runtime,
512
- catalog,
513
- view: args.view,
514
- action,
515
- source,
516
- items,
517
- offset,
518
- total: orderedTargets.length,
519
- });
551
+ : [...targets].sort((left, right) => catalogOrder.get(left.id) - catalogOrder.get(right.id));
520
552
  }
521
553
 
522
554
  async function loadMigrationTargets({ sourcePath, baselinePath, sourceChoiceId, branch, text }) {
@@ -632,7 +664,82 @@ function migrationTargetBranch(action, sourceKind) {
632
664
  throw new Error(`Unsupported migration target action: ${action}`);
633
665
  }
634
666
 
635
- function migrationQueryResult({ runtime, catalog, view, action, source, items, offset, total }) {
667
+ function catalogReferenceToken(catalog) {
668
+ return createHash('sha256')
669
+ .update(`${catalog.sourceSha256}\n${catalog.baselineSha256}`)
670
+ .digest('hex')
671
+ .slice(0, 8);
672
+ }
673
+
674
+ function choiceRef(catalog, items, id, prefix) {
675
+ const index = items.findIndex(item => item.id === id);
676
+ if (index < 0) throw new Error(`Migration choice is not bound to the current catalog: ${id}`);
677
+ return `${prefix}${index + 1}-${catalogReferenceToken(catalog)}`;
678
+ }
679
+
680
+ function choiceIdFromRef(catalog, items, ref, prefix, label) {
681
+ if (!ref.startsWith(prefix)) {
682
+ throw invalidMigrationInput(`invalid migration ${label} ref: ${ref}`);
683
+ }
684
+ const [ordinal, token] = ref.slice(1).split('-');
685
+ if (token !== catalogReferenceToken(catalog)) {
686
+ throw invalidMigrationInput(`stale migration ${label} ref: ${ref}`);
687
+ }
688
+ const item = items[Number(ordinal) - 1];
689
+ if (!item) throw invalidMigrationInput(`unknown migration ${label} ref: ${ref}`);
690
+ return item.id;
691
+ }
692
+
693
+ const migrationActionCodes = new Map([
694
+ ['place-content', 'P'],
695
+ ['keep-template-label', 'L'],
696
+ ['select-template-option', 'O'],
697
+ ]);
698
+ const migrationActionsByCode = new Map([...migrationActionCodes].map(([action, code]) => [code, action]));
699
+
700
+ function migrationAlternativeRef(catalog, source, action, target) {
701
+ const sourceOrdinal = catalog.sources.findIndex(item => item.id === source.id) + 1;
702
+ const targetOrdinal = catalog.targets.findIndex(item => item.id === target.id) + 1;
703
+ const code = migrationActionCodes.get(action);
704
+ if (sourceOrdinal < 1 || targetOrdinal < 1 || !code) {
705
+ throw new Error('Migration alternative is not bound to the current catalog');
706
+ }
707
+ return `S${sourceOrdinal}-${code}${targetOrdinal}-${migrationAlternativeToken(catalog, source, action, target)}`;
708
+ }
709
+
710
+ function migrationAlternativeToken(catalog, source, action, target) {
711
+ return createHash('sha256')
712
+ .update(`${catalog.sourceSha256}\n${catalog.baselineSha256}\n${source.id}\n${action}\n${target.id}`)
713
+ .digest('hex')
714
+ .slice(0, 8);
715
+ }
716
+
717
+ function migrationChoiceFromAlternativeRef(catalog, ref) {
718
+ const match = /^S([1-9][0-9]*)-([PLO])([1-9][0-9]*)-([0-9a-f]{8})$/.exec(ref);
719
+ if (!match) throw invalidMigrationInput(`invalid migration alternative ref: ${ref}`);
720
+ const source = catalog.sources[Number(match[1]) - 1];
721
+ const target = catalog.targets[Number(match[3]) - 1];
722
+ const action = migrationActionsByCode.get(match[2]);
723
+ if (!source || !target || !action) {
724
+ throw invalidMigrationInput(`unknown migration alternative ref: ${ref}`);
725
+ }
726
+ if (match[4] !== migrationAlternativeToken(catalog, source, action, target)) {
727
+ throw invalidMigrationInput(`stale or invalid migration alternative ref: ${ref}`);
728
+ }
729
+ return { sourceChoiceId: source.id, action, targetChoiceId: target.id };
730
+ }
731
+
732
+ function publicMigrationChoice(catalog, choice, prefix) {
733
+ const { id, ...visible } = choice;
734
+ const items = prefix === 'S' ? catalog.sources : catalog.targets;
735
+ return {
736
+ ...visible,
737
+ allowedActions: visible.allowedActions.filter(action => action !== 'keep-template-content'),
738
+ ref: choiceRef(catalog, items, id, prefix),
739
+ };
740
+ }
741
+
742
+ function migrationQueryResult({ runtime, catalog, view, action, source, items, offset, total, alternatives = false }) {
636
743
  return {
637
744
  tool: 'docx_query_migration_choices',
638
745
  runtime,
@@ -640,8 +747,10 @@ function migrationQueryResult({ runtime, catalog, view, action, source, items, o
640
747
  baselineSha256: catalog.baselineSha256,
641
748
  view,
642
749
  action,
643
- source,
644
- items,
750
+ source: source ? publicMigrationChoice(catalog, source, 'S') : null,
751
+ items: alternatives
752
+ ? items
753
+ : items.map(item => publicMigrationChoice(catalog, item, view === 'sources' ? 'S' : 'T')),
645
754
  page: {
646
755
  offset,
647
756
  returned: items.length,
@@ -659,6 +768,53 @@ async function docxVerifyMigration(args) {
659
768
  return runTemplateMigrationCommand('docx_verify_migration', 'verify-template-migration', args);
660
769
  }
661
770
 
771
+ function invalidMigrationInput(message) {
772
+ return Object.assign(new Error(message), { code: -32602 });
773
+ }
774
+
775
+ function completeMigrationChoices(catalog, choices) {
776
+ const sources = new Map(catalog.sources.map(source => [source.id, source]));
777
+ const seen = new Set();
778
+ const completed = choices.map(rawChoice => {
779
+ const choice = rawChoice.alternativeRef
780
+ ? migrationChoiceFromAlternativeRef(catalog, rawChoice.alternativeRef)
781
+ : {
782
+ sourceChoiceId: choiceIdFromRef(catalog, catalog.sources, rawChoice.sourceRef, 'S', 'source'),
783
+ action: rawChoice.action,
784
+ };
785
+ const { sourceChoiceId } = choice;
786
+ const source = sources.get(sourceChoiceId);
787
+ if (!source) throw invalidMigrationInput(`unknown migration source id: ${sourceChoiceId}`);
788
+ if (seen.has(sourceChoiceId)) throw invalidMigrationInput(`duplicate migration source id: ${sourceChoiceId}`);
789
+ seen.add(sourceChoiceId);
790
+ if (!source.allowedActions.includes(choice.action)) {
791
+ throw invalidMigrationInput(`migration action ${choice.action} is not allowed for source id: ${sourceChoiceId}`);
792
+ }
793
+ return source.requiredCardinality === 'all'
794
+ ? { ...choice, cardinality: 'all' }
795
+ : choice;
796
+ });
797
+ const missing = [...sources.keys()].filter(sourceChoiceId => !seen.has(sourceChoiceId));
798
+ if (missing.length > 0) {
799
+ throw invalidMigrationInput(`migration choices must cover every source id; missing ${missing.length}`);
800
+ }
801
+ return completed;
802
+ }
803
+
804
+ function completeTemplateCleanup(catalog, cleanup, choices) {
805
+ const targets = new Map(catalog.targets.map(target => [target.id, target]));
806
+ const claimedTargets = new Set(choices.flatMap(choice => choice.targetChoiceId ? [choice.targetChoiceId] : []));
807
+ const seen = new Set();
808
+ return cleanup.map(rawCleanup => {
809
+ const targetChoiceId = choiceIdFromRef(catalog, catalog.targets, rawCleanup.targetRef, 'T', 'target');
810
+ if (!seen.add(targetChoiceId)) throw invalidMigrationInput(`duplicate migration cleanup target id: ${targetChoiceId}`);
811
+ if (!targets.get(targetChoiceId)?.allowedActions.includes('template-cleanup')) {
812
+ throw invalidMigrationInput(`migration cleanup is not allowed for target id: ${targetChoiceId}`);
813
+ }
814
+ return { targetChoiceId, scope: rawCleanup.scope };
815
+ }).filter(cleanupChoice => !claimedTargets.has(cleanupChoice.targetChoiceId));
816
+ }
817
+
662
818
  async function runTemplateMigrationCommand(tool, command, args) {
663
819
  const source = requireString(args.source, 'source');
664
820
  const baseline = requireString(args.baseline, 'baseline');
@@ -666,16 +822,26 @@ async function runTemplateMigrationCommand(tool, command, args) {
666
822
  if (!Array.isArray(args.choices)) {
667
823
  throw Object.assign(new Error('choices must be an array'), { code: -32602 });
668
824
  }
825
+ const catalogResult = await runJsonCandidateChain(docxCandidates, ['list-template-migration-choices', source, baseline]);
826
+ const catalog = migrationCatalog.parse(catalogResult.json);
827
+ const choices = completeMigrationChoices(catalog, args.choices);
828
+ const templateCleanup = Array.isArray(args.templateCleanup)
829
+ ? completeTemplateCleanup(catalog, args.templateCleanup, choices)
830
+ : [];
669
831
  const payload = {
670
832
  schema: 'tiwater.docx.template-migration-business-choices/v1',
671
- choices: args.choices,
672
- ...(Array.isArray(args.templateCleanup) ? { templateCleanup: args.templateCleanup } : {}),
833
+ choices,
834
+ ...(templateCleanup.length > 0 ? { templateCleanup } : {}),
673
835
  };
674
836
  return withTempJsonFile(payload, async choicesPath => {
675
837
  const result = await runJsonCandidateChain(
676
838
  docxCandidates,
677
839
  [command, source, baseline, choicesPath, output],
678
840
  { allowedExitCodes: [0, 1] });
841
+ if (result.json === null) {
842
+ const detail = result.stderr.trim() || result.stdout.trim() || 'no diagnostic output';
843
+ throw new Error(`${result.command} ${command} returned no JSON receipt (exit ${result.code}): ${detail}`);
844
+ }
679
845
  const receipt = migrationReceipt.parse(result.json);
680
846
  return {
681
847
  tool,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiwater/office-mcp",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Published MCP server for Tiwater Office document capabilities",
5
5
  "type": "module",
6
6
  "license": "MIT",