xlsx-for-ai 3.0.0 → 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
@@ -1,10 +1,8 @@
1
1
  # xlsx-for-ai
2
2
 
3
- > **⚠️ MCP users on 2.25.0–2.26.0: upgrade.** Those versions crash the MCP server on startup (missing `lib/annotations.js`, fixed in 2.26.1). Run `npm install -g xlsx-for-ai@latest` and restart your MCP client. Or switch to the `npx -y` config snippets below so future versions self-heal on every restart.
4
-
5
3
  **The missing reliability layer that makes spreadsheet reasoning production-grade for LLMs.**
6
4
 
7
- A thin npm client over a hosted API. Install once, add to your agent config, and your agent gets 48 production-grade tools for reading, writing, diffing, redacting, healing, and cryptographically attesting `.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.
8
6
 
9
7
  ```bash
10
8
  npm install -g xlsx-for-ai
@@ -24,7 +22,7 @@ Add `xlsx-for-ai` as a tool server in your agent runtime. First invocation auto-
24
22
 
25
23
  **Easiest: one-click install via the `.mcpb` bundle.** Download and drag into Claude Desktop (Settings → Extensions):
26
24
 
27
- **[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)*
28
26
 
29
27
  The bundle includes the full npm package and registers all MCP tools automatically. No manual config edits needed.
30
28
 
@@ -41,7 +39,7 @@ The bundle includes the full npm package and registers all MCP tools automatical
41
39
  }
42
40
  ```
43
41
 
44
- Verify either path: restart Claude Desktop, open a new conversation, and ask "what MCP tools do you have?" — 48 `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).
45
43
 
46
44
  ### Cursor
47
45
 
@@ -58,7 +56,7 @@ Config file: `~/.cursor/mcp.json`
58
56
  }
59
57
  ```
60
58
 
61
- 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.
62
60
 
63
61
  ### Continue
64
62
 
@@ -93,7 +91,7 @@ Pass `--mcp-server` on the command line, or add to your Codex config:
93
91
  }
94
92
  ```
95
93
 
96
- 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.
97
95
 
98
96
  ### Zed
99
97
 
@@ -139,7 +137,7 @@ For custom MCP clients, the binary is `xlsx-for-ai-mcp` (stdio transport). Overr
139
137
 
140
138
  ## What it does
141
139
 
142
- 48 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).
143
141
 
144
142
  ### Triage / orient
145
143
 
@@ -158,11 +156,14 @@ For custom MCP clients, the binary is `xlsx-for-ai-mcp` (stdio transport). Overr
158
156
  | Tool | What it does |
159
157
  |---|---|
160
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. |
161
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. |
162
162
  | `xlsx_diff` | Semantic diff between two workbooks — cell-level deltas, formula changes, structural shifts. Deterministic output. |
163
163
  | `xlsx_redact` | Redact PII from a workbook before sharing. Server-side detection; returns redacted copy plus audit manifest. |
164
164
  | `xlsx_convert` | 25+ in / 16 out formats (csv, tsv, html, ods, xls, xlsb, dif, sylk, prn, txt, dbf, eth, json, markdown, xlsx, etc.). |
165
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. |
166
167
 
167
168
  ### Pandas-parity (compute fresh aggregates)
168
169
 
@@ -214,6 +215,17 @@ For custom MCP clients, the binary is `xlsx-for-ai-mcp` (stdio transport). Overr
214
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. |
215
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. |
216
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
+
217
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.
218
230
 
219
231
  ---
@@ -275,7 +287,7 @@ Annual-only — kills churn ops overhead. All paid tiers include every tool (`xl
275
287
 
276
288
  | Tier | Price | File cap | Calls/mo | Notes |
277
289
  |---|---|---|---|---|
278
- | 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. |
279
291
  | Bronze | $29/yr | 25 MB | 20,000 | Commercial use. + `xlsx_validate` cross-engine check. |
280
292
  | Silver | $99/yr | 50 MB | 40,000 | Same surface, higher caps. |
281
293
  | Gold | $199/yr | 100 MB | 100,000 | Same surface, highest caps for solo users. |
@@ -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');
@@ -1168,6 +1168,70 @@ const TOOLS = [
1168
1168
  required: ['file_path', 'intent'],
1169
1169
  },
1170
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
+ },
1171
1235
  ];
1172
1236
 
1173
1237
  // ---------------------------------------------------------------------------
@@ -1738,6 +1802,27 @@ async function dispatchTool(name, args) {
1738
1802
  return applyFileB64(result, args.out_path);
1739
1803
  }
1740
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
+
1741
1826
  // All other tools (list_sheets, schema, hyperlinks, conditional_formats,
1742
1827
  // styles, etc.) — single-file relay. Forward any common option keys the
1743
1828
  // routes accept so we don't silently drop them. New keys added here as
@@ -1758,46 +1843,56 @@ async function dispatchTool(name, args) {
1758
1843
  // ---------------------------------------------------------------------------
1759
1844
 
1760
1845
  async function main() {
1761
- 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
+ });
1762
1871
 
1763
- // Dynamic tool catalog: query the hosted API once at startup so new
1764
- // server-side tools appear without re-publishing this npm package.
1765
- // resolveCatalog returns the baked-in TOOLS as last-resort fallback so
1766
- // 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.
1767
1877
  //
1768
- // Hard timeout (8s) on top of any timeout inside resolveCatalog so that
1769
- // a network call which hangs forever (DNS sinkhole, TCP black hole, slow-
1770
- // loris-style stalled response) cannot block MCP server startup
1771
- // indefinitely. Pre-Friday-external CRITICAL per the Tier-1 audit.
1772
- const CATALOG_FETCH_TIMEOUT_MS = 8000;
1773
- let catalog;
1774
- try {
1775
- catalog = await Promise.race([
1776
- resolveCatalog(TOOLS),
1777
- new Promise((_, reject) =>
1778
- setTimeout(
1779
- () => reject(new Error(`catalog fetch timed out after ${CATALOG_FETCH_TIMEOUT_MS}ms`)),
1780
- CATALOG_FETCH_TIMEOUT_MS
1781
- )
1782
- ),
1783
- ]);
1784
- } catch (_) {
1785
- catalog = { tools: TOOLS, source: 'static-fallback' };
1786
- }
1787
- // Surface catalog source so operators can tell server vs cache vs static
1788
- // when an MCP session looks "off" (e.g., a tool missing because the remote
1789
- // /api/v1/tools/list 404'd and we silently fell back to the stale baked-in
1790
- // set). Stderr only — stdout is the MCP transport.
1791
- process.stderr.write(`xlsx-for-ai-mcp: tool catalog source=${catalog.source} count=${Array.isArray(catalog.tools) ? catalog.tools.length : 0}\n`);
1792
- // Overlay MCP annotations (title / readOnlyHint / destructiveHint) so
1793
- // they flow through to clients regardless of catalog source. The remote
1794
- // /api/v1/tools/list returns minimal entries today; this is what
1795
- // restores the annotations the wire format would otherwise drop.
1796
- 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`);
1797
1892
 
1798
1893
  const server = new Server(
1799
1894
  { name: 'xlsx-for-ai', version: require('./package.json').version },
1800
- { capabilities: { tools: {} } }
1895
+ { capabilities: { tools: { listChanged: true } } }
1801
1896
  );
1802
1897
 
1803
1898
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: liveTools }));
@@ -1841,6 +1936,88 @@ async function main() {
1841
1936
 
1842
1937
  const transport = new StdioServerTransport();
1843
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
+ }
1844
2021
  }
1845
2022
 
1846
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": "3.0.0",
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": {