xlsx-for-ai 2.22.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 +13 -1
- package/SECURITY.md +10 -9
- package/index.js +257 -2
- package/lib/fallback-read.js +93 -6
- package/mcp.js +414 -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
|
|
|
@@ -196,6 +196,15 @@ For custom MCP clients, the binary is `xlsx-for-ai-mcp` (stdio transport). Overr
|
|
|
196
196
|
| `xlsx_post_slack` | Post a workbook to a Slack channel as a file attachment with an optional message. BYOA — the agent supplies the user's Slack bot token (`xoxb-…`); the token is forwarded to Slack and never persisted. Uses Slack's external upload flow. |
|
|
197
197
|
| `xlsx_post_teams` | Post a workbook to a Microsoft Teams channel as a file attachment in a channel message, with an optional message. BYOA — the agent supplies the user's Microsoft Graph access token (JWT); the token is forwarded to Microsoft and never persisted. Uses Graph's filesFolder + upload-session + post-message flow. |
|
|
198
198
|
|
|
199
|
+
### Integrity verification
|
|
200
|
+
|
|
201
|
+
| Tool | What it does |
|
|
202
|
+
|---|---|
|
|
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
|
+
| `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. |
|
|
207
|
+
|
|
199
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.
|
|
200
209
|
|
|
201
210
|
---
|
|
@@ -355,3 +364,6 @@ The config file at `~/.xlsx-for-ai/config.json` is extended in-place — existin
|
|
|
355
364
|
## Security
|
|
356
365
|
|
|
357
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,32 +919,233 @@ 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'],
|
|
940
|
+
},
|
|
941
|
+
},
|
|
942
|
+
|
|
943
|
+
{
|
|
944
|
+
name: 'xlsx_stamp',
|
|
945
|
+
description:
|
|
946
|
+
'xlsx-for-ai — read, write, diff, redact, supervise .xlsx files locally.\n' +
|
|
947
|
+
'This tool: sign a workbook with a "workbook integrity verification" stamp — a cryptographic attestation embedded in docProps/custom.xml that says "this file was generated by these tools, passed these N specific checks, signed at this time, and hasn\'t been tampered with since." Factual claims only (never an opinion-shaped seal of approval). Returns the stamped workbook as base64 in _meta.file_b64; pass out_path to write to disk.\n' +
|
|
948
|
+
'The caller supplies the `checks` array (e.g., from a supervisor review): list of named tests, each with passed/failed/skipped status. Verifiers see the individual check results, not a single good/bad opinion.\n\n' +
|
|
949
|
+
'USE WHEN: the agent has just produced or reviewed a workbook and wants to attach provable provenance + check results that travel with the file. The recipient can later verify the stamp via xlsx_verify_stamp to confirm the file hasn\'t been modified and to see the original check results.\n\n' +
|
|
950
|
+
'DO NOT USE WHEN: the user just wants to share a file (use xlsx_post_slack / xlsx_post_teams). Or when there are no real checks to attest to (an empty checks array is allowed and means "we ran zero checks" — but the stamp\'s value is in the checks it records).',
|
|
951
|
+
inputSchema: {
|
|
952
|
+
type: 'object',
|
|
953
|
+
properties: {
|
|
954
|
+
file_path: { type: 'string', description: 'Absolute path to the .xlsx file to stamp.' },
|
|
955
|
+
checks: {
|
|
956
|
+
type: 'array',
|
|
957
|
+
description: 'Array of named checks, each with passed/failed/skipped status. These are the factual claims the stamp attests to.',
|
|
958
|
+
items: {
|
|
959
|
+
type: 'object',
|
|
960
|
+
properties: {
|
|
961
|
+
id: { type: 'string', description: 'Stable check identifier (e.g., "tieouts_consistent").' },
|
|
962
|
+
name: { type: 'string', description: 'Human-readable check name.' },
|
|
963
|
+
status: { type: 'string', enum: ['passed', 'failed', 'skipped'] },
|
|
964
|
+
detail: { type: 'string', description: 'Optional explanation, especially for failed/skipped status.' },
|
|
965
|
+
},
|
|
966
|
+
required: ['id', 'name', 'status'],
|
|
967
|
+
},
|
|
968
|
+
},
|
|
969
|
+
out_path: { type: 'string', description: 'Optional: write the stamped workbook to this absolute path. If omitted, the stamped bytes are returned in _meta.file_b64 only.' },
|
|
970
|
+
exclude_sheets: { type: 'array', items: { type: 'string' }, description: 'Optional: sheet names to exclude from the content hash. Use for scratch tabs that legitimately change without affecting attestation.' },
|
|
971
|
+
supervisor_version: { type: 'string', description: 'Optional: xlsx-supervisor version string to include in the stamp\'s generated_by claim (e.g., "xlsx-supervisor@1.4.0").' },
|
|
972
|
+
},
|
|
973
|
+
required: ['file_path', 'checks'],
|
|
974
|
+
},
|
|
975
|
+
},
|
|
976
|
+
|
|
977
|
+
{
|
|
978
|
+
name: 'xlsx_verify_stamp',
|
|
979
|
+
description:
|
|
980
|
+
'xlsx-for-ai — read, write, diff, redact, supervise .xlsx files locally.\n' +
|
|
981
|
+
'This tool: verify a workbook\'s embedded integrity-verification stamp. Returns whether the cryptographic signature is valid, whether the workbook bytes match what was signed (recomputed hash vs hash IN the stamp), and the full check-result content of the stamp.\n' +
|
|
982
|
+
'A workbook can fail verification three ways: (1) no stamp present (file was never stamped, or the stamp was stripped); (2) signature_valid=false (someone modified the claims after signing, or signed with a different key); (3) hash_matches=false (someone modified the workbook bytes after signing). Each is a distinct trust signal.\n\n' +
|
|
983
|
+
'USE WHEN: the agent (or a downstream verifier) needs to confirm a workbook hasn\'t been tampered with since it was signed, OR needs to surface the original check results that were attested to. Common scenario: incoming workbook from a counterparty, agent runs verify before trusting any of its values.',
|
|
984
|
+
inputSchema: {
|
|
985
|
+
type: 'object',
|
|
986
|
+
properties: {
|
|
987
|
+
file_path: { type: 'string', description: 'Absolute path to the .xlsx file to verify.' },
|
|
988
|
+
},
|
|
989
|
+
required: ['file_path'],
|
|
990
|
+
},
|
|
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'],
|
|
873
1046
|
},
|
|
874
1047
|
},
|
|
875
1048
|
];
|
|
876
1049
|
|
|
877
1050
|
// ---------------------------------------------------------------------------
|
|
878
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.
|
|
879
1060
|
// ---------------------------------------------------------------------------
|
|
880
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
|
+
|
|
881
1073
|
function fileToB64(filePath) {
|
|
882
|
-
|
|
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
|
+
}
|
|
883
1149
|
}
|
|
884
1150
|
|
|
885
1151
|
// ---------------------------------------------------------------------------
|
|
@@ -953,7 +1219,23 @@ async function dispatchTool(name, args) {
|
|
|
953
1219
|
if (name === 'xlsx_write') {
|
|
954
1220
|
let spec = args.spec;
|
|
955
1221
|
if (!spec && args.spec_path) {
|
|
956
|
-
|
|
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'));
|
|
957
1239
|
}
|
|
958
1240
|
const writeBody = { spec };
|
|
959
1241
|
if (args.base_file_b64) writeBody.base_file_b64 = args.base_file_b64;
|
|
@@ -1085,14 +1367,47 @@ async function dispatchTool(name, args) {
|
|
|
1085
1367
|
});
|
|
1086
1368
|
}
|
|
1087
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
|
+
|
|
1088
1387
|
// xlsx_post_slack: outbound file-to-Slack. Top-level fields, not the
|
|
1089
1388
|
// standard {file_b64, options} shape — channel + slack_token + message
|
|
1090
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.
|
|
1091
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.
|
|
1092
1407
|
const body = {
|
|
1093
1408
|
file_b64: fileToB64(args.file_path),
|
|
1094
1409
|
channel: args.channel,
|
|
1095
|
-
slack_token:
|
|
1410
|
+
slack_token: slackToken,
|
|
1096
1411
|
};
|
|
1097
1412
|
if (args.message !== undefined) body.message = args.message;
|
|
1098
1413
|
body.filename = args.filename || path.basename(args.file_path);
|
|
@@ -1101,18 +1416,89 @@ async function dispatchTool(name, args) {
|
|
|
1101
1416
|
|
|
1102
1417
|
// xlsx_post_teams: outbound file-to-Teams. Same shape as Slack but with
|
|
1103
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.
|
|
1104
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.
|
|
1105
1436
|
const body = {
|
|
1106
1437
|
file_b64: fileToB64(args.file_path),
|
|
1107
1438
|
team_id: args.team_id,
|
|
1108
1439
|
channel_id: args.channel_id,
|
|
1109
|
-
graph_token:
|
|
1440
|
+
graph_token: graphToken,
|
|
1110
1441
|
};
|
|
1111
1442
|
if (args.message !== undefined) body.message = args.message;
|
|
1112
1443
|
body.filename = args.filename || path.basename(args.file_path);
|
|
1113
1444
|
return callTool('xlsx_post_teams', body);
|
|
1114
1445
|
}
|
|
1115
1446
|
|
|
1447
|
+
// xlsx_stamp: sign + embed an integrity-verification stamp. Returns the
|
|
1448
|
+
// stamped file as base64 in _meta.file_b64; if out_path is provided we
|
|
1449
|
+
// also write the bytes to disk (same pattern as xlsx_write / xlsx_redact).
|
|
1450
|
+
if (name === 'xlsx_stamp') {
|
|
1451
|
+
const body = {
|
|
1452
|
+
file_b64: fileToB64(args.file_path),
|
|
1453
|
+
checks: args.checks,
|
|
1454
|
+
};
|
|
1455
|
+
if (args.exclude_sheets !== undefined) body.exclude_sheets = args.exclude_sheets;
|
|
1456
|
+
if (args.supervisor_version !== undefined) {
|
|
1457
|
+
body.generated_by = { npm: 'xlsx-for-ai@' + require('./package.json').version, supervisor: args.supervisor_version };
|
|
1458
|
+
}
|
|
1459
|
+
const result = await callTool('xlsx_stamp', body);
|
|
1460
|
+
return applyFileB64(result, args.out_path);
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
// xlsx_verify_stamp: extract + verify the integrity-verification stamp.
|
|
1464
|
+
// Returns structured result in _meta.{valid, signature_valid, hash_matches,
|
|
1465
|
+
// claims, workbook_hash_in_stamp, workbook_hash_recomputed, …}.
|
|
1466
|
+
if (name === 'xlsx_verify_stamp') {
|
|
1467
|
+
const body = { file_b64: fileToB64(args.file_path) };
|
|
1468
|
+
return callTool('xlsx_verify_stamp', body);
|
|
1469
|
+
}
|
|
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
|
+
|
|
1116
1502
|
// All other tools (list_sheets, schema, hyperlinks, conditional_formats,
|
|
1117
1503
|
// styles, etc.) — single-file relay. Forward any common option keys the
|
|
1118
1504
|
// routes accept so we don't silently drop them. New keys added here as
|
|
@@ -1145,7 +1531,16 @@ async function main() {
|
|
|
1145
1531
|
} catch (_) {
|
|
1146
1532
|
catalog = { tools: TOOLS, source: 'static-fallback' };
|
|
1147
1533
|
}
|
|
1148
|
-
|
|
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 : []);
|
|
1149
1544
|
|
|
1150
1545
|
const server = new Server(
|
|
1151
1546
|
{ name: 'xlsx-for-ai', version: require('./package.json').version },
|
|
@@ -1192,5 +1587,9 @@ if (require.main === module) {
|
|
|
1192
1587
|
});
|
|
1193
1588
|
}
|
|
1194
1589
|
|
|
1195
|
-
//
|
|
1196
|
-
|
|
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
|
}
|