@tiwater/office-mcp 0.5.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
@@ -31,3 +31,7 @@ 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.
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';
@@ -156,29 +157,64 @@ const migrationQueryPage = z.object({
156
157
  hasMore: z.boolean(),
157
158
  }).strict();
158
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
+
159
186
  const migrationChoiceQueryInput = z.discriminatedUnion('view', [
160
187
  z.object({
161
- catalog: pathInput.describe('Path returned by docx_list_migration_choices.'),
188
+ ...migrationQueryDocuments,
162
189
  view: z.literal('sources'),
163
- offset: z.number().int().nonnegative().optional(),
164
- limit: z.number().int().min(1).max(10).optional(),
190
+ offset: boundedOffset,
191
+ limit: boundedLimit,
165
192
  }).strict(),
166
193
  z.object({
167
- catalog: pathInput.describe('Path returned by docx_list_migration_choices.'),
194
+ ...migrationQueryDocuments,
168
195
  view: z.literal('targets'),
169
- sourceChoiceId: z.string().trim().min(1),
170
- text: z.string().trim().min(1).optional().describe('Literal case-insensitive text to find in target text or visible context.'),
171
- kinds: z.array(z.string().trim().min(1)).min(1).optional(),
172
- scopes: z.array(z.string().trim().min(1)).min(1).optional(),
173
- offset: z.number().int().nonnegative().optional(),
174
- limit: z.number().int().min(1).max(10).optional(),
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,
175
208
  }).strict(),
176
209
  ]);
177
210
 
178
211
  const migrationChoiceQueryOutput = z.object({
179
212
  tool: z.literal('docx_query_migration_choices'),
180
- catalogSha256: z.string().regex(/^[0-9a-f]{64}$/),
181
- view: z.enum(['sources', 'targets']),
213
+ runtime: runtimeIdentity,
214
+ sourceSha256: z.string(),
215
+ baselineSha256: z.string(),
216
+ view: z.enum(['sources', 'targets', 'cleanup']),
217
+ action: migrationTargetAction.nullable(),
182
218
  source: migrationChoiceOutput.nullable(),
183
219
  items: z.array(migrationChoiceOutput),
184
220
  page: migrationQueryPage,
@@ -217,7 +253,7 @@ const tools = [
217
253
  },
218
254
  {
219
255
  name: 'docx_list_migration_choices',
220
- 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.',
221
257
  inputSchema: z.object({
222
258
  source: pathInput.describe('Path to the current source DOCX.'),
223
259
  baseline: pathInput.describe('Path to the selected current baseline DOCX.'),
@@ -228,7 +264,7 @@ const tools = [
228
264
  },
229
265
  {
230
266
  name: 'docx_query_migration_choices',
231
- description: 'Read one bounded page from a migration-choice catalog. List source choices, or inspect targets for one source using literal text, kind, and scope filters. This tool does not recommend or make a business choice.',
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.',
232
268
  inputSchema: migrationChoiceQueryInput,
233
269
  outputSchema: migrationChoiceQueryOutput,
234
270
  annotations: { readOnlyHint: true, idempotentHint: true },
@@ -363,61 +399,105 @@ async function docxListMigrationChoices(args) {
363
399
  }
364
400
 
365
401
  async function docxQueryMigrationChoices(args) {
366
- const catalogPath = requireString(args.catalog, 'catalog');
367
- const bytes = await readFile(catalogPath);
368
- const catalog = migrationCatalog.parse(JSON.parse(bytes.toString('utf8')));
402
+ const sourcePath = requireString(args.source, 'source');
403
+ const baselinePath = requireString(args.baseline, 'baseline');
369
404
  const offset = args.offset ?? 0;
370
405
  const limit = args.limit ?? 10;
371
- let source = null;
372
- let matches;
406
+ const catalogResult = await runJsonCandidateChain(docxCandidates, ['list-template-migration-choices', sourcePath, baselinePath]);
407
+ const catalog = migrationCatalog.parse(catalogResult.json);
373
408
 
374
409
  if (args.view === 'sources') {
375
- matches = catalog.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';
376
428
  } else {
377
429
  source = catalog.sources.find(item => item.id === args.sourceChoiceId) ?? null;
378
430
  if (!source) {
379
431
  throw Object.assign(new Error(`Unknown sourceChoiceId: ${args.sourceChoiceId}`), { code: -32602 });
380
432
  }
381
- const kinds = args.kinds ? new Set(args.kinds) : null;
382
- const scopes = args.scopes ? new Set(args.scopes) : null;
383
- const textQuery = args.text?.toLocaleLowerCase();
384
- matches = catalog.targets.filter(item =>
385
- (!kinds || kinds.has(item.kind)) &&
386
- (!scopes || scopes.has(item.scope)) &&
387
- (!textQuery || migrationChoiceSearchText(item).includes(textQuery)));
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);
388
439
  }
389
440
 
390
- const items = matches.slice(offset, offset + limit);
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 }) {
391
483
  return {
392
484
  tool: 'docx_query_migration_choices',
393
- catalogSha256: createHash('sha256').update(bytes).digest('hex'),
394
- view: args.view,
485
+ runtime,
486
+ sourceSha256: catalog.sourceSha256,
487
+ baselineSha256: catalog.baselineSha256,
488
+ view,
489
+ action,
395
490
  source,
396
491
  items,
397
492
  page: {
398
493
  offset,
399
494
  returned: items.length,
400
- total: matches.length,
401
- hasMore: offset + items.length < matches.length,
495
+ total,
496
+ hasMore: offset + items.length < total,
402
497
  },
403
498
  };
404
499
  }
405
500
 
406
- function migrationChoiceSearchText(choice) {
407
- return collectVisibleStrings({ text: choice.text, context: choice.context })
408
- .join('\n')
409
- .toLocaleLowerCase();
410
- }
411
-
412
- function collectVisibleStrings(value, fieldName = '') {
413
- if (typeof value === 'string') return /text/i.test(fieldName) ? [value] : [];
414
- if (Array.isArray(value)) return value.flatMap(item => collectVisibleStrings(item, fieldName));
415
- if (value && typeof value === 'object') {
416
- return Object.entries(value).flatMap(([key, item]) => collectVisibleStrings(item, key));
417
- }
418
- return [];
419
- }
420
-
421
501
  async function docxMigrateTemplate(args) {
422
502
  return runTemplateMigrationCommand('docx_migrate_template', 'migrate-template', args);
423
503
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiwater/office-mcp",
3
- "version": "0.5.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",