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.
- package/lib/annotations.js +13 -1
- package/lib/client.js +93 -17
- package/mcp.js +13 -1
- package/package.json +1 -1
package/lib/annotations.js
CHANGED
|
@@ -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:
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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:
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
"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": {
|