xlsx-for-ai 3.0.4 → 3.0.8

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.
@@ -65,14 +65,26 @@ const TOOL_ANNOTATIONS = Object.freeze({
65
65
  xlsx_protection: { title: 'List Excel protection settings', readOnlyHint: true, destructiveHint: false },
66
66
  xlsx_styles: { title: 'List Excel cell styles', readOnlyHint: true, destructiveHint: false },
67
67
  xlsx_verify_stamp: { title: 'Verify Excel integrity stamp', readOnlyHint: true, destructiveHint: false },
68
+ xlsx_verify_receipt: { title: 'Verify Excel provenance receipt', readOnlyHint: true, destructiveHint: false },
69
+ xlsx_read_handle: { title: 'Read Excel by handle', readOnlyHint: true, destructiveHint: false },
70
+ xlsx_healer_diagnose: { title: 'Diagnose Excel external references', readOnlyHint: true, destructiveHint: false },
71
+ xlsx_healer_simulate: { title: 'Simulate Excel reference repair', readOnlyHint: true, destructiveHint: false },
68
72
 
69
- // ---- Writing — non-destructive: 5 Save-As-shape tools -----------------
73
+ // ---- Writing — non-destructive: 9 Save-As-shape tools -----------------
70
74
  // Source workbook is preserved; output goes to a new path or returned bytes.
71
75
  xlsx_write: { title: 'Write Excel file', readOnlyHint: false, destructiveHint: false },
72
76
  xlsx_redact: { title: 'Redact Excel file', readOnlyHint: false, destructiveHint: false },
73
77
  xlsx_convert: { title: 'Convert Excel to other format', readOnlyHint: false, destructiveHint: false },
74
78
  xlsx_data_clean: { title: 'Clean Excel data', readOnlyHint: false, destructiveHint: false },
75
79
  xlsx_stamp: { title: 'Stamp Excel with integrity verification', readOnlyHint: false, destructiveHint: false },
80
+ xlsx_receipt: { title: 'Generate Excel provenance receipt', readOnlyHint: false, destructiveHint: false },
81
+ xlsx_healer_cure: { title: 'Repair Excel external references', readOnlyHint: false, destructiveHint: false },
82
+ xlsx_healer_intent: { title: 'Generate Excel repair-intent file', readOnlyHint: false, destructiveHint: false },
83
+
84
+ // ---- Stateful session write: 1 tool -----------------------------------
85
+ // Mutates session-scoped state on the server (reversible by a follow-up call).
86
+ xlsx_session_set_validations:
87
+ { title: 'Set Excel session validation rules', readOnlyHint: false, destructiveHint: false },
76
88
 
77
89
  // ---- External side-effects — destructive: 2 tools ---------------------
78
90
  // A post can't be undone; the message lands in a third-party system.
package/lib/client.js CHANGED
@@ -8,19 +8,53 @@
8
8
  * post(path, body, opts) — POST JSON, returns parsed response body
9
9
  * callTool(toolName, body) — POST /api/v1/tools/<toolName> with auth
10
10
  *
11
- * Retries once on network errors. Maps HTTP errors to structured Error objects.
11
+ * 15s per-attempt timeout, up to 3 attempts (45s ceiling). On retry the
12
+ * AbortController for the prior attempt has already fired, so any
13
+ * keep-alive socket undici held on the failing attempt is released
14
+ * before the retry opens a fresh one. Surfaces phase timing to stderr
15
+ * for production-incident diagnosis (SPM P1 2026-06-06: hosted tool
16
+ * calls timing out in Claude Desktop — server saw ~200ms responses
17
+ * but client saw 2-4 minute round-trips; the gap is in the connection
18
+ * dial / IPC layer, observability captures which next time).
12
19
  */
13
20
 
14
21
  const { readConfig } = require('./config');
15
22
  const { version: CLIENT_VERSION } = require('../package.json');
16
23
 
17
24
  const DEFAULT_API = 'https://api.xlsx-for-ai.dev';
18
- const TIMEOUT_MS = 30_000;
25
+ // Per-attempt timeout. Was 30s pre-3.0.7. Tighter so a stuck dial
26
+ // (IPv6 black hole, stale keep-alive) fails fast and the retry path
27
+ // reopens a fresh socket. 3 attempts × 15s = 45s ceiling, well under
28
+ // Claude Desktop's 60s client-side initialize timeout AND under the
29
+ // MCP tools/call timeout class.
30
+ const TIMEOUT_MS = 15_000;
31
+ const MAX_ATTEMPTS = 3;
19
32
 
20
33
  function apiBase() {
21
34
  return (process.env.XLSX_FOR_AI_API || DEFAULT_API).replace(/\/$/, '');
22
35
  }
23
36
 
37
+ // Stderr structured timing log. stdout is the MCP transport in the
38
+ // mcp-server context; never write timing data there.
39
+ function emitTiming(toolPath, attempt, phase, elapsedMs, extra) {
40
+ // One-line JSON so future log-shipper can grep / parse. Kept compact
41
+ // to stay inside Claude Desktop's MCP log buffer.
42
+ const obs = {
43
+ t: 'xlsx-for-ai-mcp.timing',
44
+ path: toolPath,
45
+ attempt,
46
+ phase,
47
+ elapsed_ms: Math.round(elapsedMs),
48
+ };
49
+ if (extra) Object.assign(obs, extra);
50
+ try {
51
+ process.stderr.write(JSON.stringify(obs) + '\n');
52
+ } catch (_) {
53
+ // EPIPE on stderr is swallowed by the mcp.js top-level guard;
54
+ // here we just no-op so a missing log sink doesn't break the call.
55
+ }
56
+ }
57
+
24
58
  async function fetchWithTimeout(url, init) {
25
59
  const controller = new AbortController();
26
60
  const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
@@ -49,32 +83,71 @@ async function post(path, body, opts = {}) {
49
83
  headers['X-XFA-Privacy'] = 'strict';
50
84
  }
51
85
 
86
+ const requestStart = Date.now();
87
+ const jsonBody = JSON.stringify(body);
88
+ // Byte length (UTF-8), not code-unit length — multi-byte chars in the
89
+ // body would otherwise be undercounted in observability.
90
+ const bodyBytes = Buffer.byteLength(jsonBody, 'utf8');
91
+ // `attempt: -1` is the convention for non-attempt-scoped events
92
+ // (per-request setup / teardown); keeps the 1..MAX_ATTEMPTS scope
93
+ // unambiguous in log analysis.
94
+ emitTiming(path, -1, 'send', 0, { body_bytes: bodyBytes });
95
+
52
96
  let res;
53
- try {
54
- res = await fetchWithTimeout(url, {
55
- method: 'POST',
56
- headers,
57
- body: JSON.stringify(body),
58
- });
59
- } catch (err) {
60
- // One retry on network error
97
+ let lastErr;
98
+ let winningAttempt = -1;
99
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
100
+ const attemptStart = Date.now();
61
101
  try {
62
102
  res = await fetchWithTimeout(url, {
63
103
  method: 'POST',
64
104
  headers,
65
- body: JSON.stringify(body),
105
+ body: jsonBody,
106
+ });
107
+ const attemptElapsed = Date.now() - attemptStart;
108
+ emitTiming(path, attempt, 'response-headers', attemptElapsed, {
109
+ status: res.status,
110
+ });
111
+ lastErr = null;
112
+ winningAttempt = attempt;
113
+ break;
114
+ } catch (err) {
115
+ lastErr = err;
116
+ const attemptElapsed = Date.now() - attemptStart;
117
+ const errName = err && err.name ? err.name : 'Unknown';
118
+ const errCode = err && err.code ? err.code : null;
119
+ emitTiming(path, attempt, 'attempt-failed', attemptElapsed, {
120
+ error_name: errName,
121
+ error_code: errCode,
66
122
  });
67
- } catch (err2) {
68
- const e = new Error(`xlsx-for-ai API unreachable: ${err2.message}`);
69
- e.code = 'API_UNREACHABLE';
70
- throw e;
123
+ // No sleep between retries — let the underlying socket pool refresh
124
+ // on the next fetch call. A sleep would just lengthen the total
125
+ // wait and the SPM-evidenced symptom is already a dial stall that
126
+ // a fresh socket fixes.
71
127
  }
72
128
  }
73
129
 
130
+ if (!res) {
131
+ const totalElapsed = Date.now() - requestStart;
132
+ emitTiming(path, MAX_ATTEMPTS, 'all-attempts-failed', totalElapsed);
133
+ const e = new Error(`xlsx-for-ai API unreachable: ${lastErr ? lastErr.message : 'unknown'}`);
134
+ e.code = 'API_UNREACHABLE';
135
+ throw e;
136
+ }
137
+
74
138
  if (!res.ok) {
75
139
  let payload;
76
140
  try { payload = await res.json(); } catch (_) { payload = null; }
77
- const msg = payload?.error || payload?.message || res.statusText;
141
+ // Prefer the structured `{code, message}` shape our server emits; the
142
+ // top-level `error` / `message` fall-back keeps the older surfaces
143
+ // working. `payload.error` could be an OBJECT — coerce to .message
144
+ // first to avoid stringifying [object Object].
145
+ const errField = payload?.error;
146
+ const msg = (errField && typeof errField === 'object' ? errField.message : errField)
147
+ || payload?.message
148
+ || res.statusText;
149
+ const totalElapsed = Date.now() - requestStart;
150
+ emitTiming(path, winningAttempt, 'http-error', totalElapsed, { status: res.status });
78
151
  const e = new Error(`xlsx-for-ai API error ${res.status}: ${msg}`);
79
152
  e.status = res.status;
80
153
  e.payload = payload;
@@ -82,7 +155,10 @@ async function post(path, body, opts = {}) {
82
155
  throw e;
83
156
  }
84
157
 
85
- return res.json();
158
+ const json = await res.json();
159
+ const totalElapsed = Date.now() - requestStart;
160
+ emitTiming(path, winningAttempt, 'body-complete', totalElapsed);
161
+ return json;
86
162
  }
87
163
 
88
164
  async function callTool(toolName, body) {
package/mcp.js CHANGED
@@ -20,6 +20,7 @@ const { resolveCatalog } = require('./lib/discover');
20
20
  const { applyAnnotations, sanitizeForMcp } = require('./lib/annotations');
21
21
  const fs = require('fs');
22
22
  const fsPromises = require('fs/promises');
23
+ const os = require('os');
23
24
  const path = require('path');
24
25
 
25
26
  // ---------------------------------------------------------------------------
@@ -1193,8 +1194,19 @@ function getMaxFileMB() {
1193
1194
  return parsed;
1194
1195
  }
1195
1196
 
1197
+ // Expand a leading `~` to the user's home dir so tilde-prefixed paths the
1198
+ // model passes ("~/Desktop/foo.xlsx") don't dead-end with ENOENT. SPM P1
1199
+ // 2026-06-06 "secondary" finding — a cheap friction-reducer.
1200
+ // Only the leading character; we don't try to resolve `~user/foo` patterns.
1201
+ function expandTilde(p) {
1202
+ if (typeof p !== 'string' || p.length === 0) return p;
1203
+ if (p === '~') return os.homedir();
1204
+ if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
1205
+ return p;
1206
+ }
1207
+
1196
1208
  function fileToB64(filePath) {
1197
- const resolved = path.resolve(filePath);
1209
+ const resolved = path.resolve(expandTilde(filePath));
1198
1210
 
1199
1211
  // Open the file once and operate on the fd from here on. fstatSync and the
1200
1212
  // subsequent read both bind to the inode the fd points at, so even if the
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.4",
4
+ "version": "3.0.8",
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": {