@tiwater/office-mcp 0.9.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,37 +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
- }).strict().superRefine((choice, context) => {
60
- if (targetActions.has(choice.action) && !choice.targetChoiceId) {
61
- context.addIssue({ code: 'custom', path: ['targetChoiceId'], message: `${choice.action} requires targetChoiceId` });
62
- }
63
- if (terminalActions.has(choice.action) && choice.targetChoiceId) {
64
- context.addIssue({ code: 'custom', path: ['targetChoiceId'], message: `${choice.action} forbids targetChoiceId` });
65
- }
66
- });
67
58
 
68
59
  const templateCleanupInput = z.object({
69
- targetChoiceId: z.string().trim().min(1),
60
+ targetRef: choiceReference,
70
61
  scope: z.enum(['cell', 'row']),
71
62
  }).strict();
72
63
 
@@ -75,7 +66,7 @@ const templateMigrationInput = z.object({
75
66
  baseline: pathInput.describe('Path to the selected current baseline DOCX.'),
76
67
  output: pathInput.describe('Path to the migrated output DOCX.'),
77
68
  receiptOutput: pathInput.describe('New JSON receipt artifact path. Existing files are never overwritten.'),
78
- 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.'),
79
70
  templateCleanup: z.array(templateCleanupInput).optional().describe('Optional baseline-owned placeholders or example rows to clear.'),
80
71
  }).strict();
81
72
 
@@ -95,6 +86,16 @@ const migrationChoiceOutput = z.object({
95
86
  allowedActions: z.array(z.string()),
96
87
  }).strict();
97
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
+
98
99
  const migrationCatalog = z.object({
99
100
  schema: z.string(),
100
101
  pass: z.boolean(),
@@ -205,13 +206,6 @@ const migrationTargetPage = z.object({
205
206
  targets: z.array(migrationChoiceOutput),
206
207
  }).strict();
207
208
 
208
- const migrationTargetAction = z.enum([
209
- 'place-content',
210
- 'keep-template-content',
211
- 'keep-template-label',
212
- 'select-template-option',
213
- ]);
214
-
215
209
  const migrationQueryDocuments = {
216
210
  source: pathInput.describe('Path to the current source DOCX used by docx_list_migration_choices.'),
217
211
  baseline: pathInput.describe('Path to the same selected current baseline DOCX used by docx_list_migration_choices.'),
@@ -220,7 +214,7 @@ const migrationQueryDocuments = {
220
214
  const boundedOffset = z.number().int().nonnegative().optional().describe('Zero-based result offset. Defaults to 0.');
221
215
  const boundedLimit = z.number().int().min(1).max(10).optional().describe('Maximum results to return. Defaults to 10 and cannot exceed 10.');
222
216
 
223
- const migrationChoiceQueryInput = z.discriminatedUnion('view', [
217
+ const migrationChoiceQueryInput = z.union([
224
218
  z.object({
225
219
  ...migrationQueryDocuments,
226
220
  view: z.literal('sources'),
@@ -230,8 +224,8 @@ const migrationChoiceQueryInput = z.discriminatedUnion('view', [
230
224
  z.object({
231
225
  ...migrationQueryDocuments,
232
226
  view: z.literal('targets'),
233
- sourceChoiceId: z.string().trim().min(1).describe('Opaque current source id returned by the sources view.'),
234
- 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.'),
235
229
  text: z.string().trim().min(1).optional().describe('Optional literal case-insensitive text to find in target visible text or context.'),
236
230
  offset: boundedOffset,
237
231
  limit: boundedLimit,
@@ -251,9 +245,9 @@ const migrationChoiceQueryOutput = z.object({
251
245
  sourceSha256: z.string(),
252
246
  baselineSha256: z.string(),
253
247
  view: z.enum(['sources', 'targets', 'cleanup']),
254
- action: migrationTargetAction.nullable(),
255
- source: migrationChoiceOutput.nullable(),
256
- items: z.array(migrationChoiceOutput),
248
+ action: migrationQueryTargetAction.nullable(),
249
+ source: migrationPublicChoiceOutput.nullable(),
250
+ items: z.array(z.union([migrationPublicChoiceOutput, migrationAlternativeOutput])),
257
251
  page: migrationQueryPage,
258
252
  }).strict();
259
253
 
@@ -301,7 +295,7 @@ const tools = [
301
295
  },
302
296
  {
303
297
  name: 'docx_query_migration_choices',
304
- 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.',
305
299
  inputSchema: migrationChoiceQueryInput,
306
300
  outputSchema: migrationChoiceQueryOutput,
307
301
  annotations: { readOnlyHint: true, idempotentHint: true },
@@ -309,7 +303,7 @@ const tools = [
309
303
  },
310
304
  {
311
305
  name: 'docx_migrate_template',
312
- 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.',
313
307
  inputSchema: templateMigrationInput,
314
308
  outputSchema: migrationReceiptOutput('docx_migrate_template'),
315
309
  handler: docxMigrateTemplate,
@@ -462,57 +456,99 @@ async function docxQueryMigrationChoices(args) {
462
456
 
463
457
  let source = null;
464
458
  let action = null;
465
- let branch;
466
- let sourceChoiceId = '-';
467
459
  if (args.view === 'cleanup') {
468
- branch = 'baseline-clear';
469
- } else {
470
- source = catalog.sources.find(item => item.id === args.sourceChoiceId) ?? null;
471
- if (!source) {
472
- throw Object.assign(new Error(`Unknown sourceChoiceId: ${args.sourceChoiceId}`), { code: -32602 });
473
- }
474
- action = args.action;
475
- if (!source.allowedActions.includes(action)) {
476
- throw Object.assign(new Error(`Action ${action} is not allowed for ${source.id}`), { code: -32602 });
477
- }
478
- sourceChoiceId = source.id;
479
- 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
+ });
480
478
  }
481
479
 
482
- const targetResult = await loadMigrationTargets({
483
- sourcePath,
484
- baselinePath,
485
- sourceChoiceId,
486
- branch,
487
- 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,
488
532
  });
533
+ }
534
+
535
+ function validateAndOrderMigrationTargets(catalog, source, targets) {
489
536
  const catalogTargets = new Map(catalog.targets.map(item => [item.id, item]));
490
- for (const target of targetResult.targets) {
537
+ for (const target of targets) {
491
538
  const current = catalogTargets.get(target.id);
492
539
  if (!current || !isDeepStrictEqual(current, target)) {
493
540
  throw new Error(`Migration target ${target.id} is not bound to the current catalog`);
494
541
  }
495
542
  }
496
543
  const catalogOrder = new Map(catalog.targets.map((item, index) => [item.id, index]));
497
- const orderedTargets = source
498
- ? [...targetResult.targets].sort((left, right) => {
544
+ return source
545
+ ? [...targets].sort((left, right) => {
499
546
  const relevance = compareRelevance(
500
547
  migrationChoiceRelevance(source, right),
501
548
  migrationChoiceRelevance(source, left));
502
549
  return relevance || catalogOrder.get(left.id) - catalogOrder.get(right.id);
503
550
  })
504
- : [...targetResult.targets].sort((left, right) => catalogOrder.get(left.id) - catalogOrder.get(right.id));
505
- const items = orderedTargets.slice(offset, offset + limit);
506
- return migrationQueryResult({
507
- runtime: targetResult.runtime,
508
- catalog,
509
- view: args.view,
510
- action,
511
- source,
512
- items,
513
- offset,
514
- total: orderedTargets.length,
515
- });
551
+ : [...targets].sort((left, right) => catalogOrder.get(left.id) - catalogOrder.get(right.id));
516
552
  }
517
553
 
518
554
  async function loadMigrationTargets({ sourcePath, baselinePath, sourceChoiceId, branch, text }) {
@@ -628,7 +664,82 @@ function migrationTargetBranch(action, sourceKind) {
628
664
  throw new Error(`Unsupported migration target action: ${action}`);
629
665
  }
630
666
 
631
- 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 }) {
632
743
  return {
633
744
  tool: 'docx_query_migration_choices',
634
745
  runtime,
@@ -636,8 +747,10 @@ function migrationQueryResult({ runtime, catalog, view, action, source, items, o
636
747
  baselineSha256: catalog.baselineSha256,
637
748
  view,
638
749
  action,
639
- source,
640
- 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')),
641
754
  page: {
642
755
  offset,
643
756
  returned: items.length,
@@ -662,13 +775,20 @@ function invalidMigrationInput(message) {
662
775
  function completeMigrationChoices(catalog, choices) {
663
776
  const sources = new Map(catalog.sources.map(source => [source.id, source]));
664
777
  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);
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);
670
790
  if (!source.allowedActions.includes(choice.action)) {
671
- throw invalidMigrationInput(`migration action ${choice.action} is not allowed for source id: ${choice.sourceChoiceId}`);
791
+ throw invalidMigrationInput(`migration action ${choice.action} is not allowed for source id: ${sourceChoiceId}`);
672
792
  }
673
793
  return source.requiredCardinality === 'all'
674
794
  ? { ...choice, cardinality: 'all' }
@@ -681,6 +801,20 @@ function completeMigrationChoices(catalog, choices) {
681
801
  return completed;
682
802
  }
683
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
+
684
818
  async function runTemplateMigrationCommand(tool, command, args) {
685
819
  const source = requireString(args.source, 'source');
686
820
  const baseline = requireString(args.baseline, 'baseline');
@@ -690,10 +824,14 @@ async function runTemplateMigrationCommand(tool, command, args) {
690
824
  }
691
825
  const catalogResult = await runJsonCandidateChain(docxCandidates, ['list-template-migration-choices', source, baseline]);
692
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
+ : [];
693
831
  const payload = {
694
832
  schema: 'tiwater.docx.template-migration-business-choices/v1',
695
- choices: completeMigrationChoices(catalog, args.choices),
696
- ...(Array.isArray(args.templateCleanup) ? { templateCleanup: args.templateCleanup } : {}),
833
+ choices,
834
+ ...(templateCleanup.length > 0 ? { templateCleanup } : {}),
697
835
  };
698
836
  return withTempJsonFile(payload, async choicesPath => {
699
837
  const result = await runJsonCandidateChain(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiwater/office-mcp",
3
- "version": "0.9.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",