xlsx-for-ai 2.23.0 → 2.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -1
- package/SECURITY.md +10 -9
- package/index.js +257 -2
- package/lib/fallback-read.js +93 -6
- package/mcp.js +340 -15
- package/package.json +8 -2
package/README.md
CHANGED
|
@@ -130,7 +130,7 @@ For custom MCP clients, the binary is `xlsx-for-ai-mcp` (stdio transport). Overr
|
|
|
130
130
|
|
|
131
131
|
## What it does
|
|
132
132
|
|
|
133
|
-
|
|
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).
|
|
134
134
|
|
|
135
135
|
### Triage / orient
|
|
136
136
|
|
|
@@ -202,6 +202,8 @@ For custom MCP clients, the binary is `xlsx-for-ai-mcp` (stdio transport). Overr
|
|
|
202
202
|
|---|---|
|
|
203
203
|
| `xlsx_stamp` | Sign a workbook with a cryptographic "integrity verification" stamp — Ed25519-signed claims (named factual checks + their pass/fail/skip status + a content hash) embedded in `docProps/custom.xml`. The stamp travels with the file across saves; a recipient can verify it later to confirm the file hasn't been tampered with since signing. Factual attestations only — never an opinion-shaped seal of approval. |
|
|
204
204
|
| `xlsx_verify_stamp` | Verify a workbook's embedded stamp. Returns (a) whether the Ed25519 signature is valid against the registered public key, (b) whether the workbook bytes match the hash IN the signed claims, and (c) the full check-result content of the stamp. Three distinct trust signals — signature integrity, content integrity, and what was originally attested. |
|
|
205
|
+
| `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
|
+
| `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. |
|
|
205
207
|
|
|
206
208
|
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.
|
|
207
209
|
|
|
@@ -362,3 +364,6 @@ The config file at `~/.xlsx-for-ai/config.json` is extended in-place — existin
|
|
|
362
364
|
## Security
|
|
363
365
|
|
|
364
366
|
See [SECURITY.md](SECURITY.md). All file content is transmitted to `xlsx-for-ai-server.fly.dev` over HTTPS. Files are not retained beyond the duration of a single request on the free tier.
|
|
367
|
+
|
|
368
|
+
<!-- ci-smoke-test: 2026-05-19 grace-review workflow -->
|
|
369
|
+
<!-- retry: llm-review vendored -->
|
package/SECURITY.md
CHANGED
|
@@ -23,15 +23,16 @@ disclosure expectations in your first message.
|
|
|
23
23
|
|
|
24
24
|
## Supported versions
|
|
25
25
|
|
|
26
|
-
The latest published `
|
|
27
|
-
minors do not. Today that is `
|
|
28
|
-
it is shipped as
|
|
29
|
-
|
|
30
|
-
| Version
|
|
31
|
-
|
|
32
|
-
|
|
|
33
|
-
|
|
|
34
|
-
|
|
|
26
|
+
The latest published `2.x` minor on npm receives security fixes. Older
|
|
27
|
+
minors do not. Today that is `2.23.x`. If a fix requires a breaking change,
|
|
28
|
+
it is shipped as the next `2.x` minor and the prior minor is deprecated on npm.
|
|
29
|
+
|
|
30
|
+
| Version | Status | Security fixes |
|
|
31
|
+
|----------|-------------|----------------|
|
|
32
|
+
| 2.23.x | current | yes |
|
|
33
|
+
| 2.0–2.22 | superseded | no |
|
|
34
|
+
| 1.5.x | frozen | no |
|
|
35
|
+
| ≤ 1.4.x | superseded | no |
|
|
35
36
|
|
|
36
37
|
## What this project considers a security issue
|
|
37
38
|
|
package/index.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Usage:
|
|
8
8
|
* xlsx-for-ai <file.xlsx> [--json] [--md] [--sheet <name>] [--evaluate]
|
|
9
|
+
* xlsx-for-ai <file.xlsx> --clean [--execute] [--json] [--sheet <name>] [--detectors <list>]
|
|
9
10
|
* xlsx-for-ai --telemetry-status
|
|
10
11
|
* xlsx-for-ai --enable-telemetry
|
|
11
12
|
* xlsx-for-ai --disable-telemetry
|
|
@@ -32,7 +33,8 @@ const {
|
|
|
32
33
|
function parseArgs(argv) {
|
|
33
34
|
const opts = { file: null, format: 'text', sheet: null, evaluate: false,
|
|
34
35
|
telemetryStatus: false, enableTelemetry: false, disableTelemetry: false,
|
|
35
|
-
privacyStrict: false, showVersion: false
|
|
36
|
+
privacyStrict: false, showVersion: false,
|
|
37
|
+
clean: false, execute: false, detectors: null };
|
|
36
38
|
let i = 0;
|
|
37
39
|
while (i < argv.length) {
|
|
38
40
|
const a = argv[i];
|
|
@@ -45,18 +47,264 @@ function parseArgs(argv) {
|
|
|
45
47
|
else if (a === '--disable-telemetry') opts.disableTelemetry = true;
|
|
46
48
|
else if (a === '--privacy=strict') opts.privacyStrict = true;
|
|
47
49
|
else if (a === '--version' || a === '-v') opts.showVersion = true;
|
|
50
|
+
else if (a === '--clean') opts.clean = true;
|
|
51
|
+
else if (a === '--execute') opts.execute = true;
|
|
52
|
+
else if (a === '--detectors') {
|
|
53
|
+
// Validate the next arg exists + isn't another flag — otherwise
|
|
54
|
+
// `--detectors --json` would silently swallow `--json` as the
|
|
55
|
+
// value. Caught by gpt-5 pre-push panel.
|
|
56
|
+
const next = argv[++i];
|
|
57
|
+
// Reject undefined, any `-`-prefixed token, or empty string —
|
|
58
|
+
// `--detectors ""` would otherwise silently disable detection.
|
|
59
|
+
// Caught by gpt-5 pre-push runs 2 + 3.
|
|
60
|
+
if (next === undefined || next.startsWith('-') || next.trim() === '') {
|
|
61
|
+
process.stderr.write('xlsx-for-ai: --detectors requires a non-empty value (comma-separated detector names)\n');
|
|
62
|
+
process.exit(2);
|
|
63
|
+
}
|
|
64
|
+
opts.detectors = next;
|
|
65
|
+
}
|
|
48
66
|
else if (!a.startsWith('--')) opts.file = a;
|
|
49
67
|
i++;
|
|
50
68
|
}
|
|
51
69
|
return opts;
|
|
52
70
|
}
|
|
53
71
|
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// --clean flag — data-cleaning pipeline (xlsx_data_clean tool)
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
async function runClean(opts, absPath) {
|
|
77
|
+
const fileB64 = fs.readFileSync(absPath).toString('base64');
|
|
78
|
+
const body = { file_b64: fileB64, mode: opts.execute ? 'execute' : 'diagnose' };
|
|
79
|
+
if (opts.sheet) body.sheets = [opts.sheet];
|
|
80
|
+
if (opts.detectors) body.detectors = opts.detectors.split(',').map((s) => s.trim()).filter(Boolean);
|
|
81
|
+
|
|
82
|
+
let result;
|
|
83
|
+
try {
|
|
84
|
+
result = await callTool('xlsx_data_clean', body);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
process.stderr.write(`xlsx-for-ai --clean error: ${err.message}\n`);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const meta = (result && result._meta) || {};
|
|
91
|
+
if (opts.format === 'json') {
|
|
92
|
+
// Strip the cleaned-bytes blob from the JSON payload — it's
|
|
93
|
+
// re-emitted as a saved file below so stdout JSON stays small
|
|
94
|
+
// + human-readable.
|
|
95
|
+
const jsonOut = { ...meta };
|
|
96
|
+
delete jsonOut.file_b64;
|
|
97
|
+
process.stdout.write(JSON.stringify(jsonOut, null, 2) + '\n');
|
|
98
|
+
} else {
|
|
99
|
+
// Default: print the receipt markdown the server already
|
|
100
|
+
// synthesized.
|
|
101
|
+
const text = (result.content || []).map((c) => c.text).join('\n');
|
|
102
|
+
process.stdout.write(text + '\n');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Execute mode + applied changes → save cleaned file next to the
|
|
106
|
+
// source. Cross-platform path derivation via Node's path.parse
|
|
107
|
+
// (caught by gpt-5 pre-push run 2): the earlier lastIndexOf('/')
|
|
108
|
+
// shortcut broke on Windows backslash paths + on directories with
|
|
109
|
+
// dots in the name. path.parse handles both.
|
|
110
|
+
if (opts.execute && meta.file_b64) {
|
|
111
|
+
let outPath = process.env.XFA_CLEAN_OUT;
|
|
112
|
+
if (!outPath) {
|
|
113
|
+
const parsed = path.parse(absPath);
|
|
114
|
+
outPath = path.join(parsed.dir, `${parsed.name}-cleaned${parsed.ext || '.xlsx'}`);
|
|
115
|
+
}
|
|
116
|
+
if (path.resolve(outPath) === path.resolve(absPath)) {
|
|
117
|
+
process.stderr.write('xlsx-for-ai --clean: refusing to overwrite source; set XFA_CLEAN_OUT to an explicit output path\n');
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
fs.writeFileSync(outPath, Buffer.from(meta.file_b64, 'base64'));
|
|
122
|
+
process.stderr.write(`Cleaned file written to: ${outPath}\n`);
|
|
123
|
+
} catch (e) {
|
|
124
|
+
// Caught by gpt-5 pre-push run 2: writeFileSync throws on
|
|
125
|
+
// missing directory / permissions / disk-full. Wrap so the
|
|
126
|
+
// user sees a clear error + exit code, not a stack trace.
|
|
127
|
+
process.stderr.write(`xlsx-for-ai --clean: failed to write ${outPath}: ${e.message}\n`);
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
54
133
|
// ---------------------------------------------------------------------------
|
|
55
134
|
// Main
|
|
56
135
|
// ---------------------------------------------------------------------------
|
|
57
136
|
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Stamp / Receipt subcommands — thin wrappers around the MCP tool relays.
|
|
139
|
+
//
|
|
140
|
+
// CLI surface (per ana/specs/stamp.md §4.2 + ana/specs/receipt.md §4.4):
|
|
141
|
+
// xlsx-for-ai stamp <path> --checks <file.json> [--out <path>] [--exclude <s>...] [--supervisor <ver>]
|
|
142
|
+
// xlsx-for-ai verify-stamp <path>
|
|
143
|
+
// xlsx-for-ai receipt <path> --agent <name> [--display-name <s>] [--identity-url <u>]
|
|
144
|
+
// [--source <name>=<sha256>...] [--prompt-hash <sha256>] [--mcp-tool <name>...]
|
|
145
|
+
// [--description <s>] [--cover-sheet <s>...] [--out <path>]
|
|
146
|
+
// xlsx-for-ai verify-receipt <path>
|
|
147
|
+
//
|
|
148
|
+
// Exit codes (per spec/stamp.md §4.9):
|
|
149
|
+
// 0 = success; 1 = verify returned valid=false; 2 = usage error;
|
|
150
|
+
// 3 = server-side error; 4 = local file error.
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
const STAMP_SUBCOMMANDS = new Set(['stamp', 'verify-stamp', 'receipt', 'verify-receipt']);
|
|
154
|
+
|
|
155
|
+
function nextRequiredArg(argv, i, flag) {
|
|
156
|
+
const v = argv[i + 1];
|
|
157
|
+
if (v === undefined || v.startsWith('-')) {
|
|
158
|
+
process.stderr.write(`xlsx-for-ai ${flag} requires a value\n`);
|
|
159
|
+
process.exit(2);
|
|
160
|
+
}
|
|
161
|
+
return v;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function loadChecksFile(checksPath) {
|
|
165
|
+
let raw;
|
|
166
|
+
try { raw = fs.readFileSync(path.resolve(checksPath), 'utf8'); }
|
|
167
|
+
catch (e) { process.stderr.write(`Cannot read --checks file: ${e.message}\n`); process.exit(4); }
|
|
168
|
+
let parsed;
|
|
169
|
+
try { parsed = JSON.parse(raw); }
|
|
170
|
+
catch (e) { process.stderr.write(`--checks file is not valid JSON: ${e.message}\n`); process.exit(2); }
|
|
171
|
+
if (!Array.isArray(parsed)) {
|
|
172
|
+
process.stderr.write('--checks file must contain a JSON array of {id, name, status, detail?}\n');
|
|
173
|
+
process.exit(2);
|
|
174
|
+
}
|
|
175
|
+
return parsed;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function runStampSubcommand(subcmd, rest) {
|
|
179
|
+
if (rest.length === 0 || rest[0].startsWith('-')) {
|
|
180
|
+
process.stderr.write(`Usage: xlsx-for-ai ${subcmd} <path> [...]\n`);
|
|
181
|
+
process.exit(2);
|
|
182
|
+
}
|
|
183
|
+
const filePath = path.resolve(rest[0]);
|
|
184
|
+
if (!fs.existsSync(filePath)) {
|
|
185
|
+
process.stderr.write(`File not found: ${filePath}\n`);
|
|
186
|
+
process.exit(4);
|
|
187
|
+
}
|
|
188
|
+
await ensureRegistered();
|
|
189
|
+
const fileB64 = fs.readFileSync(filePath).toString('base64');
|
|
190
|
+
|
|
191
|
+
if (subcmd === 'stamp') {
|
|
192
|
+
let checksPath = null, outPath = null, supervisor = null;
|
|
193
|
+
const excludeSheets = [];
|
|
194
|
+
for (let i = 1; i < rest.length; i++) {
|
|
195
|
+
const a = rest[i];
|
|
196
|
+
if (a === '--checks') checksPath = nextRequiredArg(rest, i++, '--checks');
|
|
197
|
+
else if (a === '--out') outPath = nextRequiredArg(rest, i++, '--out');
|
|
198
|
+
else if (a === '--supervisor') supervisor = nextRequiredArg(rest, i++, '--supervisor');
|
|
199
|
+
else if (a === '--exclude') excludeSheets.push(nextRequiredArg(rest, i++, '--exclude'));
|
|
200
|
+
else { process.stderr.write(`Unknown flag: ${a}\n`); process.exit(2); }
|
|
201
|
+
}
|
|
202
|
+
if (!checksPath) { process.stderr.write('--checks <file.json> is required for stamp\n'); process.exit(2); }
|
|
203
|
+
const body = { file_b64: fileB64, checks: loadChecksFile(checksPath) };
|
|
204
|
+
if (excludeSheets.length) body.exclude_sheets = excludeSheets;
|
|
205
|
+
if (supervisor) body.generated_by = { npm: 'xlsx-for-ai@' + require('./package.json').version, supervisor };
|
|
206
|
+
const result = await callServerForStamp('xlsx_stamp', body, outPath, filePath, '.stamped.xlsx');
|
|
207
|
+
process.stdout.write(JSON.stringify(result._meta || {}, null, 2) + '\n');
|
|
208
|
+
return 0;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (subcmd === 'verify-stamp') {
|
|
212
|
+
const body = { file_b64: fileB64 };
|
|
213
|
+
const result = await callTool('xlsx_verify_stamp', body);
|
|
214
|
+
const meta = result._meta || {};
|
|
215
|
+
process.stdout.write(JSON.stringify(meta, null, 2) + '\n');
|
|
216
|
+
return meta.valid === true ? 0 : 1;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (subcmd === 'receipt') {
|
|
220
|
+
let agentName = null, displayName = null, identityUrl = null;
|
|
221
|
+
let promptHash = null, description = null, outPath = null;
|
|
222
|
+
const sourceFileHashes = [];
|
|
223
|
+
const mcpToolsCalled = [];
|
|
224
|
+
const coverSheets = [];
|
|
225
|
+
for (let i = 1; i < rest.length; i++) {
|
|
226
|
+
const a = rest[i];
|
|
227
|
+
if (a === '--agent') agentName = nextRequiredArg(rest, i++, '--agent');
|
|
228
|
+
else if (a === '--display-name') displayName = nextRequiredArg(rest, i++, '--display-name');
|
|
229
|
+
else if (a === '--identity-url') identityUrl = nextRequiredArg(rest, i++, '--identity-url');
|
|
230
|
+
else if (a === '--prompt-hash') promptHash = nextRequiredArg(rest, i++, '--prompt-hash');
|
|
231
|
+
else if (a === '--description') description = nextRequiredArg(rest, i++, '--description');
|
|
232
|
+
else if (a === '--out') outPath = nextRequiredArg(rest, i++, '--out');
|
|
233
|
+
else if (a === '--mcp-tool') mcpToolsCalled.push(nextRequiredArg(rest, i++, '--mcp-tool'));
|
|
234
|
+
else if (a === '--cover-sheet') coverSheets.push(nextRequiredArg(rest, i++, '--cover-sheet'));
|
|
235
|
+
else if (a === '--source') {
|
|
236
|
+
const pair = nextRequiredArg(rest, i++, '--source');
|
|
237
|
+
const eqIdx = pair.indexOf('=');
|
|
238
|
+
if (eqIdx < 0) {
|
|
239
|
+
process.stderr.write('--source requires <name>=<sha256> form\n');
|
|
240
|
+
process.exit(2);
|
|
241
|
+
}
|
|
242
|
+
sourceFileHashes.push({ name: pair.slice(0, eqIdx), sha256: pair.slice(eqIdx + 1) });
|
|
243
|
+
}
|
|
244
|
+
else { process.stderr.write(`Unknown flag: ${a}\n`); process.exit(2); }
|
|
245
|
+
}
|
|
246
|
+
if (!agentName) { process.stderr.write('--agent <name> is required for receipt\n'); process.exit(2); }
|
|
247
|
+
const body = { file_b64: fileB64, agent: { name: agentName } };
|
|
248
|
+
if (displayName) body.agent.display_name = displayName;
|
|
249
|
+
if (identityUrl) body.agent.identity_url = identityUrl;
|
|
250
|
+
const inputs = {};
|
|
251
|
+
if (sourceFileHashes.length) inputs.source_file_hashes = sourceFileHashes;
|
|
252
|
+
if (promptHash) inputs.prompt_hash = promptHash;
|
|
253
|
+
if (mcpToolsCalled.length) inputs.mcp_tools_called = mcpToolsCalled;
|
|
254
|
+
if (Object.keys(inputs).length) body.inputs = inputs;
|
|
255
|
+
if (description) body.description = description;
|
|
256
|
+
if (coverSheets.length) body.covers_sheets = coverSheets;
|
|
257
|
+
const result = await callServerForStamp('xlsx_receipt', body, outPath, filePath, '.receipted.xlsx');
|
|
258
|
+
process.stdout.write(JSON.stringify(result._meta || {}, null, 2) + '\n');
|
|
259
|
+
return 0;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (subcmd === 'verify-receipt') {
|
|
263
|
+
const body = { file_b64: fileB64 };
|
|
264
|
+
const result = await callTool('xlsx_verify_receipt', body);
|
|
265
|
+
const meta = result._meta || {};
|
|
266
|
+
process.stdout.write(JSON.stringify(meta, null, 2) + '\n');
|
|
267
|
+
return meta.valid === true ? 0 : 1;
|
|
268
|
+
}
|
|
269
|
+
return 2;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function callServerForStamp(tool, body, explicitOutPath, sourcePath, sidecarSuffix) {
|
|
273
|
+
let result;
|
|
274
|
+
try {
|
|
275
|
+
result = await callTool(tool, body);
|
|
276
|
+
} catch (err) {
|
|
277
|
+
process.stderr.write(`xlsx-for-ai ${tool}: ${err.message}\n`);
|
|
278
|
+
process.exit(err.code === 'API_UNREACHABLE' || err.code === 'API_SERVER_ERROR' ? 3 : 1);
|
|
279
|
+
}
|
|
280
|
+
const meta = result._meta || {};
|
|
281
|
+
if (!meta.file_b64) return result;
|
|
282
|
+
let outPath = explicitOutPath;
|
|
283
|
+
if (!outPath) {
|
|
284
|
+
const parsed = path.parse(sourcePath);
|
|
285
|
+
outPath = path.join(parsed.dir, `${parsed.name}${sidecarSuffix}`);
|
|
286
|
+
}
|
|
287
|
+
if (path.resolve(outPath) === path.resolve(sourcePath)) {
|
|
288
|
+
process.stderr.write(`xlsx-for-ai ${tool}: refusing to overwrite source — pass --out <other-path>\n`);
|
|
289
|
+
process.exit(2);
|
|
290
|
+
}
|
|
291
|
+
try { fs.writeFileSync(outPath, Buffer.from(meta.file_b64, 'base64')); }
|
|
292
|
+
catch (e) { process.stderr.write(`xlsx-for-ai ${tool}: failed to write ${outPath}: ${e.message}\n`); process.exit(4); }
|
|
293
|
+
process.stderr.write(`Wrote ${outPath}\n`);
|
|
294
|
+
return result;
|
|
295
|
+
}
|
|
296
|
+
|
|
58
297
|
async function main() {
|
|
59
|
-
|
|
298
|
+
// Subcommand dispatch — stamp/verify-stamp/receipt/verify-receipt
|
|
299
|
+
// route through dedicated handlers; everything else uses the legacy
|
|
300
|
+
// flag-only CLI (xlsx-for-ai <file> [--json|--md|--clean|...]).
|
|
301
|
+
const argv = process.argv.slice(2);
|
|
302
|
+
if (argv.length > 0 && STAMP_SUBCOMMANDS.has(argv[0])) {
|
|
303
|
+
const code = await runStampSubcommand(argv[0], argv.slice(1));
|
|
304
|
+
process.exit(code);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const opts = parseArgs(argv);
|
|
60
308
|
|
|
61
309
|
if (opts.showVersion) { console.log(require('./package.json').version); return; }
|
|
62
310
|
if (opts.telemetryStatus) { console.log(telemetryStatus()); return; }
|
|
@@ -82,6 +330,13 @@ async function main() {
|
|
|
82
330
|
process.env.XFA_PRIVACY = 'strict';
|
|
83
331
|
}
|
|
84
332
|
|
|
333
|
+
// --clean diverts to the data-cleaning pipeline before falling
|
|
334
|
+
// through to the default xlsx_read path.
|
|
335
|
+
if (opts.clean) {
|
|
336
|
+
await runClean(opts, absPath);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
85
340
|
const fileB64 = fs.readFileSync(absPath).toString('base64');
|
|
86
341
|
// Server format enum is 'md' | 'json' | 'sql'. The legacy CLI default 'text'
|
|
87
342
|
// maps to the server's default (md). Don't send 'text' — server rejects it.
|
package/lib/fallback-read.js
CHANGED
|
@@ -8,20 +8,75 @@
|
|
|
8
8
|
* clear message if it isn't installed.
|
|
9
9
|
*
|
|
10
10
|
* Returns the same shape as the API: { content: [{ type: 'text', text }], _meta }
|
|
11
|
+
*
|
|
12
|
+
* Asymmetry vs. the hosted API (callers should be aware):
|
|
13
|
+
* - options.sheet IS honored — the response is filtered to the named sheet.
|
|
14
|
+
* - options.format is NOT honored — fallback always emits plain text.
|
|
15
|
+
* - options.evaluate is NOT honored — formulas render as the cached values
|
|
16
|
+
* stored in the workbook, not re-evaluated by a formula engine.
|
|
17
|
+
*
|
|
18
|
+
* When any option is passed and ignored, a visible warning is prepended to
|
|
19
|
+
* the text content AND the ignored option names are echoed back via
|
|
20
|
+
* _meta.ignored_options. Callers can detect fallback unambiguously via
|
|
21
|
+
* _meta.source === 'local-fallback'.
|
|
11
22
|
*/
|
|
12
23
|
|
|
13
|
-
const fs = require('fs');
|
|
14
24
|
const path = require('path');
|
|
15
25
|
|
|
16
26
|
function requireEngine() {
|
|
17
27
|
try {
|
|
18
28
|
return require('@protobi/exceljs');
|
|
19
|
-
} catch (
|
|
20
|
-
|
|
29
|
+
} catch (e) {
|
|
30
|
+
// Only translate the "module not installed" case. A real bug inside the
|
|
31
|
+
// engine (syntax error, transitive missing dep, etc.) must surface as the
|
|
32
|
+
// original error, not get misreported as a missing-install.
|
|
33
|
+
const isModuleNotFound =
|
|
34
|
+
e && e.code === 'MODULE_NOT_FOUND' && String(e.message || '').includes('@protobi/exceljs');
|
|
35
|
+
if (!isModuleNotFound) throw e;
|
|
36
|
+
const err = new Error(
|
|
21
37
|
'Local fallback requires `npm install @protobi/exceljs` ' +
|
|
22
38
|
'(this is normally not needed when the hosted API is available).'
|
|
23
39
|
);
|
|
24
|
-
|
|
40
|
+
err.code = 'FALLBACK_ENGINE_MISSING';
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// @protobi/exceljs's cell.text getter throws on merge cells whose master
|
|
46
|
+
// value is null — produced by SEC XBRL→xlsx converters and probably any
|
|
47
|
+
// other tool that writes merge regions before populating the master cell.
|
|
48
|
+
// The thrown shape is `TypeError: Cannot read properties of null (reading
|
|
49
|
+
// 'toString')` from inside the MergeValue / value getter chain. Guard the
|
|
50
|
+
// access so one cell of one sheet can't crash the entire dump, but only
|
|
51
|
+
// swallow the exact null-deref TypeError class — anything else (a real
|
|
52
|
+
// bug in the engine, a structural surprise we haven't characterized)
|
|
53
|
+
// rethrows so we don't silently render data as empty.
|
|
54
|
+
function safeCellText(cell) {
|
|
55
|
+
try {
|
|
56
|
+
const t = cell.text;
|
|
57
|
+
return t != null ? String(t) : '';
|
|
58
|
+
} catch (e) {
|
|
59
|
+
// Extract the message defensively — an exotic error whose `message`
|
|
60
|
+
// getter itself throws would otherwise crash the handler. The inner
|
|
61
|
+
// try/catch defaults to '' so the regex test below is always safe.
|
|
62
|
+
let msg = '';
|
|
63
|
+
try { msg = String((e && e.message) || ''); } catch (_) { msg = ''; }
|
|
64
|
+
|
|
65
|
+
// Match the exact null-deref TypeError shape — NOT any TypeError whose
|
|
66
|
+
// message contains "null", and NOT undefined-deref either. The bug
|
|
67
|
+
// class we're defending against (merge cells whose master value is
|
|
68
|
+
// explicitly null, produced by SEC XBRL→xlsx converters) emits null,
|
|
69
|
+
// never undefined; an undefined-deref here is more likely a real bug
|
|
70
|
+
// in the engine or upstream code and should surface, not be silenced.
|
|
71
|
+
// Regexes are anchored both ends so partial-prefix matches can't slip
|
|
72
|
+
// through. Two alternations cover modern V8 ("…properties of null
|
|
73
|
+
// (reading 'x')") and legacy V8 ("…property 'x' of null") for older
|
|
74
|
+
// Node runtimes some consumers may still pin to.
|
|
75
|
+
const isNullDeref = e instanceof TypeError && (
|
|
76
|
+
/^Cannot read properties of null(?: \(reading '.*'\))?$/.test(msg) ||
|
|
77
|
+
/^Cannot read property '.*' of null$/.test(msg)
|
|
78
|
+
);
|
|
79
|
+
if (isNullDeref) return '';
|
|
25
80
|
throw e;
|
|
26
81
|
}
|
|
27
82
|
}
|
|
@@ -31,23 +86,55 @@ async function fallbackRead(filePath, options = {}) {
|
|
|
31
86
|
const wb = new ExcelJS.Workbook();
|
|
32
87
|
await wb.xlsx.readFile(filePath);
|
|
33
88
|
|
|
89
|
+
const requestedSheet = options.sheet || null;
|
|
90
|
+
// Detect presence (not truthiness) so the caller's intent is honored even
|
|
91
|
+
// for falsy-but-passed values like format:'' or evaluate:false.
|
|
92
|
+
const ignoredOptions = [];
|
|
93
|
+
if ('format' in options) ignoredOptions.push('format');
|
|
94
|
+
if ('evaluate' in options) ignoredOptions.push('evaluate');
|
|
95
|
+
|
|
34
96
|
const lines = [];
|
|
97
|
+
const warningParts = ['⚠ API unreachable — local fallback active.'];
|
|
98
|
+
if (ignoredOptions.length > 0) {
|
|
99
|
+
warningParts.push(`Options not honored by fallback: ${ignoredOptions.join(', ')}.`);
|
|
100
|
+
}
|
|
101
|
+
lines.push(warningParts.join(' '));
|
|
102
|
+
lines.push('');
|
|
103
|
+
|
|
104
|
+
let sheetMatched = false;
|
|
35
105
|
wb.eachSheet((sheet) => {
|
|
106
|
+
if (requestedSheet && sheet.name !== requestedSheet) return;
|
|
107
|
+
sheetMatched = true;
|
|
36
108
|
lines.push(`## Sheet: ${sheet.name}`);
|
|
37
109
|
sheet.eachRow((row) => {
|
|
38
110
|
const vals = [];
|
|
39
111
|
row.eachCell({ includeEmpty: true }, (cell) => {
|
|
40
|
-
vals.push(
|
|
112
|
+
vals.push(safeCellText(cell));
|
|
41
113
|
});
|
|
42
114
|
lines.push(vals.join('\t'));
|
|
43
115
|
});
|
|
44
116
|
lines.push('');
|
|
45
117
|
});
|
|
46
118
|
|
|
119
|
+
if (requestedSheet && !sheetMatched) {
|
|
120
|
+
const available = wb.worksheets.map((s) => s.name);
|
|
121
|
+
lines.push(
|
|
122
|
+
available.length === 0
|
|
123
|
+
? `(no sheet named "${requestedSheet}" — workbook has no sheets)`
|
|
124
|
+
: `(no sheet named "${requestedSheet}" — workbook has: ${available.join(', ')})`
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
47
128
|
const text = lines.join('\n');
|
|
48
129
|
return {
|
|
49
130
|
content: [{ type: 'text', text }],
|
|
50
|
-
_meta: {
|
|
131
|
+
_meta: {
|
|
132
|
+
source: 'local-fallback',
|
|
133
|
+
engine: '@protobi/exceljs',
|
|
134
|
+
file: path.basename(filePath),
|
|
135
|
+
sheet_filter: requestedSheet,
|
|
136
|
+
ignored_options: ignoredOptions,
|
|
137
|
+
},
|
|
51
138
|
};
|
|
52
139
|
}
|
|
53
140
|
|
package/mcp.js
CHANGED
|
@@ -17,6 +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
21
|
const fs = require('fs');
|
|
21
22
|
const fsPromises = require('fs/promises');
|
|
22
23
|
const path = require('path');
|
|
@@ -453,6 +454,68 @@ const TOOLS = [
|
|
|
453
454
|
},
|
|
454
455
|
},
|
|
455
456
|
|
|
457
|
+
{
|
|
458
|
+
name: 'xlsx_data_clean',
|
|
459
|
+
description:
|
|
460
|
+
'xlsx-for-ai — read, write, diff, redact, supervise .xlsx files locally.\n' +
|
|
461
|
+
'This tool: AI-native data cleaning. Scans a workbook for the seven most common data-grime issues — NA variants (N/A, NA, null, -), merged-cell residue, type-coercion mistakes (numeric-as-text / date-as-serial / leading-zero stripped), trailing-row noise (footers / totals), header-row-not-first (preamble before headers), encoding glitches (UTF-8-as-CP1252 mojibake like Café), and duplicate column headers — and either flags them (diagnose mode) or applies deterministic fixes (execute mode).\n' +
|
|
462
|
+
'No other tool gives this in a single call: pandas does ad-hoc fixes inline; openpyxl is structure-only; pre-existing Python "clean" libraries are domain-specific. xlsx_data_clean is the only single-call clean pipeline with an explicit informer-not-enforcer contract: every fix surfaces as a Finding the caller can accept / reject / scope-override before the file is mutated.\n\n' +
|
|
463
|
+
'USE WHEN: an upstream pipeline produced an xlsx that\'s about to feed an LLM or downstream analysis and you want a one-pass scrub. Or you just got a "messy" export (financial reports with merged title banners, CRM exports with stripped zip codes, survey data with NA-variant noise) and need it normalized before reading. ' +
|
|
464
|
+
'Free tier — counts against the 10k/mo cap.\n\n' +
|
|
465
|
+
'DO NOT USE WHEN: domain-specific transforms are needed (use a dedicated pipeline; this tool is general-purpose). Or for structural integrity checks (use xlsx_doctor). Or for upload/attached files.',
|
|
466
|
+
inputSchema: {
|
|
467
|
+
type: 'object',
|
|
468
|
+
properties: {
|
|
469
|
+
file_path: { type: 'string', description: 'Absolute path to the .xlsx file.' },
|
|
470
|
+
mode: {
|
|
471
|
+
type: 'string',
|
|
472
|
+
enum: ['diagnose', 'execute'],
|
|
473
|
+
description: 'diagnose (default): return findings only, file untouched. execute: apply deterministic fixes; cleaned bytes returned in _meta.file_b64.',
|
|
474
|
+
},
|
|
475
|
+
detectors: {
|
|
476
|
+
type: 'array',
|
|
477
|
+
items: { type: 'string' },
|
|
478
|
+
description: 'Subset of detectors to run. Default: all 7 (na_variant, merged_cell_residue, type_coercion_mistake, trailing_row_noise, header_row_not_first, encoding_glitch, duplicate_header).',
|
|
479
|
+
},
|
|
480
|
+
sheets: { type: 'array', items: { type: 'string' }, description: 'Restrict to these sheet names. Default: all sheets.' },
|
|
481
|
+
options: {
|
|
482
|
+
type: 'object',
|
|
483
|
+
description: 'Detector tunables.',
|
|
484
|
+
properties: {
|
|
485
|
+
trailing_threshold: { type: 'integer', minimum: 1, maximum: 100, description: 'Min consecutive noise rows to flag (default 3).' },
|
|
486
|
+
header_scan_depth: { type: 'integer', minimum: 2, maximum: 50, description: 'Rows to scan for header inference (default 10).' },
|
|
487
|
+
na_canonical: { type: 'string', description: 'Replacement value for NA tokens. "" (default), "null", "(blank)", or any string.' },
|
|
488
|
+
},
|
|
489
|
+
},
|
|
490
|
+
overrides: {
|
|
491
|
+
type: 'array',
|
|
492
|
+
description: 'Per-detector / per-scope skip / flag_only / force overrides.',
|
|
493
|
+
items: {
|
|
494
|
+
type: 'object',
|
|
495
|
+
properties: {
|
|
496
|
+
detector: { type: 'string' },
|
|
497
|
+
scope: {
|
|
498
|
+
type: 'object',
|
|
499
|
+
properties: {
|
|
500
|
+
sheet: { type: 'string' },
|
|
501
|
+
column_letter: { type: 'string', description: 'A-Z column letter; alternative to region.' },
|
|
502
|
+
region: { type: 'object', properties: { top_left: { type: 'string' }, bottom_right: { type: 'string' } } },
|
|
503
|
+
},
|
|
504
|
+
required: ['sheet'],
|
|
505
|
+
},
|
|
506
|
+
action: { type: 'string', enum: ['skip', 'flag_only', 'force'] },
|
|
507
|
+
},
|
|
508
|
+
required: ['detector', 'scope', 'action'],
|
|
509
|
+
},
|
|
510
|
+
},
|
|
511
|
+
accept_findings: { type: 'array', items: { type: 'string' }, description: 'Execute mode only — finding IDs to apply. Default: all.' },
|
|
512
|
+
reject_findings: { type: 'array', items: { type: 'string' }, description: 'Execute mode only — finding IDs to skip.' },
|
|
513
|
+
out_path: { type: 'string', description: 'Optional save path for cleaned output (execute mode).' },
|
|
514
|
+
},
|
|
515
|
+
required: ['file_path'],
|
|
516
|
+
},
|
|
517
|
+
},
|
|
518
|
+
|
|
456
519
|
{
|
|
457
520
|
name: 'xlsx_validate',
|
|
458
521
|
description:
|
|
@@ -832,21 +895,23 @@ const TOOLS = [
|
|
|
832
895
|
name: 'xlsx_post_slack',
|
|
833
896
|
description:
|
|
834
897
|
'xlsx-for-ai — read, write, diff, redact, supervise .xlsx files locally.\n' +
|
|
835
|
-
'This tool: upload a local .xlsx file to a Slack channel as a file attachment, with an optional accompanying message
|
|
898
|
+
'This tool: upload a local .xlsx file to a Slack channel as a file attachment, with an optional accompanying message.\n' +
|
|
899
|
+
'Token intake: set SLACK_BOT_TOKEN in the environment (recommended — keeps the token out of conversation logs). ' +
|
|
900
|
+
'Alternatively pass slack_token as a tool argument (legacy; token will appear in MCP conversation history).\n' +
|
|
836
901
|
'Posts via Slack\'s 3-step external upload flow (files.getUploadURLExternal → upload → files.completeUploadExternal), which is the only sanctioned path as of 2024+.\n\n' +
|
|
837
902
|
'USE WHEN: the user asks "post this workbook to #channel," "share this with the team in Slack," or any other outbound-file-to-Slack request. The agent has just produced or modified a workbook and wants to deliver it. ' +
|
|
838
903
|
'Free tier — counts against the 10k/mo cap.\n\n' +
|
|
839
|
-
'DO NOT USE WHEN: the file lives in a Slack channel and you want to READ it (that\'s the inbound Manual-Mode-Detector pattern, not this). Or when
|
|
904
|
+
'DO NOT USE WHEN: the file lives in a Slack channel and you want to READ it (that\'s the inbound Manual-Mode-Detector pattern, not this). Or when no Slack bot token is available — the user must have installed a Slack app with files:write scope.',
|
|
840
905
|
inputSchema: {
|
|
841
906
|
type: 'object',
|
|
842
907
|
properties: {
|
|
843
908
|
file_path: { type: 'string', description: 'Absolute path to the .xlsx file to post.' },
|
|
844
909
|
channel: { type: 'string', description: 'Slack channel ID (C…/G…) the file should land in. Channel names like #general are NOT accepted — resolve to a channel ID first.' },
|
|
845
|
-
slack_token: { type: 'string', description: 'Slack bot token (xoxb-…).
|
|
910
|
+
slack_token: { type: 'string', description: 'Slack bot token (xoxb-…). Optional when SLACK_BOT_TOKEN env var is set. Passing the token here exposes it in MCP conversation logs — prefer the env var.' },
|
|
846
911
|
message: { type: 'string', description: 'Optional: message to post alongside the file (Slack\'s initial_comment).' },
|
|
847
912
|
filename: { type: 'string', description: 'Optional: filename Slack will display. Defaults to the basename of file_path.' },
|
|
848
913
|
},
|
|
849
|
-
required: ['file_path', 'channel'
|
|
914
|
+
required: ['file_path', 'channel'],
|
|
850
915
|
},
|
|
851
916
|
},
|
|
852
917
|
|
|
@@ -854,22 +919,24 @@ const TOOLS = [
|
|
|
854
919
|
name: 'xlsx_post_teams',
|
|
855
920
|
description:
|
|
856
921
|
'xlsx-for-ai — read, write, diff, redact, supervise .xlsx files locally.\n' +
|
|
857
|
-
'This tool: upload a local .xlsx file to a Microsoft Teams channel as a file attachment in a channel message, with an optional accompanying message
|
|
922
|
+
'This tool: upload a local .xlsx file to a Microsoft Teams channel as a file attachment in a channel message, with an optional accompanying message.\n' +
|
|
923
|
+
'Token intake: set TEAMS_GRAPH_TOKEN in the environment (recommended — keeps the token out of conversation logs). ' +
|
|
924
|
+
'Alternatively pass graph_token as a tool argument (legacy; token will appear in MCP conversation history).\n' +
|
|
858
925
|
'Uses Microsoft Graph\'s 4-step flow: locate the channel\'s filesFolder driveItem, create an upload session, upload the bytes, then post a chatMessage with the file as an inline attachment.\n\n' +
|
|
859
926
|
'USE WHEN: the user asks "post this workbook to my Teams channel," "share this with the team in Teams," or any other outbound-file-to-Teams request. The agent has just produced or modified a workbook and wants to deliver it to a Microsoft Teams channel. ' +
|
|
860
927
|
'Free tier — counts against the 10k/mo cap.\n\n' +
|
|
861
|
-
'DO NOT USE WHEN: posting to Slack (use xlsx_post_slack). Or when
|
|
928
|
+
'DO NOT USE WHEN: posting to Slack (use xlsx_post_slack). Or when no Microsoft Graph token is available — the user must have an Entra ID app registration with Group.ReadWrite.All or Files.ReadWrite.All + ChannelMessage.Send scopes, AND a valid access token for that app.',
|
|
862
929
|
inputSchema: {
|
|
863
930
|
type: 'object',
|
|
864
931
|
properties: {
|
|
865
932
|
file_path: { type: 'string', description: 'Absolute path to the .xlsx file to post.' },
|
|
866
933
|
team_id: { type: 'string', description: 'Microsoft Teams team ID (GUID). Find via Graph: GET /me/joinedTeams.' },
|
|
867
934
|
channel_id: { type: 'string', description: 'Microsoft Teams channel ID. Find via Graph: GET /teams/{team-id}/channels.' },
|
|
868
|
-
graph_token: { type: 'string', description: 'Microsoft Graph access token (JWT).
|
|
935
|
+
graph_token: { type: 'string', description: 'Microsoft Graph access token (JWT). Optional when TEAMS_GRAPH_TOKEN env var is set. Passing the token here exposes it in MCP conversation logs — prefer the env var. Must have file-upload + channel-message-send scopes.' },
|
|
869
936
|
message: { type: 'string', description: 'Optional: message to post alongside the file. Plain text; will be HTML-escaped server-side.' },
|
|
870
937
|
filename: { type: 'string', description: 'Optional: filename Teams will display. Defaults to the basename of file_path.' },
|
|
871
938
|
},
|
|
872
|
-
required: ['file_path', 'team_id', 'channel_id'
|
|
939
|
+
required: ['file_path', 'team_id', 'channel_id'],
|
|
873
940
|
},
|
|
874
941
|
},
|
|
875
942
|
|
|
@@ -922,14 +989,163 @@ const TOOLS = [
|
|
|
922
989
|
required: ['file_path'],
|
|
923
990
|
},
|
|
924
991
|
},
|
|
992
|
+
|
|
993
|
+
{
|
|
994
|
+
name: 'xlsx_receipt',
|
|
995
|
+
description:
|
|
996
|
+
'xlsx-for-ai — read, write, diff, redact, supervise .xlsx files locally.\n' +
|
|
997
|
+
'This tool: attach an AI-generation receipt to a workbook — a cryptographic attestation embedded in docProps/custom.xml that says "this file was generated by THIS agent, at THIS time, against THESE inputs." Returns the receipted workbook as base64 in _meta.file_b64; pass out_path to write to disk.\n' +
|
|
998
|
+
'Honesty boundary (load-bearing): the server signs the CALLER-DECLARED `agent.name` — it does NOT verify the caller actually IS that agent. The signature proves "this server signed these strings at this time," not "this came from claude-sonnet-4-6." Caller is responsible for honest declaration. Cryptographic identity binding (per-agent issued signing keys) is v1.1+ scope.\n\n' +
|
|
999
|
+
'USE WHEN: an AI agent (Claude, custom SDK agent, automated pipeline) generates a workbook and the recipient wants verifiable provenance — "what produced this file, when, against what." Or chaining attestations across a multi-step pipeline (each step adds its own receipt under different agent.name).\n\n' +
|
|
1000
|
+
'DO NOT USE WHEN: the workbook was human-authored (use xlsx_stamp — Stamp attests to check results, Receipt attests to generation context). Or when the use case demands cryptographically-bound identity (v1.1+).',
|
|
1001
|
+
inputSchema: {
|
|
1002
|
+
type: 'object',
|
|
1003
|
+
properties: {
|
|
1004
|
+
file_path: { type: 'string', description: 'Absolute path to the .xlsx file to receipt.' },
|
|
1005
|
+
agent_name: {
|
|
1006
|
+
type: 'string',
|
|
1007
|
+
description: 'Canonical agent name (lowercase + dot/dash/underscore/slash/colon, 1-128 chars). Examples: "claude-sonnet-4-6", "claude-code/0.5.2", "custom:my-agent-v1".',
|
|
1008
|
+
},
|
|
1009
|
+
agent_display_name: { type: 'string', description: 'Optional: human-readable display name (e.g., "Acme Q4 Forecast Bot").' },
|
|
1010
|
+
agent_identity_url: { type: 'string', description: 'Optional: caller-declared identity URL (GitHub repo, registry entry, etc.).' },
|
|
1011
|
+
source_file_hashes: {
|
|
1012
|
+
type: 'array',
|
|
1013
|
+
description: 'Optional: array of {name, sha256} entries describing source files the agent read to produce this workbook.',
|
|
1014
|
+
items: {
|
|
1015
|
+
type: 'object',
|
|
1016
|
+
properties: {
|
|
1017
|
+
name: { type: 'string' },
|
|
1018
|
+
sha256: { type: 'string', description: 'Hex SHA-256 (64 lowercase chars).' },
|
|
1019
|
+
},
|
|
1020
|
+
required: ['name', 'sha256'],
|
|
1021
|
+
},
|
|
1022
|
+
},
|
|
1023
|
+
prompt_hash: { type: 'string', description: 'Optional: hex SHA-256 of the prompt or instruction set that produced the workbook.' },
|
|
1024
|
+
mcp_tools_called: { type: 'array', items: { type: 'string' }, description: 'Optional: list of MCP tool names the agent called during generation.' },
|
|
1025
|
+
description: { type: 'string', description: 'Optional: short human-readable description of what the workbook is (≤256 chars).' },
|
|
1026
|
+
covers_sheets: { type: 'array', items: { type: 'string' }, description: 'Optional: sheets covered by the content hash. Default: all sheets.' },
|
|
1027
|
+
out_path: { type: 'string', description: 'Optional: write the receipted workbook to this absolute path. If omitted, the bytes are returned in _meta.file_b64 only.' },
|
|
1028
|
+
},
|
|
1029
|
+
required: ['file_path', 'agent_name'],
|
|
1030
|
+
},
|
|
1031
|
+
},
|
|
1032
|
+
|
|
1033
|
+
{
|
|
1034
|
+
name: 'xlsx_verify_receipt',
|
|
1035
|
+
description:
|
|
1036
|
+
'xlsx-for-ai — read, write, diff, redact, supervise .xlsx files locally.\n' +
|
|
1037
|
+
'This tool: verify a workbook\'s embedded AI-generation receipt. Returns whether the signature is valid, whether the recomputed content hash matches the hash IN the receipt, and the full caller-declared claims (agent identity, generation timestamp, source-file hashes, prompt hash, MCP tools called, description).\n' +
|
|
1038
|
+
'A workbook can fail verification three ways: (1) no receipt present (never receipted, or receipt was stripped); (2) signature_valid=false (claims modified after signing); (3) hash_matches=false (workbook bytes modified after receipt was generated). Honesty: a valid receipt proves the SERVER signed the caller-DECLARED agent string — not that the agent IS that.\n\n' +
|
|
1039
|
+
'USE WHEN: a workbook arrives claiming AI provenance and the user wants to verify it. Or auditing a corpus of workbooks to find ones with broken receipts (likely-tampered) or no receipts at all.',
|
|
1040
|
+
inputSchema: {
|
|
1041
|
+
type: 'object',
|
|
1042
|
+
properties: {
|
|
1043
|
+
file_path: { type: 'string', description: 'Absolute path to the .xlsx file to verify.' },
|
|
1044
|
+
},
|
|
1045
|
+
required: ['file_path'],
|
|
1046
|
+
},
|
|
1047
|
+
},
|
|
925
1048
|
];
|
|
926
1049
|
|
|
927
1050
|
// ---------------------------------------------------------------------------
|
|
928
1051
|
// File → base64 helper
|
|
1052
|
+
//
|
|
1053
|
+
// Security: only spreadsheet extensions are permitted. Any path that resolves
|
|
1054
|
+
// to a non-allowed extension (or does not exist) is rejected immediately so a
|
|
1055
|
+
// misbehaving agent cannot exfiltrate arbitrary local files via a tool call.
|
|
1056
|
+
//
|
|
1057
|
+
// Stability: a size cap is enforced before the synchronous read so a giant
|
|
1058
|
+
// workbook can't OOM-kill the MCP server (which would disconnect every tool
|
|
1059
|
+
// for the user). Override via XFA_MAX_FILE_MB; default is 50 MB.
|
|
929
1060
|
// ---------------------------------------------------------------------------
|
|
930
1061
|
|
|
1062
|
+
const ALLOWED_READ_EXTENSIONS = new Set(['.xlsx', '.xls', '.xlsm', '.xlsb', '.csv', '.ods', '.fods', '.numbers', '.tsv']);
|
|
1063
|
+
const DEFAULT_MAX_FILE_MB = 50;
|
|
1064
|
+
|
|
1065
|
+
function getMaxFileMB() {
|
|
1066
|
+
const raw = process.env.XFA_MAX_FILE_MB;
|
|
1067
|
+
if (!raw) return DEFAULT_MAX_FILE_MB;
|
|
1068
|
+
const parsed = parseInt(raw, 10);
|
|
1069
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_MAX_FILE_MB;
|
|
1070
|
+
return parsed;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
931
1073
|
function fileToB64(filePath) {
|
|
932
|
-
|
|
1074
|
+
const resolved = path.resolve(filePath);
|
|
1075
|
+
|
|
1076
|
+
// Open the file once and operate on the fd from here on. fstatSync and the
|
|
1077
|
+
// subsequent read both bind to the inode the fd points at, so even if the
|
|
1078
|
+
// path is swapped after the size check the bytes we hash are the bytes we
|
|
1079
|
+
// sized — the size-cap TOCTOU is closed.
|
|
1080
|
+
// O_NOFOLLOW (where available) refuses symlinks at open time; it's undefined
|
|
1081
|
+
// on Windows, where we fall back to 0 (symlink semantics differ there and
|
|
1082
|
+
// the spreadsheet-extension allowlist is the load-bearing guard anyway).
|
|
1083
|
+
const O_NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
|
|
1084
|
+
let fd;
|
|
1085
|
+
try {
|
|
1086
|
+
fd = fs.openSync(resolved, fs.constants.O_RDONLY | O_NOFOLLOW);
|
|
1087
|
+
} catch (e) {
|
|
1088
|
+
if (e && e.code === 'ENOENT') {
|
|
1089
|
+
const err = new Error(`File not found: ${resolved}`);
|
|
1090
|
+
err.code = 'FILE_NOT_FOUND';
|
|
1091
|
+
throw err;
|
|
1092
|
+
}
|
|
1093
|
+
if (e && e.code === 'ELOOP') {
|
|
1094
|
+
const err = new Error(`Refusing to read symlink: ${resolved}`);
|
|
1095
|
+
err.code = 'SYMLINK_REJECTED';
|
|
1096
|
+
throw err;
|
|
1097
|
+
}
|
|
1098
|
+
throw e;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
try {
|
|
1102
|
+
const stat = fs.fstatSync(fd);
|
|
1103
|
+
|
|
1104
|
+
if (!stat.isFile()) {
|
|
1105
|
+
const err = new Error(`Not a regular file: ${resolved}`);
|
|
1106
|
+
err.code = 'NOT_REGULAR_FILE';
|
|
1107
|
+
throw err;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
1111
|
+
if (!ALLOWED_READ_EXTENSIONS.has(ext)) {
|
|
1112
|
+
const err = new Error(
|
|
1113
|
+
`Blocked: "${ext}" is not an allowed spreadsheet extension. ` +
|
|
1114
|
+
`Allowed: ${[...ALLOWED_READ_EXTENSIONS].join(', ')}`
|
|
1115
|
+
);
|
|
1116
|
+
err.code = 'DISALLOWED_EXTENSION';
|
|
1117
|
+
throw err;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
const maxMB = getMaxFileMB();
|
|
1121
|
+
if (stat.size > maxMB * 1024 * 1024) {
|
|
1122
|
+
const sizeMB = stat.size / (1024 * 1024);
|
|
1123
|
+
const err = new Error(
|
|
1124
|
+
`File too large: ${sizeMB.toFixed(1)} MB exceeds the ${maxMB} MB cap. ` +
|
|
1125
|
+
`Set XFA_MAX_FILE_MB to a higher value to allow larger workbooks. ` +
|
|
1126
|
+
`(The cap protects the MCP server from OOM on synchronous base64 load — ` +
|
|
1127
|
+
`a 200 MB workbook would allocate ~267 MB of base64 before any API call.)`
|
|
1128
|
+
);
|
|
1129
|
+
err.code = 'FILE_TOO_LARGE';
|
|
1130
|
+
throw err;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// Read exactly stat.size bytes from the fd into a pre-sized buffer. If
|
|
1134
|
+
// the file grows between fstat and now, the extra bytes are NOT read —
|
|
1135
|
+
// we never allocate more than the validated cap. If the file shrinks
|
|
1136
|
+
// (short read), we encode what we got and stop. This closes the
|
|
1137
|
+
// grow-after-stat bypass on the size cap.
|
|
1138
|
+
const buf = Buffer.alloc(stat.size);
|
|
1139
|
+
let bytesRead = 0;
|
|
1140
|
+
while (bytesRead < stat.size) {
|
|
1141
|
+
const chunk = fs.readSync(fd, buf, bytesRead, stat.size - bytesRead, null);
|
|
1142
|
+
if (chunk === 0) break;
|
|
1143
|
+
bytesRead += chunk;
|
|
1144
|
+
}
|
|
1145
|
+
return buf.subarray(0, bytesRead).toString('base64');
|
|
1146
|
+
} finally {
|
|
1147
|
+
try { fs.closeSync(fd); } catch (_) { /* best effort */ }
|
|
1148
|
+
}
|
|
933
1149
|
}
|
|
934
1150
|
|
|
935
1151
|
// ---------------------------------------------------------------------------
|
|
@@ -1003,7 +1219,23 @@ async function dispatchTool(name, args) {
|
|
|
1003
1219
|
if (name === 'xlsx_write') {
|
|
1004
1220
|
let spec = args.spec;
|
|
1005
1221
|
if (!spec && args.spec_path) {
|
|
1006
|
-
|
|
1222
|
+
// Security: spec_path must exist and must be a .json file.
|
|
1223
|
+
const resolvedSpecPath = path.resolve(args.spec_path);
|
|
1224
|
+
if (!fs.existsSync(resolvedSpecPath)) {
|
|
1225
|
+
const err = new Error(`spec_path not found: ${resolvedSpecPath}`);
|
|
1226
|
+
err.code = 'FILE_NOT_FOUND';
|
|
1227
|
+
throw err;
|
|
1228
|
+
}
|
|
1229
|
+
const specExt = path.extname(resolvedSpecPath).toLowerCase();
|
|
1230
|
+
if (specExt !== '.json') {
|
|
1231
|
+
const err = new Error(
|
|
1232
|
+
`spec_path must be a .json file; got "${specExt}". ` +
|
|
1233
|
+
'Pass the workbook spec as inline JSON via the "spec" argument instead.'
|
|
1234
|
+
);
|
|
1235
|
+
err.code = 'DISALLOWED_EXTENSION';
|
|
1236
|
+
throw err;
|
|
1237
|
+
}
|
|
1238
|
+
spec = JSON.parse(fs.readFileSync(resolvedSpecPath, 'utf8'));
|
|
1007
1239
|
}
|
|
1008
1240
|
const writeBody = { spec };
|
|
1009
1241
|
if (args.base_file_b64) writeBody.base_file_b64 = args.base_file_b64;
|
|
@@ -1135,14 +1367,47 @@ async function dispatchTool(name, args) {
|
|
|
1135
1367
|
});
|
|
1136
1368
|
}
|
|
1137
1369
|
|
|
1370
|
+
// xlsx_data_clean: scan + optional execute. Diagnose mode returns
|
|
1371
|
+
// findings only (no file_b64 in _meta). Execute mode returns
|
|
1372
|
+
// cleaned bytes in _meta.file_b64; applyFileB64 saves to out_path
|
|
1373
|
+
// if provided. SPEC fields pass through verbatim — server validates.
|
|
1374
|
+
if (name === 'xlsx_data_clean') {
|
|
1375
|
+
const body = { file_b64: fileToB64(args.file_path) };
|
|
1376
|
+
if (args.mode !== undefined) body.mode = args.mode;
|
|
1377
|
+
if (args.detectors !== undefined) body.detectors = args.detectors;
|
|
1378
|
+
if (args.sheets !== undefined) body.sheets = args.sheets;
|
|
1379
|
+
if (args.options !== undefined) body.options = args.options;
|
|
1380
|
+
if (args.overrides !== undefined) body.overrides = args.overrides;
|
|
1381
|
+
if (args.accept_findings !== undefined) body.accept_findings = args.accept_findings;
|
|
1382
|
+
if (args.reject_findings !== undefined) body.reject_findings = args.reject_findings;
|
|
1383
|
+
const result = await callTool('xlsx_data_clean', body);
|
|
1384
|
+
return applyFileB64(result, args.out_path);
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1138
1387
|
// xlsx_post_slack: outbound file-to-Slack. Top-level fields, not the
|
|
1139
1388
|
// standard {file_b64, options} shape — channel + slack_token + message
|
|
1140
1389
|
// + filename live alongside file_b64 in the server route's body schema.
|
|
1390
|
+
//
|
|
1391
|
+
// Token resolution order (H3 fix):
|
|
1392
|
+
// 1. SLACK_BOT_TOKEN env var (recommended — never enters conversation logs)
|
|
1393
|
+
// 2. slack_token tool arg (legacy; visible in MCP conversation history)
|
|
1394
|
+
// Error if neither is present.
|
|
1141
1395
|
if (name === 'xlsx_post_slack') {
|
|
1396
|
+
const slackToken = process.env.SLACK_BOT_TOKEN || args.slack_token;
|
|
1397
|
+
if (!slackToken) {
|
|
1398
|
+
const err = new Error(
|
|
1399
|
+
'Slack token required. Set the SLACK_BOT_TOKEN environment variable ' +
|
|
1400
|
+
'(recommended) or pass slack_token as a tool argument.'
|
|
1401
|
+
);
|
|
1402
|
+
err.code = 'MISSING_TOKEN';
|
|
1403
|
+
throw err;
|
|
1404
|
+
}
|
|
1405
|
+
// fileToB64 enforces existence + extension allowlist (H1 fix) — only
|
|
1406
|
+
// spreadsheet extensions (.xlsx, .xlsm, etc.) are permitted here.
|
|
1142
1407
|
const body = {
|
|
1143
1408
|
file_b64: fileToB64(args.file_path),
|
|
1144
1409
|
channel: args.channel,
|
|
1145
|
-
slack_token:
|
|
1410
|
+
slack_token: slackToken,
|
|
1146
1411
|
};
|
|
1147
1412
|
if (args.message !== undefined) body.message = args.message;
|
|
1148
1413
|
body.filename = args.filename || path.basename(args.file_path);
|
|
@@ -1151,12 +1416,28 @@ async function dispatchTool(name, args) {
|
|
|
1151
1416
|
|
|
1152
1417
|
// xlsx_post_teams: outbound file-to-Teams. Same shape as Slack but with
|
|
1153
1418
|
// Microsoft Graph fields (team_id + channel_id + graph_token).
|
|
1419
|
+
//
|
|
1420
|
+
// Token resolution order (H3 fix):
|
|
1421
|
+
// 1. TEAMS_GRAPH_TOKEN env var (recommended — never enters conversation logs)
|
|
1422
|
+
// 2. graph_token tool arg (legacy; visible in MCP conversation history)
|
|
1423
|
+
// Error if neither is present.
|
|
1154
1424
|
if (name === 'xlsx_post_teams') {
|
|
1425
|
+
const graphToken = process.env.TEAMS_GRAPH_TOKEN || args.graph_token;
|
|
1426
|
+
if (!graphToken) {
|
|
1427
|
+
const err = new Error(
|
|
1428
|
+
'Microsoft Graph token required. Set the TEAMS_GRAPH_TOKEN environment variable ' +
|
|
1429
|
+
'(recommended) or pass graph_token as a tool argument.'
|
|
1430
|
+
);
|
|
1431
|
+
err.code = 'MISSING_TOKEN';
|
|
1432
|
+
throw err;
|
|
1433
|
+
}
|
|
1434
|
+
// fileToB64 enforces existence + extension allowlist (H1 fix) — only
|
|
1435
|
+
// spreadsheet extensions (.xlsx, .xlsm, etc.) are permitted here.
|
|
1155
1436
|
const body = {
|
|
1156
1437
|
file_b64: fileToB64(args.file_path),
|
|
1157
1438
|
team_id: args.team_id,
|
|
1158
1439
|
channel_id: args.channel_id,
|
|
1159
|
-
graph_token:
|
|
1440
|
+
graph_token: graphToken,
|
|
1160
1441
|
};
|
|
1161
1442
|
if (args.message !== undefined) body.message = args.message;
|
|
1162
1443
|
body.filename = args.filename || path.basename(args.file_path);
|
|
@@ -1187,6 +1468,37 @@ async function dispatchTool(name, args) {
|
|
|
1187
1468
|
return callTool('xlsx_verify_stamp', body);
|
|
1188
1469
|
}
|
|
1189
1470
|
|
|
1471
|
+
// xlsx_receipt: attach an AI-generation receipt. Server signs caller-
|
|
1472
|
+
// declared agent + optional inputs (source-file hashes, prompt hash,
|
|
1473
|
+
// mcp-tools-called) + optional description; embeds the SignedReceipt in
|
|
1474
|
+
// docProps/custom.xml. Honesty: server signs the STRINGS the caller
|
|
1475
|
+
// supplied; does NOT verify agent identity.
|
|
1476
|
+
if (name === 'xlsx_receipt') {
|
|
1477
|
+
const body = {
|
|
1478
|
+
file_b64: fileToB64(args.file_path),
|
|
1479
|
+
agent: { name: args.agent_name },
|
|
1480
|
+
};
|
|
1481
|
+
if (args.agent_display_name !== undefined) body.agent.display_name = args.agent_display_name;
|
|
1482
|
+
if (args.agent_identity_url !== undefined) body.agent.identity_url = args.agent_identity_url;
|
|
1483
|
+
const inputs = {};
|
|
1484
|
+
if (args.source_file_hashes !== undefined) inputs.source_file_hashes = args.source_file_hashes;
|
|
1485
|
+
if (args.prompt_hash !== undefined) inputs.prompt_hash = args.prompt_hash;
|
|
1486
|
+
if (args.mcp_tools_called !== undefined) inputs.mcp_tools_called = args.mcp_tools_called;
|
|
1487
|
+
if (Object.keys(inputs).length > 0) body.inputs = inputs;
|
|
1488
|
+
if (args.description !== undefined) body.description = args.description;
|
|
1489
|
+
if (args.covers_sheets !== undefined) body.covers_sheets = args.covers_sheets;
|
|
1490
|
+
const result = await callTool('xlsx_receipt', body);
|
|
1491
|
+
return applyFileB64(result, args.out_path);
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
// xlsx_verify_receipt: extract + verify the AI-generation receipt.
|
|
1495
|
+
// Surfaces caller-declared agent.name as declared; no cryptographic
|
|
1496
|
+
// identity binding in v1.
|
|
1497
|
+
if (name === 'xlsx_verify_receipt') {
|
|
1498
|
+
const body = { file_b64: fileToB64(args.file_path) };
|
|
1499
|
+
return callTool('xlsx_verify_receipt', body);
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1190
1502
|
// All other tools (list_sheets, schema, hyperlinks, conditional_formats,
|
|
1191
1503
|
// styles, etc.) — single-file relay. Forward any common option keys the
|
|
1192
1504
|
// routes accept so we don't silently drop them. New keys added here as
|
|
@@ -1219,7 +1531,16 @@ async function main() {
|
|
|
1219
1531
|
} catch (_) {
|
|
1220
1532
|
catalog = { tools: TOOLS, source: 'static-fallback' };
|
|
1221
1533
|
}
|
|
1222
|
-
|
|
1534
|
+
// Surface catalog source so operators can tell server vs cache vs static
|
|
1535
|
+
// when an MCP session looks "off" (e.g., a tool missing because the remote
|
|
1536
|
+
// /api/v1/tools/list 404'd and we silently fell back to the stale baked-in
|
|
1537
|
+
// set). Stderr only — stdout is the MCP transport.
|
|
1538
|
+
process.stderr.write(`xlsx-for-ai-mcp: tool catalog source=${catalog.source} count=${Array.isArray(catalog.tools) ? catalog.tools.length : 0}\n`);
|
|
1539
|
+
// Overlay MCP annotations (title / readOnlyHint / destructiveHint) so
|
|
1540
|
+
// they flow through to clients regardless of catalog source. The remote
|
|
1541
|
+
// /api/v1/tools/list returns minimal entries today; this is what
|
|
1542
|
+
// restores the annotations the wire format would otherwise drop.
|
|
1543
|
+
const liveTools = applyAnnotations(Array.isArray(catalog.tools) ? catalog.tools : []);
|
|
1223
1544
|
|
|
1224
1545
|
const server = new Server(
|
|
1225
1546
|
{ name: 'xlsx-for-ai', version: require('./package.json').version },
|
|
@@ -1266,5 +1587,9 @@ if (require.main === module) {
|
|
|
1266
1587
|
});
|
|
1267
1588
|
}
|
|
1268
1589
|
|
|
1269
|
-
//
|
|
1270
|
-
|
|
1590
|
+
// Exports for build-time scripts and tests only. Do NOT import these from
|
|
1591
|
+
// runtime production code — they're only here to let the manifest-build
|
|
1592
|
+
// script use TOOLS as the single source of truth for downstream artifacts
|
|
1593
|
+
// (manifest.json, mcp-tools.json snapshot consumed by the MSFT plugin
|
|
1594
|
+
// manifest), and to expose helpers under test.
|
|
1595
|
+
module.exports = { applyFileB64, dispatchTool, TOOLS };
|
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.
|
|
4
|
+
"version": "2.25.0",
|
|
5
5
|
"description": "The MCP server that makes LLMs reliable on real-world Excel spreadsheets. Thin npm client over a hosted API — read, write, diff, redact, and supervise .xlsx files from any MCP-aware agent.",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"bin": {
|
|
@@ -22,7 +22,10 @@
|
|
|
22
22
|
"LICENSE"
|
|
23
23
|
],
|
|
24
24
|
"scripts": {
|
|
25
|
-
"test": "node --test test/v2/*.test.js"
|
|
25
|
+
"test": "node --test test/v2/*.test.js",
|
|
26
|
+
"build-manifests": "node scripts/build-manifests.js",
|
|
27
|
+
"check-manifests": "node scripts/build-manifests.js --check",
|
|
28
|
+
"prepare": "husky"
|
|
26
29
|
},
|
|
27
30
|
"keywords": [
|
|
28
31
|
"xlsx",
|
|
@@ -57,5 +60,8 @@
|
|
|
57
60
|
},
|
|
58
61
|
"optionalDependencies": {
|
|
59
62
|
"@protobi/exceljs": "^4.4.0-protobi.9"
|
|
63
|
+
},
|
|
64
|
+
"devDependencies": {
|
|
65
|
+
"husky": "^9.1.7"
|
|
60
66
|
}
|
|
61
67
|
}
|