@tiwater/office-mcp 0.5.0 → 0.7.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 +5 -0
- package/office/index.mjs +236 -48
- package/package.json +1 -1
package/office/README.md
CHANGED
|
@@ -31,3 +31,8 @@ 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.
|
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';
|
|
@@ -73,7 +74,7 @@ const templateMigrationInput = z.object({
|
|
|
73
74
|
baseline: pathInput.describe('Path to the selected current baseline DOCX.'),
|
|
74
75
|
output: pathInput.describe('Path to the migrated output DOCX.'),
|
|
75
76
|
receiptOutput: pathInput.describe('New JSON receipt artifact path. Existing files are never overwritten.'),
|
|
76
|
-
choices: z.array(migrationChoiceInput).describe('Exactly one business choice for every source id
|
|
77
|
+
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.'),
|
|
77
78
|
templateCleanup: z.array(templateCleanupInput).optional().describe('Optional baseline-owned placeholders or example rows to clear.'),
|
|
78
79
|
}).strict();
|
|
79
80
|
|
|
@@ -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
|
-
|
|
188
|
+
...migrationQueryDocuments,
|
|
162
189
|
view: z.literal('sources'),
|
|
163
|
-
offset:
|
|
164
|
-
limit:
|
|
190
|
+
offset: boundedOffset,
|
|
191
|
+
limit: boundedLimit,
|
|
165
192
|
}).strict(),
|
|
166
193
|
z.object({
|
|
167
|
-
|
|
194
|
+
...migrationQueryDocuments,
|
|
168
195
|
view: z.literal('targets'),
|
|
169
|
-
sourceChoiceId: z.string().trim().min(1),
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
196
|
+
sourceChoiceId: z.string().trim().min(1).describe('Opaque current source id returned by the sources view.'),
|
|
197
|
+
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.'),
|
|
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
|
-
|
|
181
|
-
|
|
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
|
|
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: '
|
|
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. Target results are ordered by literal and local document-context relevance; that order helps discovery but does not make the business choice.',
|
|
232
268
|
inputSchema: migrationChoiceQueryInput,
|
|
233
269
|
outputSchema: migrationChoiceQueryOutput,
|
|
234
270
|
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
@@ -363,61 +399,213 @@ async function docxListMigrationChoices(args) {
|
|
|
363
399
|
}
|
|
364
400
|
|
|
365
401
|
async function docxQueryMigrationChoices(args) {
|
|
366
|
-
const
|
|
367
|
-
const
|
|
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
|
-
|
|
372
|
-
|
|
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
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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 loadMigrationTargets({
|
|
442
|
+
sourcePath,
|
|
443
|
+
baselinePath,
|
|
444
|
+
sourceChoiceId,
|
|
445
|
+
branch,
|
|
446
|
+
text: args.text,
|
|
447
|
+
});
|
|
448
|
+
const catalogTargets = new Map(catalog.targets.map(item => [item.id, item]));
|
|
449
|
+
for (const target of targetResult.targets) {
|
|
450
|
+
const current = catalogTargets.get(target.id);
|
|
451
|
+
if (!current || !isDeepStrictEqual(current, target)) {
|
|
452
|
+
throw new Error(`Migration target ${target.id} is not bound to the current catalog`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
const catalogOrder = new Map(catalog.targets.map((item, index) => [item.id, index]));
|
|
456
|
+
const orderedTargets = source
|
|
457
|
+
? [...targetResult.targets].sort((left, right) => {
|
|
458
|
+
const relevance = compareRelevance(
|
|
459
|
+
migrationChoiceRelevance(source, right),
|
|
460
|
+
migrationChoiceRelevance(source, left));
|
|
461
|
+
return relevance || catalogOrder.get(left.id) - catalogOrder.get(right.id);
|
|
462
|
+
})
|
|
463
|
+
: [...targetResult.targets].sort((left, right) => catalogOrder.get(left.id) - catalogOrder.get(right.id));
|
|
464
|
+
const items = orderedTargets.slice(offset, offset + limit);
|
|
465
|
+
return migrationQueryResult({
|
|
466
|
+
runtime: targetResult.runtime,
|
|
467
|
+
catalog,
|
|
468
|
+
view: args.view,
|
|
469
|
+
action,
|
|
470
|
+
source,
|
|
471
|
+
items,
|
|
472
|
+
offset,
|
|
473
|
+
total: orderedTargets.length,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function loadMigrationTargets({ sourcePath, baselinePath, sourceChoiceId, branch, text }) {
|
|
478
|
+
const targets = [];
|
|
479
|
+
let offset = 0;
|
|
480
|
+
let total = null;
|
|
481
|
+
let runtime = null;
|
|
482
|
+
do {
|
|
483
|
+
const result = await runJsonCandidateChain(docxCandidates, [
|
|
484
|
+
'find-template-migration-targets',
|
|
485
|
+
sourcePath,
|
|
486
|
+
baselinePath,
|
|
487
|
+
sourceChoiceId,
|
|
488
|
+
branch,
|
|
489
|
+
text ?? '-',
|
|
490
|
+
String(offset),
|
|
491
|
+
'100',
|
|
492
|
+
]);
|
|
493
|
+
const page = migrationTargetPage.parse(result.json);
|
|
494
|
+
if (page.sourceChoiceId !== (sourceChoiceId === '-' ? null : sourceChoiceId) || page.branch !== branch) {
|
|
495
|
+
throw new Error('Migration target page identity does not match the requested current source and action');
|
|
496
|
+
}
|
|
497
|
+
if (total !== null && page.total !== total) {
|
|
498
|
+
throw new Error('Migration target page total changed while reading current alternatives');
|
|
499
|
+
}
|
|
500
|
+
if (page.offset !== offset || page.targets.length === 0 && offset < page.total) {
|
|
501
|
+
throw new Error('Migration target pagination did not advance');
|
|
502
|
+
}
|
|
503
|
+
runtime ??= commandRuntime(result);
|
|
504
|
+
total = page.total;
|
|
505
|
+
targets.push(...page.targets);
|
|
506
|
+
offset += page.targets.length;
|
|
507
|
+
} while (offset < total);
|
|
508
|
+
return { runtime, targets };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function migrationChoiceRelevance(source, target) {
|
|
512
|
+
const sourceContext = source.context ?? {};
|
|
513
|
+
const targetContext = target.context ?? {};
|
|
514
|
+
const sourceRows = sourceContext.sameRowTexts ?? [];
|
|
515
|
+
const targetRows = targetContext.sameRowTexts ?? [];
|
|
516
|
+
const sourceHeaders = sourceContext.tableHeaderTexts ?? [];
|
|
517
|
+
const targetHeaders = targetContext.tableHeaderTexts ?? [];
|
|
518
|
+
const mainText = textSimilarity(source.text, target.text);
|
|
519
|
+
const tableIdentity = Math.max(
|
|
520
|
+
textSimilarity(sourceContext.columnHeaderText, targetContext.columnHeaderText),
|
|
521
|
+
maximumPairSimilarity(sourceHeaders, targetHeaders));
|
|
522
|
+
const neighborhood = Math.max(
|
|
523
|
+
textSimilarity(sourceContext.previousText, targetContext.previousText),
|
|
524
|
+
textSimilarity(sourceContext.nextText, targetContext.nextText),
|
|
525
|
+
maximumPairSimilarity(sourceRows, targetRows),
|
|
526
|
+
maximumPairSimilarity([source.text], targetRows),
|
|
527
|
+
maximumPairSimilarity(sourceRows, [target.text]));
|
|
528
|
+
return source.kind === 'table-cell' && target.kind === 'table-cell'
|
|
529
|
+
? [tableIdentity, mainText, neighborhood]
|
|
530
|
+
: [mainText, neighborhood, tableIdentity];
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function compareRelevance(left, right) {
|
|
534
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
535
|
+
if (left[index] !== right[index]) return left[index] - right[index];
|
|
536
|
+
}
|
|
537
|
+
return 0;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function maximumPairSimilarity(leftValues, rightValues) {
|
|
541
|
+
let maximum = 0;
|
|
542
|
+
for (const left of leftValues ?? []) {
|
|
543
|
+
for (const right of rightValues ?? []) {
|
|
544
|
+
maximum = Math.max(maximum, textSimilarity(left, right));
|
|
545
|
+
}
|
|
388
546
|
}
|
|
547
|
+
return maximum;
|
|
548
|
+
}
|
|
389
549
|
|
|
390
|
-
|
|
550
|
+
function textSimilarity(leftValue, rightValue) {
|
|
551
|
+
const left = normalizeSearchText(leftValue);
|
|
552
|
+
const right = normalizeSearchText(rightValue);
|
|
553
|
+
if (!left || !right) return 0;
|
|
554
|
+
if (left === right) return 1;
|
|
555
|
+
if (Math.min(left.length, right.length) >= 3 && (left.includes(right) || right.includes(left))) {
|
|
556
|
+
return Math.min(left.length, right.length) / Math.max(left.length, right.length);
|
|
557
|
+
}
|
|
558
|
+
const leftPairs = characterPairs(left);
|
|
559
|
+
const rightPairs = characterPairs(right);
|
|
560
|
+
if (leftPairs.size === 0 || rightPairs.size === 0) return 0;
|
|
561
|
+
let shared = 0;
|
|
562
|
+
for (const pair of leftPairs) if (rightPairs.has(pair)) shared += 1;
|
|
563
|
+
return 2 * shared / (leftPairs.size + rightPairs.size);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function normalizeSearchText(value) {
|
|
567
|
+
return typeof value === 'string'
|
|
568
|
+
? value.normalize('NFKC').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '')
|
|
569
|
+
: '';
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function characterPairs(value) {
|
|
573
|
+
const characters = [...value];
|
|
574
|
+
if (characters.length < 2) return new Set(value ? [value] : []);
|
|
575
|
+
const pairs = new Set();
|
|
576
|
+
for (let index = 0; index + 1 < characters.length; index += 1) {
|
|
577
|
+
pairs.add(characters[index] + characters[index + 1]);
|
|
578
|
+
}
|
|
579
|
+
return pairs;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function migrationTargetBranch(action, sourceKind) {
|
|
583
|
+
if (action === 'place-content') return sourceKind === 'media' ? 'copy-media' : 'copy-text';
|
|
584
|
+
if (action === 'keep-template-content') return 'retain-target';
|
|
585
|
+
if (action === 'keep-template-label') return 'retain-target-label';
|
|
586
|
+
if (action === 'select-template-option') return 'choice-selection';
|
|
587
|
+
throw new Error(`Unsupported migration target action: ${action}`);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function migrationQueryResult({ runtime, catalog, view, action, source, items, offset, total }) {
|
|
391
591
|
return {
|
|
392
592
|
tool: 'docx_query_migration_choices',
|
|
393
|
-
|
|
394
|
-
|
|
593
|
+
runtime,
|
|
594
|
+
sourceSha256: catalog.sourceSha256,
|
|
595
|
+
baselineSha256: catalog.baselineSha256,
|
|
596
|
+
view,
|
|
597
|
+
action,
|
|
395
598
|
source,
|
|
396
599
|
items,
|
|
397
600
|
page: {
|
|
398
601
|
offset,
|
|
399
602
|
returned: items.length,
|
|
400
|
-
total
|
|
401
|
-
hasMore: offset + items.length <
|
|
603
|
+
total,
|
|
604
|
+
hasMore: offset + items.length < total,
|
|
402
605
|
},
|
|
403
606
|
};
|
|
404
607
|
}
|
|
405
608
|
|
|
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
609
|
async function docxMigrateTemplate(args) {
|
|
422
610
|
return runTemplateMigrationCommand('docx_migrate_template', 'migrate-template', args);
|
|
423
611
|
}
|