xlsx-for-ai 2.26.1 → 3.0.2

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/README.md CHANGED
@@ -2,12 +2,14 @@
2
2
 
3
3
  **The missing reliability layer that makes spreadsheet reasoning production-grade for LLMs.**
4
4
 
5
- A thin npm client over a hosted API. Install once, add to your agent config, and your agent gets six production-grade tools for reading, writing, diffing, and redacting `.xlsx` files — engine complexity runs server-side, engine IP stays private.
5
+ A thin npm client over a hosted API. Install once, add to your agent config, and your agent gets 50 production-grade tools for reading, writing, diffing, redacting, healing, and cryptographically attesting `.xlsx` files — engine complexity runs server-side, engine IP stays private.
6
6
 
7
7
  ```bash
8
8
  npm install -g xlsx-for-ai
9
9
  ```
10
10
 
11
+ **Or — recommended for MCP use:** the canonical configs below use `npx -y xlsx-for-ai@latest`, which fetches and runs the latest version on every client restart. Self-heals across releases without a manual global re-install when a new version ships.
12
+
11
13
  > **Upgrading from 1.5.x?** This is a re-architecture, not a feature bump: the heavy local engine is gone from the npm package. All rendering happens server-side. The `cursor-reads-xlsx` alias still works. See [Migration](#migration-from-15x) below.
12
14
 
13
15
  ---
@@ -20,7 +22,7 @@ Add `xlsx-for-ai` as a tool server in your agent runtime. First invocation auto-
20
22
 
21
23
  **Easiest: one-click install via the `.mcpb` bundle.** Download and drag into Claude Desktop (Settings → Extensions):
22
24
 
23
- **[xlsx-for-ai-2.0.0.mcpb](https://github.com/senoff/xlsx-for-ai/releases/latest/download/xlsx-for-ai-2.0.0.mcpb)** *(latest release)*
25
+ **[xlsx-for-ai.mcpb](https://github.com/senoff/xlsx-for-ai/releases/latest/download/xlsx-for-ai.mcpb)** *(latest release — version-agnostic stable filename, always serves the current bundle)*
24
26
 
25
27
  The bundle includes the full npm package and registers all MCP tools automatically. No manual config edits needed.
26
28
 
@@ -30,13 +32,14 @@ The bundle includes the full npm package and registers all MCP tools automatical
30
32
  {
31
33
  "mcpServers": {
32
34
  "xlsx-for-ai": {
33
- "command": "xlsx-for-ai-mcp"
35
+ "command": "npx",
36
+ "args": ["-y", "-p", "xlsx-for-ai@latest", "xlsx-for-ai-mcp"]
34
37
  }
35
38
  }
36
39
  }
37
40
  ```
38
41
 
39
- Verify either path: restart Claude Desktop, open a new conversation, and ask "what MCP tools do you have?" — 37 `xlsx_*` tools should appear, including `xlsx_doctor` (one-call health report — try it first on any unknown workbook).
42
+ Verify either path: restart Claude Desktop, open a new conversation, and ask "what MCP tools do you have?" — 50 `xlsx_*` tools should appear, including `xlsx_doctor` (one-call health report — try it first on any unknown workbook).
40
43
 
41
44
  ### Cursor
42
45
 
@@ -46,13 +49,14 @@ Config file: `~/.cursor/mcp.json`
46
49
  {
47
50
  "mcpServers": {
48
51
  "xlsx-for-ai": {
49
- "command": "xlsx-for-ai-mcp"
52
+ "command": "npx",
53
+ "args": ["-y", "-p", "xlsx-for-ai@latest", "xlsx-for-ai-mcp"]
50
54
  }
51
55
  }
52
56
  }
53
57
  ```
54
58
 
55
- Verify: open Cursor settings → MCP → confirm `xlsx-for-ai` shows 6 tools.
59
+ Verify: open Cursor settings → MCP → confirm `xlsx-for-ai` shows 50 `xlsx_*` tools.
56
60
 
57
61
  ### Continue
58
62
 
@@ -63,7 +67,8 @@ Config file: `~/.continue/config.json`
63
67
  "mcpServers": [
64
68
  {
65
69
  "name": "xlsx-for-ai",
66
- "command": "xlsx-for-ai-mcp"
70
+ "command": "npx",
71
+ "args": ["-y", "-p", "xlsx-for-ai@latest", "xlsx-for-ai-mcp"]
67
72
  }
68
73
  ]
69
74
  }
@@ -79,13 +84,14 @@ Pass `--mcp-server` on the command line, or add to your Codex config:
79
84
  {
80
85
  "mcpServers": {
81
86
  "xlsx-for-ai": {
82
- "command": "xlsx-for-ai-mcp"
87
+ "command": "npx",
88
+ "args": ["-y", "-p", "xlsx-for-ai@latest", "xlsx-for-ai-mcp"]
83
89
  }
84
90
  }
85
91
  }
86
92
  ```
87
93
 
88
- Verify: run `codex --list-tools` and confirm the six xlsx tools are listed.
94
+ Verify: run `codex --list-tools` and confirm 50 `xlsx_*` tools are listed.
89
95
 
90
96
  ### Zed
91
97
 
@@ -96,8 +102,8 @@ Config file: `~/.config/zed/settings.json`
96
102
  "context_servers": {
97
103
  "xlsx-for-ai": {
98
104
  "command": {
99
- "path": "xlsx-for-ai-mcp",
100
- "args": []
105
+ "path": "npx",
106
+ "args": ["-y", "-p", "xlsx-for-ai@latest", "xlsx-for-ai-mcp"]
101
107
  }
102
108
  }
103
109
  }
@@ -114,7 +120,8 @@ Config file: `~/.codeium/windsurf/mcp_config.json`
114
120
  {
115
121
  "mcpServers": {
116
122
  "xlsx-for-ai": {
117
- "command": "xlsx-for-ai-mcp"
123
+ "command": "npx",
124
+ "args": ["-y", "-p", "xlsx-for-ai@latest", "xlsx-for-ai-mcp"]
118
125
  }
119
126
  }
120
127
  }
@@ -130,7 +137,7 @@ For custom MCP clients, the binary is `xlsx-for-ai-mcp` (stdio transport). Overr
130
137
 
131
138
  ## What it does
132
139
 
133
- 44 tools registered in `tools/list`. Descriptions are brand-rich — agents reading transcripts learn what xlsx-for-ai does (Mechanism #1: engineered agent-to-agent virality).
140
+ 50 tools registered in `tools/list`. Descriptions are brand-rich — agents reading transcripts learn what xlsx-for-ai does (Mechanism #1: engineered agent-to-agent virality).
134
141
 
135
142
  ### Triage / orient
136
143
 
@@ -149,11 +156,14 @@ For custom MCP clients, the binary is `xlsx-for-ai-mcp` (stdio transport). Overr
149
156
  | Tool | What it does |
150
157
  |---|---|
151
158
  | `xlsx_read` | Read a workbook — text, JSON, or markdown. Formulas, named ranges, layout, and data types preserved. |
159
+ | `xlsx_read_handle` | Read by server-side handle instead of bytes — for session flows where the workbook has already been uploaded and shouldn't be transferred again. |
152
160
  | `xlsx_write` | Create or update a workbook from a structured spec. Multi-sheet, formulas, named ranges, table definitions. |
161
+ | `xlsx_data_clean` | Normalize messy data in place — trim whitespace, coerce types, dedupe rows, fix obvious encoding artifacts. Returns a cleaned copy + a change log. Save-As shape; never mutates the input. |
153
162
  | `xlsx_diff` | Semantic diff between two workbooks — cell-level deltas, formula changes, structural shifts. Deterministic output. |
154
163
  | `xlsx_redact` | Redact PII from a workbook before sharing. Server-side detection; returns redacted copy plus audit manifest. |
155
164
  | `xlsx_convert` | 25+ in / 16 out formats (csv, tsv, html, ods, xls, xlsb, dif, sylk, prn, txt, dbf, eth, json, markdown, xlsx, etc.). |
156
165
  | `xlsx_validate` | Cross-engine consistency check — runs the workbook through TWO independent renderers and reports cell-level divergences. |
166
+ | `xlsx_session_set_validations` | Configure per-session validation rules the server will apply to subsequent calls in the same session (e.g., reject rows missing required columns). Stateful — affects this session only. |
157
167
 
158
168
  ### Pandas-parity (compute fresh aggregates)
159
169
 
@@ -205,6 +215,17 @@ For custom MCP clients, the binary is `xlsx-for-ai-mcp` (stdio transport). Overr
205
215
  | `xlsx_receipt` | Attach an AI-generation receipt — Ed25519-signed claims describing the caller-declared agent identity (name, display name, identity URL), generation timestamp, content hash, optional source-file hashes, optional prompt hash, optional MCP tools called, and an optional description. Honesty boundary (load-bearing): the server signs the caller-declared `agent.name` — it does NOT verify the caller actually IS that agent. Cryptographic identity binding (per-agent issued signing keys) is v1.1+ scope. |
206
216
  | `xlsx_verify_receipt` | Verify a workbook's embedded receipt. Returns the same three trust signals as `xlsx_verify_stamp` plus the caller-declared agent identity AS declared (no UI affordances implying cryptographic identity verification). Use to surface "where did this file come from?" — backed by the server's signature over caller honest declaration. |
207
217
 
218
+ ### Healer — external-reference breakage
219
+
220
+ Workbooks rot. A file moves and `#REF!` propagates through every dependent formula. A Power Query connection embeds credentials nobody can rotate. A defined name points at an external workbook that doesn't exist anymore. The healer family diagnoses these classes and applies targeted cures — read-only diagnosis, simulated-before-applied repair, and a high-level intent path when the agent doesn't want to spell out individual cure operations.
221
+
222
+ | Tool | What it does |
223
+ |---|---|
224
+ | `xlsx_healer_diagnose` | Structured report of external-reference breakage — broken external refs, defined-name external refs, Power Query connections with embedded credentials, `#REF!` propagation maps, multi-hop chains. Read-only. |
225
+ | `xlsx_healer_simulate` | Show what a specific cure operation would change before applying it — same shape as `xlsx_healer_cure` but read-only. Use to preview impact when the agent is uncertain whether to proceed. |
226
+ | `xlsx_healer_cure` | Apply ONE specific cure operation (e.g., strip broken external refs, harmonize a defined name, replace `#REF!` propagation with a deterministic value). Save-As shape; the source workbook is preserved unless `confirm:true` is set with `mode:"in_place"`. |
227
+ | `xlsx_healer_intent` | High-level intent path — `make-it-work`, `make-standalone`, `migrate` — translated into the right sequence of cure ops. For when the agent knows the goal but not the operation. |
228
+
208
229
  Tool responses include a citation footer and a `_meta` block (tool name, version, tier, request ID, `powered_by`). Both pass through verbatim; nothing is stripped.
209
230
 
210
231
  ---
@@ -266,7 +287,7 @@ Annual-only — kills churn ops overhead. All paid tiers include every tool (`xl
266
287
 
267
288
  | Tier | Price | File cap | Calls/mo | Notes |
268
289
  |---|---|---|---|---|
269
- | Free | $0 | 10 MB | 10,000 | Anonymous UUID registration. All 36 read-only tools. Non-commercial use. |
290
+ | Free | $0 | 10 MB | 10,000 | Anonymous UUID registration. All 39 read-only tools. Non-commercial use. |
270
291
  | Bronze | $29/yr | 25 MB | 20,000 | Commercial use. + `xlsx_validate` cross-engine check. |
271
292
  | Silver | $99/yr | 50 MB | 40,000 | Same surface, higher caps. |
272
293
  | Gold | $199/yr | 100 MB | 100,000 | Same surface, highest caps for solo users. |
package/index.js CHANGED
@@ -218,21 +218,30 @@ function loadChecksFile(checksPath) {
218
218
  // ---------------------------------------------------------------------------
219
219
  // Heal subcommand — exposes the healer-deep HTTP routes from the CLI.
220
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]
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> apply one cure operation
224
+ // xlsx-for-ai heal <path> --intent <make-it-work|make-standalone|migrate>
225
+ // [--from <prefix>] [--to <prefix>] intent-driven cure (plan + apply)
226
+ // [--confirm] required for --mode in_place
227
+ // xlsx-for-ai heal <path> [--mode as_copy|in_place] [--out <path>] [--format text|json]
225
228
  //
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.
229
+ // The intent path requires the /api/v1/tools/xlsx_healer_intent route
230
+ // to be live; it's been deployed since 2026-06-04. `--from` and `--to`
231
+ // are required when intent=migrate (the route also enforces this); for
232
+ // other intents they're ignored.
230
233
  // ---------------------------------------------------------------------------
231
234
 
235
+ const VALID_INTENTS = new Set(['make-it-work', 'make-standalone', 'migrate']);
236
+
232
237
  async function runHealSubcommand(rest) {
233
238
  if (rest.length === 0 || rest[0].startsWith('-')) {
234
239
  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',
240
+ 'Usage: xlsx-for-ai heal <file.xlsx>\n' +
241
+ ' [--diagnose-only]\n' +
242
+ ' [--operation <op> --params <json>]\n' +
243
+ ' [--intent <make-it-work|make-standalone|migrate> [--from <prefix>] [--to <prefix>] [--confirm]]\n' +
244
+ ' [--mode as_copy|in_place] [--out <path>] [--format text|json]\n',
236
245
  );
237
246
  process.exit(2);
238
247
  }
@@ -246,6 +255,10 @@ async function runHealSubcommand(rest) {
246
255
  let diagnoseOnly = false;
247
256
  let operation = null;
248
257
  let paramsJson = null;
258
+ let intent = null;
259
+ let intentFrom = null;
260
+ let intentTo = null;
261
+ let confirm = false;
249
262
  let mode = 'as_copy';
250
263
  let outPath = null;
251
264
  let format = 'text';
@@ -254,13 +267,14 @@ async function runHealSubcommand(rest) {
254
267
  if (a === '--diagnose-only') diagnoseOnly = true;
255
268
  else if (a === '--operation') operation = nextRequiredArg(rest, i++, '--operation');
256
269
  else if (a === '--params') paramsJson = nextRequiredArg(rest, i++, '--params');
270
+ else if (a === '--intent') intent = nextRequiredArg(rest, i++, '--intent');
271
+ else if (a === '--from') intentFrom = nextRequiredArg(rest, i++, '--from');
272
+ else if (a === '--to') intentTo = nextRequiredArg(rest, i++, '--to');
273
+ else if (a === '--confirm') confirm = true;
257
274
  else if (a === '--mode') mode = nextRequiredArg(rest, i++, '--mode');
258
275
  else if (a === '--out') outPath = nextRequiredArg(rest, i++, '--out');
259
276
  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 {
277
+ else {
264
278
  process.stderr.write(`Unknown flag: ${a}\n`);
265
279
  process.exit(2);
266
280
  }
@@ -268,19 +282,43 @@ async function runHealSubcommand(rest) {
268
282
 
269
283
  // Validate mutually-exclusive shapes early — clearer than letting
270
284
  // 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');
285
+ const modeCount = (diagnoseOnly ? 1 : 0) + (operation ? 1 : 0) + (intent ? 1 : 0);
286
+ if (modeCount > 1) {
287
+ process.stderr.write(
288
+ 'xlsx-for-ai heal: --diagnose-only, --operation, and --intent are mutually exclusive — pick one.\n',
289
+ );
273
290
  process.exit(2);
274
291
  }
275
- if (!diagnoseOnly && !operation) {
292
+ if (modeCount === 0) {
276
293
  // Default to diagnose-only — first-touch use of `xlsx-for-ai heal`
277
294
  // should show the user what's wrong before they pick a cure.
278
295
  diagnoseOnly = true;
279
296
  }
297
+ if (intent !== null && !VALID_INTENTS.has(intent)) {
298
+ process.stderr.write(
299
+ `xlsx-for-ai heal: --intent must be one of: ${[...VALID_INTENTS].join(', ')} (got '${intent}')\n`,
300
+ );
301
+ process.exit(2);
302
+ }
303
+ if (intent === 'migrate' && (!intentFrom || !intentTo)) {
304
+ process.stderr.write(
305
+ 'xlsx-for-ai heal: --intent migrate requires both --from <prefix> and --to <prefix>\n',
306
+ );
307
+ process.exit(2);
308
+ }
280
309
  if (mode !== 'as_copy' && mode !== 'in_place') {
281
310
  process.stderr.write(`xlsx-for-ai heal: --mode must be 'as_copy' or 'in_place' (got '${mode}')\n`);
282
311
  process.exit(2);
283
312
  }
313
+ if (mode === 'in_place' && !confirm && (operation || intent)) {
314
+ // Server-side intent route enforces confirm for in_place — surface
315
+ // the same gate client-side so the CLI's error message matches the
316
+ // intent of the safety check (don't overwrite without confirmation).
317
+ process.stderr.write(
318
+ 'xlsx-for-ai heal: --mode in_place requires --confirm (explicit overwrite gate)\n',
319
+ );
320
+ process.exit(2);
321
+ }
284
322
  if (format !== 'text' && format !== 'json') {
285
323
  process.stderr.write(`xlsx-for-ai heal: --format must be 'text' or 'json' (got '${format}')\n`);
286
324
  process.exit(2);
@@ -316,31 +354,52 @@ async function runHealSubcommand(rest) {
316
354
  return 0;
317
355
  }
318
356
 
319
- // ---- cure path ---------------------------------------------------------
320
- let cureParams = {};
321
- if (paramsJson !== null) {
357
+ // ---- cure or intent path -----------------------------------------------
358
+ let result;
359
+ let healLabel; // for the friendly error prefix
360
+ if (intent) {
361
+ // Intent path — server plans + applies. intent_params carries the
362
+ // optional from/to prefix pair for the migrate intent.
363
+ const intentParams = {};
364
+ if (intentFrom) intentParams.from = intentFrom;
365
+ if (intentTo) intentParams.to = intentTo;
366
+ const body = { file_b64: fileB64, intent, mode };
367
+ if (Object.keys(intentParams).length > 0) body.intent_params = intentParams;
368
+ if (mode === 'in_place') body.confirm = true;
369
+ healLabel = `--intent ${intent}`;
322
370
  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);
371
+ result = await callTool('xlsx_healer_intent', body);
372
+ } catch (err) {
373
+ process.stderr.write(friendlyCliError(`xlsx-for-ai heal ${healLabel}`, err) + '\n');
374
+ process.exit(err.code === 'API_UNREACHABLE' || err.code === 'API_SERVER_ERROR' ? 3 : 1);
327
375
  }
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);
376
+ } else {
377
+ let cureParams = {};
378
+ if (paramsJson !== null) {
379
+ try {
380
+ cureParams = JSON.parse(paramsJson);
381
+ } catch (e) {
382
+ process.stderr.write(`xlsx-for-ai heal: --params is not valid JSON: ${e.message}\n`);
383
+ process.exit(2);
384
+ }
385
+ if (cureParams === null || typeof cureParams !== 'object' || Array.isArray(cureParams)) {
386
+ process.stderr.write('xlsx-for-ai heal: --params must be a JSON object\n');
387
+ process.exit(2);
388
+ }
331
389
  }
332
- }
333
390
 
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);
391
+ const body = { file_b64: fileB64, operation, cure_params: cureParams, mode };
392
+ if (mode === 'in_place') body.confirm = true;
393
+ healLabel = `--operation ${operation}`;
394
+ try {
395
+ result = await callTool('xlsx_healer_cure', body);
396
+ } catch (err) {
397
+ // Same sanitization shape as the diagnose path friendlyCliError
398
+ // (above) maps known codes to canned messages; raw err.message
399
+ // only surfaces with XFA_DEBUG=1 for incident triage.
400
+ process.stderr.write(friendlyCliError(`xlsx-for-ai heal ${healLabel}`, err) + '\n');
401
+ process.exit(err.code === 'API_UNREACHABLE' || err.code === 'API_SERVER_ERROR' ? 3 : 1);
402
+ }
344
403
  }
345
404
 
346
405
  const meta = (result && result._meta) || {};
@@ -116,7 +116,53 @@ function applyAnnotations(tools) {
116
116
  });
117
117
  }
118
118
 
119
+ /**
120
+ * Sanitize an MCP-shaped tool array so every entry has the fields the MCP
121
+ * spec requires for client registration: `name` (already required), plus a
122
+ * non-empty `inputSchema` and a non-empty `description`.
123
+ *
124
+ * Floor strategy:
125
+ * - If `inputSchema` is missing or not an object, substitute the permissive
126
+ * `{ type: 'object' }`. Claude Desktop and other strict clients drop
127
+ * tools without an inputSchema; the permissive object schema is enough
128
+ * for them to REGISTER the tool. Real per-arg schemas are upstream
129
+ * (server-side /api/v1/tools/list) work; this is the unblocking floor.
130
+ * - If `description` is missing or empty, substitute the annotation title
131
+ * (if any) or a generic `xlsx-for-ai tool: <name>` so the tool surfaces
132
+ * in client UIs that key off description text.
133
+ * - Tools without a `name` field are dropped (the MCP spec requires it
134
+ * and dispatch would have nothing to route by anyway).
135
+ *
136
+ * SPM P0 2026-06-05 (mcp-toolslist-missing-inputschema). The hosted
137
+ * /api/v1/tools/list endpoint currently returns minimal entries
138
+ * ({name, category, maturity_state, endpoint}); the field-level mergeTools
139
+ * upstream of this preserves the baked-in inputSchema/description for the
140
+ * names the client ships, but server-only tools (e.g. newer additions not
141
+ * yet in the baked TOOLS array) still need a floor so they don't poison
142
+ * the whole tools/list.
143
+ */
144
+ function sanitizeForMcp(tools) {
145
+ if (!Array.isArray(tools)) return [];
146
+ const out = [];
147
+ for (const t of tools) {
148
+ if (!t || typeof t.name !== 'string' || !t.name) continue;
149
+ const fixed = { ...t };
150
+ if (!fixed.inputSchema || typeof fixed.inputSchema !== 'object' || Array.isArray(fixed.inputSchema)) {
151
+ fixed.inputSchema = { type: 'object' };
152
+ }
153
+ if (!fixed.description || typeof fixed.description !== 'string') {
154
+ const annTitle = fixed.annotations && typeof fixed.annotations.title === 'string'
155
+ ? fixed.annotations.title
156
+ : null;
157
+ fixed.description = annTitle || `xlsx-for-ai tool: ${fixed.name}`;
158
+ }
159
+ out.push(fixed);
160
+ }
161
+ return out;
162
+ }
163
+
119
164
  module.exports = {
120
165
  TOOL_ANNOTATIONS,
121
166
  applyAnnotations,
167
+ sanitizeForMcp,
122
168
  };
package/lib/discover.js CHANGED
@@ -106,19 +106,35 @@ async function fetchRemoteCatalog() {
106
106
  }
107
107
 
108
108
  /**
109
- * mergeTools: server catalog wins on name collision; baked-in tools fill gaps.
110
- * Order: every remote tool first (preserving server order), then any baked-in
111
- * tool whose name isn't in the remote set. This way the most up-to-date
112
- * description always wins, but we never lose a tool the client knows how to
113
- * dispatch even if the server temporarily forgets it.
109
+ * mergeTools: server catalog wins on name collision, but FIELD-BY-FIELD
110
+ * the remote response only overwrites fields it actually provides. The
111
+ * baked-in description + inputSchema survive when the server returns a
112
+ * minimal manifest (which is exactly what /api/v1/tools/list does today:
113
+ * {name, category, maturity_state, endpoint} only).
114
+ *
115
+ * Without this, Claude Desktop receives a tools/list whose entries have no
116
+ * inputSchema and silently drops the whole array — tools panel empty, no
117
+ * tools/call ever fires. SPM P0 2026-06-05 (mcp-toolslist-missing-inputschema).
118
+ *
119
+ * Order: remote tools first (preserving server order), then any baked-in
120
+ * tool whose name isn't in the remote set. That way the server can still
121
+ * remove a tool, and a tool the client knows how to dispatch survives a
122
+ * server forgetting it.
114
123
  */
115
124
  function mergeTools(remote, baked) {
125
+ const bakedByName = new Map();
126
+ for (const t of baked) {
127
+ if (t && typeof t.name === 'string') bakedByName.set(t.name, t);
128
+ }
116
129
  const out = [];
117
130
  const seen = new Set();
118
131
  for (const t of remote) {
119
132
  if (!t || typeof t.name !== 'string') continue;
120
133
  if (seen.has(t.name)) continue; // dedupe within remote too — first wins
121
- out.push(t);
134
+ const bakedTool = bakedByName.get(t.name);
135
+ // {...baked, ...remote}: remote wins on every field it actually has;
136
+ // baked fills in fields remote omits (description, inputSchema).
137
+ out.push(bakedTool ? { ...bakedTool, ...t } : t);
122
138
  seen.add(t.name);
123
139
  }
124
140
  for (const t of baked) {
package/mcp.js CHANGED
@@ -17,7 +17,7 @@ const { ensureRegistered } = require('./lib/register');
17
17
  const { callTool } = require('./lib/client');
18
18
  const { fallbackRead } = require('./lib/fallback-read');
19
19
  const { resolveCatalog } = require('./lib/discover');
20
- const { applyAnnotations } = require('./lib/annotations');
20
+ const { applyAnnotations, sanitizeForMcp } = require('./lib/annotations');
21
21
  const fs = require('fs');
22
22
  const fsPromises = require('fs/promises');
23
23
  const path = require('path');
@@ -1045,6 +1045,193 @@ const TOOLS = [
1045
1045
  required: ['file_path'],
1046
1046
  },
1047
1047
  },
1048
+
1049
+ {
1050
+ name: 'xlsx_healer_diagnose',
1051
+ description:
1052
+ 'xlsx-for-ai — read, write, diff, redact, supervise .xlsx files locally.\n' +
1053
+ 'This tool: produce a structured diagnostic report of external references that are broken or at risk in a workbook. Returns five classes of finding: (1) external-workbook references that can\'t resolve, (2) defined-name external refs, (3) Power Query connections with embedded credentials, (4) #REF! propagation maps from upstream breakage, (5) multi-hop chains (workbook → workbook → workbook). Findings carry reference_id keys that downstream cure operations key on.\n\n' +
1054
+ 'USE WHEN: a workbook shows #REF! errors, an agent moves a file and refs need rewriting, a customer reports "the workbook stopped working after we reorganized SharePoint", or auditing a corpus for hidden external-link breakage before sharing.\n\n' +
1055
+ 'DO NOT USE WHEN: the user wants the cleaning/normalization surface (use xlsx_data_clean — different concern). Or when there is no .xlsx source path (Healer reads the source bytes, doesn\'t reconstruct from a structured spec).',
1056
+ inputSchema: {
1057
+ type: 'object',
1058
+ properties: {
1059
+ file_path: { type: 'string', description: 'Absolute path to the .xlsx file to diagnose.' },
1060
+ },
1061
+ required: ['file_path'],
1062
+ },
1063
+ },
1064
+
1065
+ {
1066
+ name: 'xlsx_healer_cure',
1067
+ description:
1068
+ 'xlsx-for-ai — read, write, diff, redact, supervise .xlsx files locally.\n' +
1069
+ 'This tool: apply ONE specific cure operation against a diagnosed workbook. Each operation targets a specific failure mode: rename_move (rewrite ref paths), pattern_bulk (regex-style ref rewrites), source_deleted_freeze (replace broken refs with cached values), source_deleted_redirect (point at a replacement file), source_deleted_localize (snapshot full external source into a local copy), permission_denied (strip credentials), structure_changed (rewrite formulas for moved cells), format_change (re-link after extension change), make_standalone (fully dereference all externals). Returns the cured workbook bytes + a receipt naming exactly what changed.\n\n' +
1070
+ 'USE WHEN: a diagnostic report (xlsx_healer_diagnose) named a specific operation as the recommended fix and the user confirmed it; or running a known recipe across a folder of files; or restoring a workbook whose source moved by a known prefix.\n\n' +
1071
+ 'DO NOT USE WHEN: the failure mode isn\'t one of the supported operations (use xlsx_healer_intent for goal-shaped fixes). Or when diagnose hasn\'t been run yet on the file (cures need diagnose-emitted reference_ids).',
1072
+ inputSchema: {
1073
+ type: 'object',
1074
+ properties: {
1075
+ file_path: { type: 'string', description: 'Absolute path to the .xlsx file to cure.' },
1076
+ operation: {
1077
+ type: 'string',
1078
+ description: 'The cure operation to apply.',
1079
+ enum: [
1080
+ 'rename_move',
1081
+ 'pattern_bulk',
1082
+ 'source_deleted_freeze',
1083
+ 'source_deleted_redirect',
1084
+ 'source_deleted_localize',
1085
+ 'permission_denied',
1086
+ 'structure_changed',
1087
+ 'format_change',
1088
+ 'make_standalone',
1089
+ 'chain_collapse',
1090
+ 'modernize_to_pq',
1091
+ ],
1092
+ },
1093
+ cure_params: {
1094
+ type: 'object',
1095
+ description: 'Operation-specific parameters. E.g., rename_move takes {from_prefix, to_prefix}; pattern_bulk takes {pattern, replacement}.',
1096
+ },
1097
+ mode: {
1098
+ type: 'string',
1099
+ enum: ['as_copy', 'in_place'],
1100
+ description: 'as_copy (default) writes a new file alongside the source; in_place overwrites it.',
1101
+ },
1102
+ confirm: {
1103
+ type: 'boolean',
1104
+ description: 'Required as true when mode=in_place. Prevents accidental in-place overwrites; explicit confirmation is the safety gate.',
1105
+ },
1106
+ out_path: { type: 'string', description: 'Optional: write the cured workbook to this absolute path. Defaults to <name>-healed.xlsx next to the source when mode=as_copy.' },
1107
+ },
1108
+ required: ['file_path', 'operation'],
1109
+ },
1110
+ },
1111
+
1112
+ {
1113
+ name: 'xlsx_healer_simulate',
1114
+ description:
1115
+ 'xlsx-for-ai — read, write, diff, redact, supervise .xlsx files locally.\n' +
1116
+ 'This tool: simulate recipient-side accessibility of a workbook\'s external references. Given a list of paths the recipient CAN see (`accessible_paths`), returns which references will still resolve at the recipient end and which will break (and why). Read-only; produces no output workbook.\n\n' +
1117
+ 'USE WHEN: an agent or user wants to know "will this workbook work when I send it to <person>?" before sharing — e.g., before posting to Slack, attaching to email, or sharing a OneDrive link. Or auditing a workbook against a known recipient-accessible-paths inventory.\n\n' +
1118
+ 'DO NOT USE WHEN: the user wants to FIX the breakage (use xlsx_healer_cure or xlsx_healer_intent). Or when the recipient is the sender themselves (no path discrepancy to simulate).',
1119
+ inputSchema: {
1120
+ type: 'object',
1121
+ properties: {
1122
+ file_path: { type: 'string', description: 'Absolute path to the .xlsx file to simulate.' },
1123
+ accessible_paths: {
1124
+ type: 'array',
1125
+ items: { type: 'string' },
1126
+ description: 'List of paths (absolute or URL) the recipient CAN see. Max 1000 entries, each ≤4096 chars. Often a folder tree or a SharePoint root.',
1127
+ },
1128
+ },
1129
+ required: ['file_path', 'accessible_paths'],
1130
+ },
1131
+ },
1132
+
1133
+ {
1134
+ name: 'xlsx_healer_intent',
1135
+ description:
1136
+ 'xlsx-for-ai — read, write, diff, redact, supervise .xlsx files locally.\n' +
1137
+ 'This tool: goal-driven healing. Caller declares an INTENT (`make-it-work`, `make-standalone`, or `migrate`) instead of a specific cure operation; Healer plans the operation sequence + applies it. make-it-work: minimum surgery to clear errors. make-standalone: fully de-externalize the workbook (snapshot every external dep). migrate: rewrite all references against a from/to prefix pair. Returns the planned operations, the cured bytes, and an unactionable list for refs that couldn\'t be auto-resolved.\n\n' +
1138
+ 'USE WHEN: the user describes the goal in plain English ("just make this work for the recipient" / "send a fully self-contained version" / "we moved the share root, update the refs"). Or when multiple cure operations need to compose and the orchestration is non-trivial.\n\n' +
1139
+ 'DO NOT USE WHEN: the user has already chosen a specific cure operation (use xlsx_healer_cure directly — avoids the planning overhead). Or when no diagnostic has been run on the workbook yet (intent uses the diagnostic surface internally; running diagnose first surfaces what intent will work with).',
1140
+ inputSchema: {
1141
+ type: 'object',
1142
+ properties: {
1143
+ file_path: { type: 'string', description: 'Absolute path to the .xlsx file to heal.' },
1144
+ intent: {
1145
+ type: 'string',
1146
+ enum: ['make-it-work', 'make-standalone', 'migrate'],
1147
+ description: 'The healing goal. make-it-work: smallest surgery to clear errors. make-standalone: fully dereference all externals. migrate: rewrite against a from/to prefix pair (requires intent_params.from + intent_params.to).',
1148
+ },
1149
+ intent_params: {
1150
+ type: 'object',
1151
+ properties: {
1152
+ from: { type: 'string', description: 'Source path prefix (required for migrate intent).' },
1153
+ to: { type: 'string', description: 'Target path prefix (required for migrate intent).' },
1154
+ },
1155
+ description: 'Intent-specific parameters. Required keys depend on the intent (migrate needs from + to).',
1156
+ },
1157
+ mode: {
1158
+ type: 'string',
1159
+ enum: ['as_copy', 'in_place'],
1160
+ description: 'as_copy (default) writes a new file alongside the source; in_place overwrites it.',
1161
+ },
1162
+ confirm: {
1163
+ type: 'boolean',
1164
+ description: 'Required as true when mode=in_place. Prevents accidental in-place overwrites; explicit confirmation is the safety gate.',
1165
+ },
1166
+ out_path: { type: 'string', description: 'Optional: write the cured workbook to this absolute path. Defaults to <name>-healed.xlsx next to the source when mode=as_copy.' },
1167
+ },
1168
+ required: ['file_path', 'intent'],
1169
+ },
1170
+ },
1171
+ {
1172
+ name: 'xlsx_read_handle',
1173
+ description:
1174
+ 'xlsx-for-ai — read, write, diff, redact, supervise .xlsx files locally.\n' +
1175
+ 'This tool: read a workbook that has already been uploaded to the server via the chunked upload flow, by its server-side cache handle, WITHOUT re-transferring the bytes. Returns the same shape as xlsx_read (text / json / markdown) but skips the file_b64 round-trip.\n\n' +
1176
+ 'USE WHEN: the workbook has already been chunked + finalized into the server-side workbook cache (a `workbook_handle` was returned from the finalize call) and you want to read it again — e.g., a multi-step session where the same large workbook is queried repeatedly. Avoids re-uploading the bytes on every call.\n\n' +
1177
+ 'DO NOT USE WHEN: you have a local file path and no prior upload (use xlsx_read — it handles the file_b64 transport for you). Handles expire when the cache TTL elapses; the call returns a clear "not found / expired" error in that case.',
1178
+ inputSchema: {
1179
+ type: 'object',
1180
+ properties: {
1181
+ workbook_handle: {
1182
+ type: 'string',
1183
+ description: 'Server-side cache handle returned by the chunked-upload finalize call. 1-128 chars.',
1184
+ minLength: 1,
1185
+ maxLength: 128,
1186
+ },
1187
+ sheet: { type: 'string', description: 'Optional: restrict the read to a single sheet by name.' },
1188
+ format: {
1189
+ type: 'string',
1190
+ enum: ['md', 'json'],
1191
+ description: 'Output format. Defaults to md.',
1192
+ },
1193
+ },
1194
+ required: ['workbook_handle'],
1195
+ },
1196
+ },
1197
+ {
1198
+ name: 'xlsx_session_set_validations',
1199
+ description:
1200
+ 'xlsx-for-ai — read, write, diff, redact, supervise .xlsx files locally.\n' +
1201
+ 'This tool: configure per-session data-validation rules the server will apply to subsequent calls in the same session (e.g., reject rows missing required columns, enforce enum values on a category column, range-bound numeric inputs). Stateful — affects this session only.\n\n' +
1202
+ 'USE WHEN: the workflow has multiple write/clean steps in sequence and you want consistent server-side validation across them without restating the rules on every call. Or when validating user-supplied data against a known schema you want enforced for the rest of the session.\n\n' +
1203
+ 'DO NOT USE WHEN: you only have a single call to make (just include the validation logic in that call). Or when you do not have a `session_id` (sessions are returned from the session-create surface; this tool is a no-op without one).',
1204
+ inputSchema: {
1205
+ type: 'object',
1206
+ properties: {
1207
+ session_id: {
1208
+ type: 'string',
1209
+ description: 'Session identifier returned by the session-create surface. 16-128 chars.',
1210
+ minLength: 16,
1211
+ maxLength: 128,
1212
+ },
1213
+ validations: {
1214
+ type: 'array',
1215
+ description: 'List of validation rules to apply. Each rule names a sheet, a cell ref (e.g., "A1:A100"), and a type (whole|decimal|list|date|time|textLength|custom).',
1216
+ minItems: 1,
1217
+ maxItems: 5000,
1218
+ items: {
1219
+ type: 'object',
1220
+ properties: {
1221
+ sheet: { type: 'string', description: 'Target sheet name.' },
1222
+ ref: { type: 'string', description: 'A1-style cell range the rule applies to.' },
1223
+ type: {
1224
+ type: 'string',
1225
+ description: 'Validation type. Server-side enum: whole, decimal, list, date, time, textLength, custom.',
1226
+ },
1227
+ },
1228
+ required: ['sheet', 'ref', 'type'],
1229
+ },
1230
+ },
1231
+ },
1232
+ required: ['session_id', 'validations'],
1233
+ },
1234
+ },
1048
1235
  ];
1049
1236
 
1050
1237
  // ---------------------------------------------------------------------------
@@ -1567,6 +1754,75 @@ async function dispatchTool(name, args) {
1567
1754
  return callTool('xlsx_verify_receipt', body);
1568
1755
  }
1569
1756
 
1757
+ // xlsx_healer_diagnose: produce structured diagnostic report of broken/
1758
+ // at-risk external refs. Read-only; returns the report in the response
1759
+ // _meta block. No output file.
1760
+ if (name === 'xlsx_healer_diagnose') {
1761
+ const body = { file_b64: fileToB64(args.file_path) };
1762
+ return callTool('xlsx_healer_diagnose', body);
1763
+ }
1764
+
1765
+ // xlsx_healer_cure: apply ONE specific cure operation. Returns the
1766
+ // cured bytes in _meta.file_b64 + a per-operation receipt; out_path
1767
+ // (or in_place mode) triggers the standard applyFileB64 disk write.
1768
+ if (name === 'xlsx_healer_cure') {
1769
+ const body = {
1770
+ file_b64: fileToB64(args.file_path),
1771
+ operation: args.operation,
1772
+ };
1773
+ if (args.cure_params !== undefined) body.cure_params = args.cure_params;
1774
+ if (args.mode !== undefined) body.mode = args.mode;
1775
+ if (args.confirm !== undefined) body.confirm = args.confirm;
1776
+ const result = await callTool('xlsx_healer_cure', body);
1777
+ return applyFileB64(result, args.out_path);
1778
+ }
1779
+
1780
+ // xlsx_healer_simulate: recipient-side accessibility check. Read-only;
1781
+ // returns the simulation report in _meta. No output file.
1782
+ if (name === 'xlsx_healer_simulate') {
1783
+ const body = {
1784
+ file_b64: fileToB64(args.file_path),
1785
+ accessible_paths: args.accessible_paths,
1786
+ };
1787
+ return callTool('xlsx_healer_simulate', body);
1788
+ }
1789
+
1790
+ // xlsx_healer_intent: goal-driven healing (plan + apply). Returns the
1791
+ // planned operations + cured bytes + unactionable list. Same out_path /
1792
+ // in_place semantics as xlsx_healer_cure.
1793
+ if (name === 'xlsx_healer_intent') {
1794
+ const body = {
1795
+ file_b64: fileToB64(args.file_path),
1796
+ intent: args.intent,
1797
+ };
1798
+ if (args.intent_params !== undefined) body.intent_params = args.intent_params;
1799
+ if (args.mode !== undefined) body.mode = args.mode;
1800
+ if (args.confirm !== undefined) body.confirm = args.confirm;
1801
+ const result = await callTool('xlsx_healer_intent', body);
1802
+ return applyFileB64(result, args.out_path);
1803
+ }
1804
+
1805
+ // Handle-based read (no file_b64; the bytes are already in the server
1806
+ // cache from a prior chunked-upload finalize). Body mirrors the server
1807
+ // schema in routes/xlsx-read-handle.ts.
1808
+ if (name === 'xlsx_read_handle') {
1809
+ const options = {};
1810
+ if (args.sheet !== undefined) options.sheet = args.sheet;
1811
+ if (args.format !== undefined) options.format = args.format;
1812
+ const body = { workbook_handle: args.workbook_handle };
1813
+ if (Object.keys(options).length > 0) body.options = options;
1814
+ return callTool('xlsx_read_handle', body);
1815
+ }
1816
+
1817
+ // Session-state write — no file bytes, just session_id + validation rules.
1818
+ // Body mirrors the server schema in routes/xlsx-session-set-validations.ts.
1819
+ if (name === 'xlsx_session_set_validations') {
1820
+ return callTool('xlsx_session_set_validations', {
1821
+ session_id: args.session_id,
1822
+ validations: args.validations,
1823
+ });
1824
+ }
1825
+
1570
1826
  // All other tools (list_sheets, schema, hyperlinks, conditional_formats,
1571
1827
  // styles, etc.) — single-file relay. Forward any common option keys the
1572
1828
  // routes accept so we don't silently drop them. New keys added here as
@@ -1587,46 +1843,56 @@ async function dispatchTool(name, args) {
1587
1843
  // ---------------------------------------------------------------------------
1588
1844
 
1589
1845
  async function main() {
1590
- await ensureRegistered();
1846
+ // Swallow EPIPE on the transport. When the client disconnects while a
1847
+ // background catalog upgrade is still in flight, sendToolListChanged
1848
+ // writes to a closed pipe and Node raises EPIPE asynchronously on the
1849
+ // Socket — our awaited try/catch around sendToolListChanged never sees
1850
+ // it. Without this guard, a client unplug after the upgrade settles
1851
+ // crashes the process with an unhandled Socket 'error' event.
1852
+ //
1853
+ // stdout is the MCP transport: EPIPE there means the client is gone,
1854
+ // exit cleanly. stderr is the log sink: an EPIPE on stderr (parent
1855
+ // closed its log pipe) is NOT a transport failure and must not take
1856
+ // the server down.
1857
+ process.stdout.on('error', (err) => {
1858
+ if (err && err.code === 'EPIPE') {
1859
+ process.exit(0);
1860
+ }
1861
+ // Anything else on the transport stream is a real failure (e.g.
1862
+ // ERR_STREAM_DESTROYED) — rethrow so it surfaces as uncaughtException
1863
+ // instead of being silently swallowed.
1864
+ throw err;
1865
+ });
1866
+ process.stderr.on('error', (err) => {
1867
+ // Silence EPIPE on stderr; rethrow anything else so we don't hide
1868
+ // genuine logging-layer bugs.
1869
+ if (!err || err.code !== 'EPIPE') throw err;
1870
+ });
1591
1871
 
1592
- // Dynamic tool catalog: query the hosted API once at startup so new
1593
- // server-side tools appear without re-publishing this npm package.
1594
- // resolveCatalog returns the baked-in TOOLS as last-resort fallback so
1595
- // we never fail-open on a transient network blip. See lib/discover.js.
1872
+ // `initialize` MUST respond from local state never block on the network.
1873
+ // Under Claude Desktop's bundled Node 24.x runtime, the registration POST
1874
+ // and the catalog GET can hang indefinitely (Happy-Eyeballs / IPv6 dial
1875
+ // edge cases inside Electron), and the client gives up at 60s. The whole
1876
+ // MCP attach dies before tools/list is even called.
1596
1877
  //
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;
1602
- let catalog;
1603
- try {
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
- ]);
1613
- } catch (_) {
1614
- catalog = { tools: TOOLS, source: 'static-fallback' };
1615
- }
1616
- // Surface catalog source so operators can tell server vs cache vs static
1617
- // when an MCP session looks "off" (e.g., a tool missing because the remote
1618
- // /api/v1/tools/list 404'd and we silently fell back to the stale baked-in
1619
- // set). Stderr only — stdout is the MCP transport.
1620
- process.stderr.write(`xlsx-for-ai-mcp: tool catalog source=${catalog.source} count=${Array.isArray(catalog.tools) ? catalog.tools.length : 0}\n`);
1621
- // Overlay MCP annotations (title / readOnlyHint / destructiveHint) so
1622
- // they flow through to clients regardless of catalog source. The remote
1623
- // /api/v1/tools/list returns minimal entries today; this is what
1624
- // restores the annotations the wire format would otherwise drop.
1625
- const liveTools = applyAnnotations(Array.isArray(catalog.tools) ? catalog.tools : []);
1878
+ // Shape: connect transport FIRST with the bundled TOOLS as the floor.
1879
+ // Then background-upgrade registration + catalog with bounded timeouts,
1880
+ // and fire notifications/tools/list_changed once the live catalog lands.
1881
+ // The bundled set already covers every tool the user reaches in normal
1882
+ // flows; the upgrade is additive.
1883
+ // sanitizeForMcp guarantees every tool the server emits has a valid
1884
+ // inputSchema + description — without it Claude Desktop silently drops
1885
+ // tools that lack inputSchema, which is the exact symptom in SPM P0
1886
+ // 2026-06-05 (mcp-toolslist-missing-inputschema). For the bundled
1887
+ // catalog this is a no-op (every TOOLS entry already has full fields);
1888
+ // for the upgraded catalog it's the floor that keeps stub server
1889
+ // entries registerable.
1890
+ let liveTools = sanitizeForMcp(applyAnnotations(TOOLS));
1891
+ process.stderr.write(`xlsx-for-ai-mcp: tool catalog source=bundled count=${liveTools.length}\n`);
1626
1892
 
1627
1893
  const server = new Server(
1628
1894
  { name: 'xlsx-for-ai', version: require('./package.json').version },
1629
- { capabilities: { tools: {} } }
1895
+ { capabilities: { tools: { listChanged: true } } }
1630
1896
  );
1631
1897
 
1632
1898
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: liveTools }));
@@ -1670,6 +1936,88 @@ async function main() {
1670
1936
 
1671
1937
  const transport = new StdioServerTransport();
1672
1938
  await server.connect(transport);
1939
+
1940
+ // Background-upgrade: registration + dynamic catalog. Bounded so a
1941
+ // hung network never wastes resources; failure is non-fatal because
1942
+ // the bundled catalog already serves tools/list. Detached on purpose
1943
+ // — we do not await this; main() returns and the upgrade lands when
1944
+ // it lands.
1945
+ upgradeCatalogInBackground(server, (next) => {
1946
+ liveTools = next;
1947
+ });
1948
+ }
1949
+
1950
+ async function withTimeout(promise, ms, label) {
1951
+ // Promise.race with a setTimeout-rejecting promise leaks unhandled
1952
+ // rejections in two directions:
1953
+ // (a) Main wins — the timer still fires later and its branch
1954
+ // rejects with nobody awaiting it. clearTimeout in finally
1955
+ // eliminates this.
1956
+ // (b) Timer wins — the original promise can still reject later
1957
+ // (the underlying fetch eventually errors out long after we
1958
+ // gave up). Attaching a no-op catch ensures that late
1959
+ // rejection is consumed instead of crashing the MCP server
1960
+ // minutes after startup.
1961
+ // The (b) case is the SPM P0 surface: the bundled-Node-24 dial
1962
+ // can stall, time out, and then much later reject with EAI_AGAIN
1963
+ // or a TLS error — by then nobody is listening.
1964
+ promise.catch(() => {});
1965
+ let timer;
1966
+ try {
1967
+ return await Promise.race([
1968
+ promise,
1969
+ new Promise((_, reject) => {
1970
+ timer = setTimeout(
1971
+ () => reject(new Error(`${label} timed out after ${ms}ms`)),
1972
+ ms
1973
+ );
1974
+ }),
1975
+ ]);
1976
+ } finally {
1977
+ if (timer) clearTimeout(timer);
1978
+ }
1979
+ }
1980
+
1981
+ async function upgradeCatalogInBackground(server, swap) {
1982
+ const REGISTRATION_TIMEOUT_MS = 10_000;
1983
+ const CATALOG_TIMEOUT_MS = 8_000;
1984
+
1985
+ try {
1986
+ await withTimeout(ensureRegistered(), REGISTRATION_TIMEOUT_MS, 'registration');
1987
+ } catch (err) {
1988
+ process.stderr.write(`xlsx-for-ai-mcp: registration deferred (${err.message})\n`);
1989
+ }
1990
+
1991
+ let catalog;
1992
+ try {
1993
+ catalog = await withTimeout(resolveCatalog(TOOLS), CATALOG_TIMEOUT_MS, 'catalog fetch');
1994
+ } catch (err) {
1995
+ process.stderr.write(`xlsx-for-ai-mcp: catalog upgrade skipped (${err.message})\n`);
1996
+ return;
1997
+ }
1998
+
1999
+ if (!catalog || !Array.isArray(catalog.tools)) {
2000
+ return;
2001
+ }
2002
+ // No upgrade to apply when discover.js fell back to the baked-in set
2003
+ // (source=static): the list is identical to what initialize already
2004
+ // returned, so a list_changed notification would be wire noise.
2005
+ if (catalog.source === 'static') {
2006
+ process.stderr.write(`xlsx-for-ai-mcp: catalog upgrade unavailable (source=static) — staying on bundled\n`);
2007
+ return;
2008
+ }
2009
+
2010
+ const upgraded = sanitizeForMcp(applyAnnotations(catalog.tools));
2011
+ swap(upgraded);
2012
+ process.stderr.write(`xlsx-for-ai-mcp: tool catalog source=${catalog.source} count=${upgraded.length}\n`);
2013
+
2014
+ try {
2015
+ await server.sendToolListChanged();
2016
+ } catch (_) {
2017
+ // Transport may already be torn down (client disconnected before the
2018
+ // upgrade landed). Non-fatal — next attach starts with the bundled
2019
+ // catalog and retries the upgrade.
2020
+ }
1673
2021
  }
1674
2022
 
1675
2023
  // Guard: don't auto-start when required by tests
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.26.1",
4
+ "version": "3.0.2",
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": {