@tiwater/office-mcp 0.3.0 → 0.5.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.
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs/promises';
2
+ import { constants as fsConstants } from 'node:fs';
2
3
  import os from 'node:os';
3
4
  import path from 'node:path';
4
5
  import { fileURLToPath } from 'node:url';
@@ -78,7 +79,7 @@ export function requireString(value, label) {
78
79
  }
79
80
 
80
81
  async function runCommand(candidate, args, options) {
81
- const env = { ...process.env, ...(candidate.env || {}), ...(options.env || {}) };
82
+ const env = await withDotnetRoot({ ...process.env, ...(candidate.env || {}), ...(options.env || {}) });
82
83
  const cwd = candidate.cwd || options.cwd || repoRoot;
83
84
  const commandArgs = [...(candidate.argsPrefix || []), ...args];
84
85
 
@@ -106,3 +107,35 @@ async function runCommand(candidate, args, options) {
106
107
  });
107
108
  });
108
109
  }
110
+
111
+ async function withDotnetRoot(env) {
112
+ const architectureVariable = process.arch === 'arm64'
113
+ ? 'DOTNET_ROOT_ARM64'
114
+ : process.arch === 'x64'
115
+ ? 'DOTNET_ROOT_X64'
116
+ : null;
117
+ if (env.DOTNET_ROOT || (architectureVariable && env[architectureVariable])) return env;
118
+
119
+ const dotnet = await findOnPath(process.platform === 'win32' ? 'dotnet.exe' : 'dotnet', env.PATH);
120
+ if (!dotnet) return env;
121
+
122
+ const root = path.dirname(dotnet);
123
+ return {
124
+ ...env,
125
+ DOTNET_ROOT: root,
126
+ ...(architectureVariable ? { [architectureVariable]: root } : {}),
127
+ };
128
+ }
129
+
130
+ async function findOnPath(command, pathValue) {
131
+ for (const directory of String(pathValue ?? '').split(path.delimiter).filter(Boolean)) {
132
+ const candidate = path.join(directory, command);
133
+ try {
134
+ await fs.access(candidate, fsConstants.X_OK);
135
+ return candidate;
136
+ } catch {
137
+ // Continue to the next PATH entry.
138
+ }
139
+ }
140
+ return null;
141
+ }
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`
package/office/index.mjs CHANGED
@@ -72,6 +72,7 @@ const templateMigrationInput = z.object({
72
72
  source: pathInput.describe('Path to the current source DOCX.'),
73
73
  baseline: pathInput.describe('Path to the selected current baseline DOCX.'),
74
74
  output: pathInput.describe('Path to the migrated output DOCX.'),
75
+ receiptOutput: pathInput.describe('New JSON receipt artifact path. Existing files are never overwritten.'),
75
76
  choices: z.array(migrationChoiceInput).describe('Exactly one business choice for every source id returned by docx_list_migration_choices.'),
76
77
  templateCleanup: z.array(templateCleanupInput).optional().describe('Optional baseline-owned placeholders or example rows to clear.'),
77
78
  }).strict();
@@ -92,24 +93,103 @@ const migrationChoiceOutput = z.object({
92
93
  allowedActions: z.array(z.string()),
93
94
  }).strict();
94
95
 
96
+ const migrationCatalog = z.object({
97
+ schema: z.string(),
98
+ pass: z.boolean(),
99
+ sourceSha256: z.string(),
100
+ baselineSha256: z.string(),
101
+ sources: z.array(migrationChoiceOutput),
102
+ targets: z.array(migrationChoiceOutput),
103
+ }).strict().superRefine((catalog, context) => {
104
+ for (const key of ['sources', 'targets']) {
105
+ const seen = new Set();
106
+ for (const [index, choice] of catalog[key].entries()) {
107
+ if (seen.has(choice.id)) {
108
+ context.addIssue({ code: 'custom', path: [key, index, 'id'], message: `duplicate ${key} choice id: ${choice.id}` });
109
+ }
110
+ seen.add(choice.id);
111
+ }
112
+ }
113
+ });
114
+
115
+ const migrationReceipt = z.object({
116
+ schema: z.string(),
117
+ toolVersion: z.string(),
118
+ status: z.enum(['pass', 'review-required', 'failed']),
119
+ pass: z.boolean(),
120
+ reviewRequired: z.boolean(),
121
+ outputVerified: z.boolean(),
122
+ output: z.string().nullable(),
123
+ plan: z.string().nullable(),
124
+ failures: z.array(z.unknown()),
125
+ }).passthrough();
126
+
127
+ const inputOnly = z.object({ input: pathInput }).strict();
128
+ const artifactInput = z.object({
129
+ input: pathInput,
130
+ output: pathInput.describe('New JSON artifact path. Existing files are never overwritten.'),
131
+ }).strict();
132
+ const artifact = z.object({
133
+ path: z.string(),
134
+ sha256: z.string().regex(/^[0-9a-f]{64}$/),
135
+ bytes: z.number().int().nonnegative(),
136
+ }).strict();
137
+
95
138
  const migrationCatalogOutput = z.object({
96
139
  tool: z.literal('docx_list_migration_choices'),
97
140
  runtime: runtimeIdentity,
98
- catalog: z.object({
141
+ artifact,
142
+ summary: z.object({
99
143
  schema: z.string(),
100
144
  pass: z.boolean(),
101
145
  sourceSha256: z.string(),
102
146
  baselineSha256: z.string(),
103
- sources: z.array(migrationChoiceOutput),
104
- targets: z.array(migrationChoiceOutput),
147
+ sourceCount: z.number().int().nonnegative(),
148
+ targetCount: z.number().int().nonnegative(),
105
149
  }).strict(),
106
150
  }).strict();
107
151
 
152
+ const migrationQueryPage = z.object({
153
+ offset: z.number().int().nonnegative(),
154
+ returned: z.number().int().nonnegative(),
155
+ total: z.number().int().nonnegative(),
156
+ hasMore: z.boolean(),
157
+ }).strict();
158
+
159
+ const migrationChoiceQueryInput = z.discriminatedUnion('view', [
160
+ z.object({
161
+ catalog: pathInput.describe('Path returned by docx_list_migration_choices.'),
162
+ view: z.literal('sources'),
163
+ offset: z.number().int().nonnegative().optional(),
164
+ limit: z.number().int().min(1).max(10).optional(),
165
+ }).strict(),
166
+ z.object({
167
+ catalog: pathInput.describe('Path returned by docx_list_migration_choices.'),
168
+ 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(),
175
+ }).strict(),
176
+ ]);
177
+
178
+ const migrationChoiceQueryOutput = z.object({
179
+ tool: z.literal('docx_query_migration_choices'),
180
+ catalogSha256: z.string().regex(/^[0-9a-f]{64}$/),
181
+ view: z.enum(['sources', 'targets']),
182
+ source: migrationChoiceOutput.nullable(),
183
+ items: z.array(migrationChoiceOutput),
184
+ page: migrationQueryPage,
185
+ }).strict();
186
+
108
187
  function migrationReceiptOutput(tool) {
109
188
  return z.object({
110
189
  tool: z.literal(tool),
111
190
  runtime: runtimeIdentity,
112
- receipt: z.object({
191
+ artifact,
192
+ summary: z.object({
113
193
  schema: z.string(),
114
194
  toolVersion: z.string(),
115
195
  status: z.enum(['pass', 'review-required', 'failed']),
@@ -118,22 +198,11 @@ function migrationReceiptOutput(tool) {
118
198
  outputVerified: z.boolean(),
119
199
  output: z.string().nullable(),
120
200
  plan: z.string().nullable(),
121
- failures: z.array(z.unknown()),
122
- }).passthrough(),
201
+ failureCount: z.number().int().nonnegative(),
202
+ }).strict(),
123
203
  }).strict();
124
204
  }
125
205
 
126
- const inputOnly = z.object({ input: pathInput }).strict();
127
- const artifactInput = z.object({
128
- input: pathInput,
129
- output: pathInput.describe('New JSON artifact path. Existing files are never overwritten.'),
130
- }).strict();
131
- const artifact = z.object({
132
- path: z.string(),
133
- sha256: z.string().regex(/^[0-9a-f]{64}$/),
134
- bytes: z.number().int().nonnegative(),
135
- }).strict();
136
-
137
206
  function artifactOutput(tool) {
138
207
  return z.object({ tool: z.literal(tool), runtime: runtimeIdentity, artifact }).strict();
139
208
  }
@@ -148,15 +217,23 @@ const tools = [
148
217
  },
149
218
  {
150
219
  name: 'docx_list_migration_choices',
151
- description: 'List every current source item that still needs a business choice and the selectable current baseline targets. Returns opaque ids and context; it does not recommend a choice.',
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.',
152
221
  inputSchema: z.object({
153
222
  source: pathInput.describe('Path to the current source DOCX.'),
154
223
  baseline: pathInput.describe('Path to the selected current baseline DOCX.'),
224
+ output: pathInput.describe('New JSON artifact path. Existing files are never overwritten.'),
155
225
  }).strict(),
156
226
  outputSchema: migrationCatalogOutput,
157
- annotations: { readOnlyHint: true, idempotentHint: true },
158
227
  handler: docxListMigrationChoices,
159
228
  },
229
+ {
230
+ 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.',
232
+ inputSchema: migrationChoiceQueryInput,
233
+ outputSchema: migrationChoiceQueryOutput,
234
+ annotations: { readOnlyHint: true, idempotentHint: true },
235
+ handler: docxQueryMigrationChoices,
236
+ },
160
237
  {
161
238
  name: 'docx_migrate_template',
162
239
  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.',
@@ -169,7 +246,6 @@ const tools = [
169
246
  description: 'Independently re-resolve the same business choices and verify a migrated DOCX against the current source and baseline. This does not trust the migration receipt.',
170
247
  inputSchema: templateMigrationInput,
171
248
  outputSchema: migrationReceiptOutput('docx_verify_migration'),
172
- annotations: { readOnlyHint: true, idempotentHint: true },
173
249
  handler: docxVerifyMigration,
174
250
  },
175
251
  {
@@ -270,7 +346,76 @@ async function docxListMigrationChoices(args) {
270
346
  const source = requireString(args.source, 'source');
271
347
  const baseline = requireString(args.baseline, 'baseline');
272
348
  const result = await runJsonCandidateChain(docxCandidates, ['list-template-migration-choices', source, baseline]);
273
- return { tool: 'docx_list_migration_choices', runtime: commandRuntime(result), catalog: result.json };
349
+ const catalog = migrationCatalog.parse(result.json);
350
+ return {
351
+ tool: 'docx_list_migration_choices',
352
+ runtime: commandRuntime(result),
353
+ artifact: await writeJsonArtifact(requireString(args.output, 'output'), catalog),
354
+ summary: {
355
+ schema: catalog.schema,
356
+ pass: catalog.pass,
357
+ sourceSha256: catalog.sourceSha256,
358
+ baselineSha256: catalog.baselineSha256,
359
+ sourceCount: catalog.sources.length,
360
+ targetCount: catalog.targets.length,
361
+ },
362
+ };
363
+ }
364
+
365
+ 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')));
369
+ const offset = args.offset ?? 0;
370
+ const limit = args.limit ?? 10;
371
+ let source = null;
372
+ let matches;
373
+
374
+ if (args.view === 'sources') {
375
+ matches = catalog.sources;
376
+ } else {
377
+ source = catalog.sources.find(item => item.id === args.sourceChoiceId) ?? null;
378
+ if (!source) {
379
+ throw Object.assign(new Error(`Unknown sourceChoiceId: ${args.sourceChoiceId}`), { code: -32602 });
380
+ }
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)));
388
+ }
389
+
390
+ const items = matches.slice(offset, offset + limit);
391
+ return {
392
+ tool: 'docx_query_migration_choices',
393
+ catalogSha256: createHash('sha256').update(bytes).digest('hex'),
394
+ view: args.view,
395
+ source,
396
+ items,
397
+ page: {
398
+ offset,
399
+ returned: items.length,
400
+ total: matches.length,
401
+ hasMore: offset + items.length < matches.length,
402
+ },
403
+ };
404
+ }
405
+
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 [];
274
419
  }
275
420
 
276
421
  async function docxMigrateTemplate(args) {
@@ -298,7 +443,23 @@ async function runTemplateMigrationCommand(tool, command, args) {
298
443
  docxCandidates,
299
444
  [command, source, baseline, choicesPath, output],
300
445
  { allowedExitCodes: [0, 1] });
301
- return { tool, runtime: commandRuntime(result), receipt: result.json };
446
+ const receipt = migrationReceipt.parse(result.json);
447
+ return {
448
+ tool,
449
+ runtime: commandRuntime(result),
450
+ artifact: await writeJsonArtifact(requireString(args.receiptOutput, 'receiptOutput'), receipt),
451
+ summary: {
452
+ schema: receipt.schema,
453
+ toolVersion: receipt.toolVersion,
454
+ status: receipt.status,
455
+ pass: receipt.pass,
456
+ reviewRequired: receipt.reviewRequired,
457
+ outputVerified: receipt.outputVerified,
458
+ output: receipt.output,
459
+ plan: receipt.plan,
460
+ failureCount: receipt.failures.length,
461
+ },
462
+ };
302
463
  });
303
464
  }
304
465
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiwater/office-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Published MCP server for Tiwater Office document capabilities",
5
5
  "type": "module",
6
6
  "license": "MIT",