xlsx-for-ai 2.25.0 → 2.26.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.
Files changed (3) hide show
  1. package/index.js +219 -9
  2. package/mcp.js +95 -2
  3. package/package.json +1 -1
package/index.js CHANGED
@@ -83,8 +83,8 @@ async function runClean(opts, absPath) {
83
83
  try {
84
84
  result = await callTool('xlsx_data_clean', body);
85
85
  } catch (err) {
86
- process.stderr.write(`xlsx-for-ai --clean error: ${err.message}\n`);
87
- process.exit(1);
86
+ process.stderr.write(friendlyCliError('xlsx-for-ai --clean', err) + '\n');
87
+ process.exit(err.code === 'API_UNREACHABLE' || err.code === 'API_SERVER_ERROR' ? 3 : 1);
88
88
  }
89
89
 
90
90
  const meta = (result && result._meta) || {};
@@ -151,6 +151,46 @@ async function runClean(opts, absPath) {
151
151
  // ---------------------------------------------------------------------------
152
152
 
153
153
  const STAMP_SUBCOMMANDS = new Set(['stamp', 'verify-stamp', 'receipt', 'verify-receipt']);
154
+ const HEAL_SUBCOMMANDS = new Set(['heal']);
155
+
156
+ // Strip _meta.file_b64 before writing the meta block to stdout. The
157
+ // stamped/receipted workbook can be megabytes; dumping it to a terminal
158
+ // or CI log clobbers scrollback AND leaks PII-bearing workbook contents
159
+ // to whatever consumes stdout. The file is already saved to disk via
160
+ // the sidecar / --out path; the b64 in stdout serves no consumer.
161
+ // Pre-Friday-external CRITICAL per the Tier-1 audit.
162
+ function metaForStdout(meta) {
163
+ if (!meta || typeof meta !== 'object') return meta;
164
+ const out = { ...meta };
165
+ delete out.file_b64;
166
+ return out;
167
+ }
168
+
169
+ // CLI-side error formatter. Same posture as friendlyErrorMessage in
170
+ // mcp.js: known operational codes get short, client-safe text; everything
171
+ // else collapses to a generic message. err.message can carry absolute
172
+ // file paths, upstream stack traces, and third-party HTTP response
173
+ // bodies — none of those belong in user-facing CLI stderr or in CI logs.
174
+ // Set XFA_DEBUG=1 to see the raw underlying message (for incident triage).
175
+ function friendlyCliError(prefix, err) {
176
+ const code = err && err.code;
177
+ const showRaw = process.env.XFA_DEBUG === '1';
178
+ const base = (() => {
179
+ switch (code) {
180
+ case 'API_UNREACHABLE': return `${prefix}: API is unreachable — check network connectivity.`;
181
+ case 'API_SERVER_ERROR': return `${prefix}: API returned a server error — retry shortly.`;
182
+ case 'DISALLOWED_EXTENSION': return `${prefix}: file must be a workbook (allowed: .xlsx/.xls/.xlsm/.xlsb/.csv/.ods/.fods/.numbers/.tsv).`;
183
+ case 'FILE_TOO_LARGE': return `${prefix}: file exceeds the XFA_MAX_FILE_MB cap (default 50 MB).`;
184
+ case 'FILE_NOT_FOUND': return `${prefix}: file not found.`;
185
+ case 'MISSING_TOKEN': return `${prefix}: required token env var is not set.`;
186
+ case 'RATE_LIMITED': return `${prefix}: free-tier monthly cap reached — see xlsx-for-ai.dev/pricing.`;
187
+ case 'TIER_UPGRADE_REQUIRED': return `${prefix}: this capability requires a paid tier.`;
188
+ case 'FALLBACK_ENGINE_MISSING': return `${prefix}: local fallback engine not installed (\`npm install @protobi/exceljs\`).`;
189
+ default: return `${prefix}: request failed${code ? ` (code=${code})` : ''}.`;
190
+ }
191
+ })();
192
+ return showRaw && err && err.message ? `${base}\nRaw: ${err.message}` : base;
193
+ }
154
194
 
155
195
  function nextRequiredArg(argv, i, flag) {
156
196
  const v = argv[i + 1];
@@ -175,6 +215,172 @@ function loadChecksFile(checksPath) {
175
215
  return parsed;
176
216
  }
177
217
 
218
+ // ---------------------------------------------------------------------------
219
+ // Heal subcommand — exposes the healer-deep HTTP routes from the CLI.
220
+ //
221
+ // xlsx-for-ai heal <path> diagnose-only (default)
222
+ // xlsx-for-ai heal <path> --diagnose-only explicit form of the default
223
+ // xlsx-for-ai heal <path> --operation <op> --params <json> [--mode <m>] [--out <path>]
224
+ // xlsx-for-ai heal <path> --format text|json [other flags]
225
+ //
226
+ // `--intent`/`--from`/`--to` are reserved for v1.1 once the
227
+ // /api/v1/tools/xlsx_healer_intent route ships; the CLI rejects
228
+ // those flags today so callers see a clean "v1.1" message rather
229
+ // than a server 404.
230
+ // ---------------------------------------------------------------------------
231
+
232
+ async function runHealSubcommand(rest) {
233
+ if (rest.length === 0 || rest[0].startsWith('-')) {
234
+ process.stderr.write(
235
+ 'Usage: xlsx-for-ai heal <file.xlsx> [--diagnose-only | --operation <op> --params <json>] [--mode as_copy|in_place] [--out <path>] [--format text|json]\n',
236
+ );
237
+ process.exit(2);
238
+ }
239
+ const filePath = path.resolve(rest[0]);
240
+ if (!fs.existsSync(filePath)) {
241
+ process.stderr.write(`File not found: ${filePath}\n`);
242
+ process.exit(4);
243
+ }
244
+
245
+ // Parse flags
246
+ let diagnoseOnly = false;
247
+ let operation = null;
248
+ let paramsJson = null;
249
+ let mode = 'as_copy';
250
+ let outPath = null;
251
+ let format = 'text';
252
+ for (let i = 1; i < rest.length; i++) {
253
+ const a = rest[i];
254
+ if (a === '--diagnose-only') diagnoseOnly = true;
255
+ else if (a === '--operation') operation = nextRequiredArg(rest, i++, '--operation');
256
+ else if (a === '--params') paramsJson = nextRequiredArg(rest, i++, '--params');
257
+ else if (a === '--mode') mode = nextRequiredArg(rest, i++, '--mode');
258
+ else if (a === '--out') outPath = nextRequiredArg(rest, i++, '--out');
259
+ else if (a === '--format') format = nextRequiredArg(rest, i++, '--format');
260
+ else if (a === '--intent' || a === '--from' || a === '--to') {
261
+ process.stderr.write(`xlsx-for-ai heal: ${a} is reserved for v1.1 (intent-driven cures via /xlsx_healer_intent); not yet wired\n`);
262
+ process.exit(2);
263
+ } else {
264
+ process.stderr.write(`Unknown flag: ${a}\n`);
265
+ process.exit(2);
266
+ }
267
+ }
268
+
269
+ // Validate mutually-exclusive shapes early — clearer than letting
270
+ // the server emit `conflicting_repair_directives` on its O8 check.
271
+ if (diagnoseOnly && operation) {
272
+ process.stderr.write('xlsx-for-ai heal: --diagnose-only and --operation are mutually exclusive\n');
273
+ process.exit(2);
274
+ }
275
+ if (!diagnoseOnly && !operation) {
276
+ // Default to diagnose-only — first-touch use of `xlsx-for-ai heal`
277
+ // should show the user what's wrong before they pick a cure.
278
+ diagnoseOnly = true;
279
+ }
280
+ if (mode !== 'as_copy' && mode !== 'in_place') {
281
+ process.stderr.write(`xlsx-for-ai heal: --mode must be 'as_copy' or 'in_place' (got '${mode}')\n`);
282
+ process.exit(2);
283
+ }
284
+ if (format !== 'text' && format !== 'json') {
285
+ process.stderr.write(`xlsx-for-ai heal: --format must be 'text' or 'json' (got '${format}')\n`);
286
+ process.exit(2);
287
+ }
288
+
289
+ await ensureRegistered();
290
+ const fileB64 = fs.readFileSync(filePath).toString('base64');
291
+
292
+ // ---- diagnose path -----------------------------------------------------
293
+ if (diagnoseOnly) {
294
+ let result;
295
+ try {
296
+ result = await callTool('xlsx_healer_diagnose', { file_b64: fileB64 });
297
+ } catch (err) {
298
+ // friendlyCliError (defined above in this file) maps known
299
+ // API error codes to short canned messages — raw err.message
300
+ // is suppressed unless XFA_DEBUG=1 is set, so internal server
301
+ // detail / paths / stack traces never reach the user's
302
+ // terminal or CI logs by default. Same sanitization shape
303
+ // the other subcommands use for API failures.
304
+ process.stderr.write(friendlyCliError('xlsx-for-ai heal --diagnose-only', err) + '\n');
305
+ process.exit(err.code === 'API_UNREACHABLE' || err.code === 'API_SERVER_ERROR' ? 3 : 1);
306
+ }
307
+ const meta = (result && result._meta) || {};
308
+ if (format === 'json') {
309
+ // metaForStdout strips file_b64 if present — diagnose has none,
310
+ // but the helper is safe to call unconditionally.
311
+ process.stdout.write(JSON.stringify(metaForStdout(meta) || {}, null, 2) + '\n');
312
+ } else {
313
+ const text = (result.content || []).map((c) => c.text).join('\n');
314
+ process.stdout.write(text + '\n');
315
+ }
316
+ return 0;
317
+ }
318
+
319
+ // ---- cure path ---------------------------------------------------------
320
+ let cureParams = {};
321
+ if (paramsJson !== null) {
322
+ try {
323
+ cureParams = JSON.parse(paramsJson);
324
+ } catch (e) {
325
+ process.stderr.write(`xlsx-for-ai heal: --params is not valid JSON: ${e.message}\n`);
326
+ process.exit(2);
327
+ }
328
+ if (cureParams === null || typeof cureParams !== 'object' || Array.isArray(cureParams)) {
329
+ process.stderr.write('xlsx-for-ai heal: --params must be a JSON object\n');
330
+ process.exit(2);
331
+ }
332
+ }
333
+
334
+ const body = { file_b64: fileB64, operation, cure_params: cureParams, mode };
335
+ let result;
336
+ try {
337
+ result = await callTool('xlsx_healer_cure', body);
338
+ } catch (err) {
339
+ // Same sanitization shape as the diagnose path — friendlyCliError
340
+ // (above) maps known codes to canned messages; raw err.message
341
+ // only surfaces with XFA_DEBUG=1 for incident triage.
342
+ process.stderr.write(friendlyCliError(`xlsx-for-ai heal --operation ${operation}`, err) + '\n');
343
+ process.exit(err.code === 'API_UNREACHABLE' || err.code === 'API_SERVER_ERROR' ? 3 : 1);
344
+ }
345
+
346
+ const meta = (result && result._meta) || {};
347
+
348
+ // Write the cured workbook to disk when `as_copy` mode produced
349
+ // bytes. `in_place` mode is server-side conceptual; the client
350
+ // still receives the cured bytes here (we'd overwrite the input
351
+ // file). Refuse to overwrite the source unless the caller passed
352
+ // --out explicitly.
353
+ if (meta.file_b64) {
354
+ let resolvedOut = outPath;
355
+ if (!resolvedOut) {
356
+ const parsed = path.parse(filePath);
357
+ resolvedOut = path.join(parsed.dir, `${parsed.name}-healed${parsed.ext || '.xlsx'}`);
358
+ }
359
+ const sourceIsTarget = path.resolve(resolvedOut) === path.resolve(filePath);
360
+ if (sourceIsTarget && mode !== 'in_place') {
361
+ process.stderr.write(
362
+ 'xlsx-for-ai heal: refusing to overwrite source — pass --out <other-path> or --mode in_place\n',
363
+ );
364
+ process.exit(2);
365
+ }
366
+ try {
367
+ fs.writeFileSync(resolvedOut, Buffer.from(meta.file_b64, 'base64'));
368
+ } catch (e) {
369
+ process.stderr.write(`xlsx-for-ai heal: failed to write ${resolvedOut}: ${e.message}\n`);
370
+ process.exit(4);
371
+ }
372
+ process.stderr.write(`Wrote ${resolvedOut}\n`);
373
+ }
374
+
375
+ if (format === 'json') {
376
+ process.stdout.write(JSON.stringify(metaForStdout(meta) || {}, null, 2) + '\n');
377
+ } else {
378
+ const text = (result.content || []).map((c) => c.text).join('\n');
379
+ process.stdout.write(text + '\n');
380
+ }
381
+ return 0;
382
+ }
383
+
178
384
  async function runStampSubcommand(subcmd, rest) {
179
385
  if (rest.length === 0 || rest[0].startsWith('-')) {
180
386
  process.stderr.write(`Usage: xlsx-for-ai ${subcmd} <path> [...]\n`);
@@ -204,7 +410,7 @@ async function runStampSubcommand(subcmd, rest) {
204
410
  if (excludeSheets.length) body.exclude_sheets = excludeSheets;
205
411
  if (supervisor) body.generated_by = { npm: 'xlsx-for-ai@' + require('./package.json').version, supervisor };
206
412
  const result = await callServerForStamp('xlsx_stamp', body, outPath, filePath, '.stamped.xlsx');
207
- process.stdout.write(JSON.stringify(result._meta || {}, null, 2) + '\n');
413
+ process.stdout.write(JSON.stringify(metaForStdout(result._meta) || {}, null, 2) + '\n');
208
414
  return 0;
209
415
  }
210
416
 
@@ -212,7 +418,7 @@ async function runStampSubcommand(subcmd, rest) {
212
418
  const body = { file_b64: fileB64 };
213
419
  const result = await callTool('xlsx_verify_stamp', body);
214
420
  const meta = result._meta || {};
215
- process.stdout.write(JSON.stringify(meta, null, 2) + '\n');
421
+ process.stdout.write(JSON.stringify(metaForStdout(meta), null, 2) + '\n');
216
422
  return meta.valid === true ? 0 : 1;
217
423
  }
218
424
 
@@ -255,7 +461,7 @@ async function runStampSubcommand(subcmd, rest) {
255
461
  if (description) body.description = description;
256
462
  if (coverSheets.length) body.covers_sheets = coverSheets;
257
463
  const result = await callServerForStamp('xlsx_receipt', body, outPath, filePath, '.receipted.xlsx');
258
- process.stdout.write(JSON.stringify(result._meta || {}, null, 2) + '\n');
464
+ process.stdout.write(JSON.stringify(metaForStdout(result._meta) || {}, null, 2) + '\n');
259
465
  return 0;
260
466
  }
261
467
 
@@ -263,7 +469,7 @@ async function runStampSubcommand(subcmd, rest) {
263
469
  const body = { file_b64: fileB64 };
264
470
  const result = await callTool('xlsx_verify_receipt', body);
265
471
  const meta = result._meta || {};
266
- process.stdout.write(JSON.stringify(meta, null, 2) + '\n');
472
+ process.stdout.write(JSON.stringify(metaForStdout(meta), null, 2) + '\n');
267
473
  return meta.valid === true ? 0 : 1;
268
474
  }
269
475
  return 2;
@@ -274,7 +480,7 @@ async function callServerForStamp(tool, body, explicitOutPath, sourcePath, sidec
274
480
  try {
275
481
  result = await callTool(tool, body);
276
482
  } catch (err) {
277
- process.stderr.write(`xlsx-for-ai ${tool}: ${err.message}\n`);
483
+ process.stderr.write(friendlyCliError(`xlsx-for-ai ${tool}`, err) + '\n');
278
484
  process.exit(err.code === 'API_UNREACHABLE' || err.code === 'API_SERVER_ERROR' ? 3 : 1);
279
485
  }
280
486
  const meta = result._meta || {};
@@ -303,6 +509,10 @@ async function main() {
303
509
  const code = await runStampSubcommand(argv[0], argv.slice(1));
304
510
  process.exit(code);
305
511
  }
512
+ if (argv.length > 0 && HEAL_SUBCOMMANDS.has(argv[0])) {
513
+ const code = await runHealSubcommand(argv.slice(1));
514
+ process.exit(code);
515
+ }
306
516
 
307
517
  const opts = parseArgs(argv);
308
518
 
@@ -353,7 +563,7 @@ async function main() {
353
563
  if (err.code === 'API_UNREACHABLE' || err.code === 'API_SERVER_ERROR') {
354
564
  result = await fallbackRead(absPath, opts);
355
565
  } else {
356
- process.stderr.write(`Error: ${err.message}\n`);
566
+ process.stderr.write(friendlyCliError('xlsx-for-ai', err) + '\n');
357
567
  process.exit(1);
358
568
  }
359
569
  }
@@ -363,6 +573,6 @@ async function main() {
363
573
  }
364
574
 
365
575
  main().catch((err) => {
366
- process.stderr.write(`xlsx-for-ai: ${err.message}\n`);
576
+ process.stderr.write(friendlyCliError('xlsx-for-ai', err) + '\n');
367
577
  process.exit(1);
368
578
  });
package/mcp.js CHANGED
@@ -1156,6 +1156,14 @@ function fileToB64(filePath) {
1156
1156
  // If out_path is not provided: leave response unchanged.
1157
1157
  // ---------------------------------------------------------------------------
1158
1158
 
1159
+ // Extensions an MCP tool is allowed to write via out_path. Tighter than the
1160
+ // READ allowlist (no .ods/.fods/.numbers/.tsv) because the server only ever
1161
+ // emits XLSX or XLSX-family workbook bytes — accepting unrelated extensions
1162
+ // would let a malicious / confused agent point out_path at /etc/profile.d/
1163
+ // or a shell startup script. The .json carve-out is for fixture/audit JSON
1164
+ // the redact + clean tools sometimes emit alongside the workbook.
1165
+ const ALLOWED_WRITE_EXTENSIONS = new Set(['.xlsx', '.xls', '.xlsm', '.xlsb', '.csv', '.json']);
1166
+
1159
1167
  async function applyFileB64(result, outPath) {
1160
1168
  if (!outPath) {
1161
1169
  // No save requested — leave response untouched (b64 stays in _meta for caller)
@@ -1164,6 +1172,21 @@ async function applyFileB64(result, outPath) {
1164
1172
 
1165
1173
  const absPath = path.resolve(outPath);
1166
1174
 
1175
+ // Containment: require an absolute path + a workbook-family extension.
1176
+ // Reject path-traversal patterns and any non-workbook extension at the
1177
+ // boundary so a malicious agent can't request a write to a shell-startup
1178
+ // location or an arbitrary system file via out_path. Pre-Friday-external
1179
+ // CRITICAL per the Tier-1 error-handling audit (2026-06-03).
1180
+ const outExt = path.extname(absPath).toLowerCase();
1181
+ if (!ALLOWED_WRITE_EXTENSIONS.has(outExt)) {
1182
+ if (result.content && result.content[0] && result.content[0].type === 'text') {
1183
+ result.content[0].text +=
1184
+ `\n\nout_path rejected: extension "${outExt}" is not in the allowed write set ` +
1185
+ `(${[...ALLOWED_WRITE_EXTENSIONS].join(', ')}). File was NOT written.`;
1186
+ }
1187
+ return result;
1188
+ }
1189
+
1167
1190
  if (result._meta && result._meta.file_b64) {
1168
1191
  await fsPromises.writeFile(absPath, Buffer.from(result._meta.file_b64, 'base64'));
1169
1192
  // Append save confirmation to first text content block
@@ -1181,6 +1204,51 @@ async function applyFileB64(result, outPath) {
1181
1204
  return result;
1182
1205
  }
1183
1206
 
1207
+ // ---------------------------------------------------------------------------
1208
+ // Boundary error sanitization
1209
+ //
1210
+ // The MCP server is a public boundary — anything in err.message that flows
1211
+ // to the client can end up in the MCP client's conversation log and from
1212
+ // there into any LLM context window the operator never intended. Map the
1213
+ // known operational error codes to short, client-safe text; collapse
1214
+ // everything else to a generic message that names the tool but not the
1215
+ // internals. Tool name is safe to echo (the caller asked for it); paths,
1216
+ // upstream server stacks, and third-party response bodies are not.
1217
+ //
1218
+ // New codes added here as the client-side error surface grows. Default
1219
+ // branch is conservative on purpose — better to under-reveal than over-
1220
+ // reveal at the boundary.
1221
+ // ---------------------------------------------------------------------------
1222
+
1223
+ function friendlyErrorMessage(toolName, code) {
1224
+ switch (code) {
1225
+ case 'DISALLOWED_EXTENSION':
1226
+ return `${toolName}: file path must point at a workbook (allowed: .xlsx/.xls/.xlsm/.xlsb/.csv/.ods/.fods/.numbers/.tsv).`;
1227
+ case 'SYMLINK_REJECTED':
1228
+ return `${toolName}: file path resolves through a symlink — provide a direct path.`;
1229
+ case 'FILE_TOO_LARGE':
1230
+ return `${toolName}: file exceeds the XFA_MAX_FILE_MB cap (default 50 MB).`;
1231
+ case 'FILE_NOT_FOUND':
1232
+ return `${toolName}: file not found at the supplied path.`;
1233
+ case 'NOT_REGULAR_FILE':
1234
+ return `${toolName}: file path is not a regular file.`;
1235
+ case 'MISSING_TOKEN':
1236
+ return `${toolName}: required token env var is not set (see tool docs for which one).`;
1237
+ case 'API_UNREACHABLE':
1238
+ return `${toolName}: API is unreachable — check network connectivity.`;
1239
+ case 'API_SERVER_ERROR':
1240
+ return `${toolName}: API returned a server error — retry shortly.`;
1241
+ case 'TIER_UPGRADE_REQUIRED':
1242
+ return `${toolName}: this capability requires a paid tier.`;
1243
+ case 'RATE_LIMITED':
1244
+ return `${toolName}: free-tier monthly cap reached — see xlsx-for-ai.dev/pricing.`;
1245
+ case 'FALLBACK_ENGINE_MISSING':
1246
+ return `${toolName}: local fallback engine not installed (\`npm install @protobi/exceljs\`).`;
1247
+ default:
1248
+ return `${toolName} failed — see server-side logs (request_id in response _meta) for details.`;
1249
+ }
1250
+ }
1251
+
1184
1252
  // ---------------------------------------------------------------------------
1185
1253
  // Tool dispatch
1186
1254
  // ---------------------------------------------------------------------------
@@ -1525,9 +1593,23 @@ async function main() {
1525
1593
  // server-side tools appear without re-publishing this npm package.
1526
1594
  // resolveCatalog returns the baked-in TOOLS as last-resort fallback so
1527
1595
  // we never fail-open on a transient network blip. See lib/discover.js.
1596
+ //
1597
+ // Hard timeout (8s) on top of any timeout inside resolveCatalog so that
1598
+ // a network call which hangs forever (DNS sinkhole, TCP black hole, slow-
1599
+ // loris-style stalled response) cannot block MCP server startup
1600
+ // indefinitely. Pre-Friday-external CRITICAL per the Tier-1 audit.
1601
+ const CATALOG_FETCH_TIMEOUT_MS = 8000;
1528
1602
  let catalog;
1529
1603
  try {
1530
- catalog = await resolveCatalog(TOOLS);
1604
+ catalog = await Promise.race([
1605
+ resolveCatalog(TOOLS),
1606
+ new Promise((_, reject) =>
1607
+ setTimeout(
1608
+ () => reject(new Error(`catalog fetch timed out after ${CATALOG_FETCH_TIMEOUT_MS}ms`)),
1609
+ CATALOG_FETCH_TIMEOUT_MS
1610
+ )
1611
+ ),
1612
+ ]);
1531
1613
  } catch (_) {
1532
1614
  catalog = { tools: TOOLS, source: 'static-fallback' };
1533
1615
  }
@@ -1568,8 +1650,19 @@ async function main() {
1568
1650
  // Pass API response through verbatim (citation footer + _meta preserved)
1569
1651
  return result;
1570
1652
  } catch (err) {
1653
+ // Error message sanitization at the MCP boundary. Raw err.message
1654
+ // can leak absolute file paths (FILE_NOT_FOUND), upstream server
1655
+ // error stacks (any thrown Error inside dispatchTool), and upstream
1656
+ // HTTP response bodies (callTool's API_SERVER_ERROR path may carry
1657
+ // server internals). Translate the known operational codes into
1658
+ // short, client-safe messages; everything else collapses to a
1659
+ // generic "tool failed" with the tool name so callers can still
1660
+ // route on it without leaking path/server detail. Pre-Friday-
1661
+ // external CRITICAL per the Tier-1 audit.
1662
+ const code = err && err.code;
1663
+ const safeMessage = friendlyErrorMessage(name, code);
1571
1664
  return {
1572
- content: [{ type: 'text', text: `xlsx-for-ai error: ${err.message}` }],
1665
+ content: [{ type: 'text', text: `xlsx-for-ai error: ${safeMessage}` }],
1573
1666
  isError: true,
1574
1667
  };
1575
1668
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "xlsx-for-ai",
3
3
  "mcpName": "io.github.senoff/xlsx-for-ai",
4
- "version": "2.25.0",
4
+ "version": "2.26.0",
5
5
  "description": "The MCP server that makes LLMs reliable on real-world Excel spreadsheets. Thin npm client over a hosted API — read, write, diff, redact, and supervise .xlsx files from any MCP-aware agent.",
6
6
  "main": "index.js",
7
7
  "bin": {