xlsx-for-ai 2.19.1 → 2.21.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 -0
- package/lib/client.js +5 -1
- package/lib/discover.js +174 -0
- package/mcp.js +56 -2
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -189,6 +189,12 @@ For custom MCP clients, the binary is `xlsx-for-ai-mcp` (stdio transport). Overr
|
|
|
189
189
|
| `xlsx_form_controls` | Interactive widgets — checkboxes, buttons, drop-downs, spinners, scroll bars, list boxes — with linked cell + bounds. |
|
|
190
190
|
| `xlsx_macros` | VBA macro presence + module-name heuristics + safety advice (does NOT extract source by policy). |
|
|
191
191
|
|
|
192
|
+
### Integrations
|
|
193
|
+
|
|
194
|
+
| Tool | What it does |
|
|
195
|
+
|---|---|
|
|
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
|
+
|
|
192
198
|
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.
|
|
193
199
|
|
|
194
200
|
---
|
package/lib/client.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
const { readConfig } = require('./config');
|
|
15
|
+
const { version: CLIENT_VERSION } = require('../package.json');
|
|
15
16
|
|
|
16
17
|
const DEFAULT_API = 'https://api.xlsx-for-ai.dev';
|
|
17
18
|
const TIMEOUT_MS = 30_000;
|
|
@@ -32,7 +33,10 @@ async function fetchWithTimeout(url, init) {
|
|
|
32
33
|
|
|
33
34
|
async function post(path, body, opts = {}) {
|
|
34
35
|
const url = apiBase() + path;
|
|
35
|
-
const headers = {
|
|
36
|
+
const headers = {
|
|
37
|
+
'Content-Type': 'application/json',
|
|
38
|
+
'X-Client-Version': CLIENT_VERSION,
|
|
39
|
+
};
|
|
36
40
|
|
|
37
41
|
if (opts.auth !== false) {
|
|
38
42
|
const cfg = readConfig();
|
package/lib/discover.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Dynamic tool catalog discovery.
|
|
5
|
+
*
|
|
6
|
+
* At MCP server startup we ask the hosted API "what tools do you support?" so
|
|
7
|
+
* new server-side tools appear in users' agents WITHOUT us re-publishing the
|
|
8
|
+
* npm package or re-signing the .mcpb. The thin client stays thin; the catalog
|
|
9
|
+
* lives where the tools live.
|
|
10
|
+
*
|
|
11
|
+
* Endpoint: GET ${apiBase}/api/v1/tools/list
|
|
12
|
+
* -> { tools: [{ name, description, inputSchema, ... }, ...], version? }
|
|
13
|
+
*
|
|
14
|
+
* Behaviour:
|
|
15
|
+
* - Fetch with a short timeout (3s — startup-blocking, must not hang an agent).
|
|
16
|
+
* - On success: cache to ~/.xlsx-for-ai/tools-cache.json with TTL.
|
|
17
|
+
* - On failure (404, network, timeout): use the cache if fresh; else use the
|
|
18
|
+
* baked-in static fallback the caller passes in.
|
|
19
|
+
* - The local fallback is the floor, NEVER the ceiling. Server > cache > static.
|
|
20
|
+
*
|
|
21
|
+
* Why dynamic: today every new server-side tool requires a TOOLS array edit +
|
|
22
|
+
* version bump + npm publish + (post-Phase 4.5) .mcpb rebuild + Anthropic
|
|
23
|
+
* directory re-review. With dynamic discovery the only release vehicle is the
|
|
24
|
+
* server deploy. See ~/xlsx-for-ai-internal/ROADMAP.md Phase 4.5.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const fs = require('fs');
|
|
28
|
+
const path = require('path');
|
|
29
|
+
const crypto = require('crypto');
|
|
30
|
+
|
|
31
|
+
const { apiBase } = require('./client');
|
|
32
|
+
const { configPath } = require('./config');
|
|
33
|
+
|
|
34
|
+
const DISCOVER_TIMEOUT_MS = 3_000;
|
|
35
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24h
|
|
36
|
+
|
|
37
|
+
function cachePath() {
|
|
38
|
+
// Scope the cache by API base so switching between prod / staging / a custom
|
|
39
|
+
// XLSX_FOR_AI_API doesn't reuse a catalog from a different host. Co-located
|
|
40
|
+
// with config.json so XFA_CONFIG_DIR override flows through for tests.
|
|
41
|
+
const baseHash = crypto.createHash('sha256').update(apiBase()).digest('hex').slice(0, 16);
|
|
42
|
+
return path.join(path.dirname(configPath()), `tools-cache-${baseHash}.json`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readCache() {
|
|
46
|
+
try {
|
|
47
|
+
const raw = fs.readFileSync(cachePath(), 'utf8');
|
|
48
|
+
const obj = JSON.parse(raw);
|
|
49
|
+
if (!obj || !Array.isArray(obj.tools) || typeof obj.fetched_at !== 'number') return null;
|
|
50
|
+
return obj;
|
|
51
|
+
} catch (_) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function writeCache(tools) {
|
|
57
|
+
try {
|
|
58
|
+
const finalPath = cachePath();
|
|
59
|
+
const dir = path.dirname(finalPath);
|
|
60
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
61
|
+
const payload = { fetched_at: Date.now(), tools };
|
|
62
|
+
// Atomic write: temp file in the same dir + rename. Avoids torn writes
|
|
63
|
+
// visible to a concurrent reader (e.g., two MCP server processes starting
|
|
64
|
+
// at once on the same host).
|
|
65
|
+
const tmpPath = `${finalPath}.${process.pid}.tmp`;
|
|
66
|
+
fs.writeFileSync(tmpPath, JSON.stringify(payload, null, 2) + '\n', 'utf8');
|
|
67
|
+
fs.renameSync(tmpPath, finalPath);
|
|
68
|
+
} catch (_) {
|
|
69
|
+
// Cache write failures are non-fatal — the next startup just re-fetches.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isCacheFresh(entry) {
|
|
74
|
+
if (!entry || typeof entry.fetched_at !== 'number') return false;
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
// Future timestamps are never "fresh" — clock skew or tampering would
|
|
77
|
+
// otherwise pin a cache forever (negative age < TTL is always true).
|
|
78
|
+
if (entry.fetched_at > now) return false;
|
|
79
|
+
return (now - entry.fetched_at) < CACHE_TTL_MS;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function fetchRemoteCatalog() {
|
|
83
|
+
const url = apiBase() + '/api/v1/tools/list';
|
|
84
|
+
const controller = new AbortController();
|
|
85
|
+
const timer = setTimeout(() => controller.abort(), DISCOVER_TIMEOUT_MS);
|
|
86
|
+
let res;
|
|
87
|
+
try {
|
|
88
|
+
res = await fetch(url, {
|
|
89
|
+
method: 'GET',
|
|
90
|
+
headers: { Accept: 'application/json' },
|
|
91
|
+
signal: controller.signal,
|
|
92
|
+
});
|
|
93
|
+
} finally {
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
}
|
|
96
|
+
if (!res.ok) {
|
|
97
|
+
const e = new Error(`tools/list returned HTTP ${res.status}`);
|
|
98
|
+
e.status = res.status;
|
|
99
|
+
throw e;
|
|
100
|
+
}
|
|
101
|
+
const body = await res.json();
|
|
102
|
+
if (!body || !Array.isArray(body.tools)) {
|
|
103
|
+
throw new Error('tools/list response missing tools array');
|
|
104
|
+
}
|
|
105
|
+
return body.tools;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* mergeTools: server catalog wins on name collision; baked-in tools fill gaps.
|
|
110
|
+
* Order: every remote tool first (preserving server order), then any baked-in
|
|
111
|
+
* tool whose name isn't in the remote set. This way the most up-to-date
|
|
112
|
+
* description always wins, but we never lose a tool the client knows how to
|
|
113
|
+
* dispatch even if the server temporarily forgets it.
|
|
114
|
+
*/
|
|
115
|
+
function mergeTools(remote, baked) {
|
|
116
|
+
const out = [];
|
|
117
|
+
const seen = new Set();
|
|
118
|
+
for (const t of remote) {
|
|
119
|
+
if (!t || typeof t.name !== 'string') continue;
|
|
120
|
+
if (seen.has(t.name)) continue; // dedupe within remote too — first wins
|
|
121
|
+
out.push(t);
|
|
122
|
+
seen.add(t.name);
|
|
123
|
+
}
|
|
124
|
+
for (const t of baked) {
|
|
125
|
+
if (!t || typeof t.name !== 'string') continue; // tolerate malformed baked entries
|
|
126
|
+
if (seen.has(t.name)) continue;
|
|
127
|
+
out.push(t);
|
|
128
|
+
seen.add(t.name);
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Resolve the tool catalog the MCP server should expose.
|
|
135
|
+
*
|
|
136
|
+
* @param {Array} bakedFallback - the static TOOLS array embedded in the package
|
|
137
|
+
* @returns {Promise<{tools: Array, source: string}>}
|
|
138
|
+
* source ∈ 'remote' | 'cache' | 'cache-stale' | 'static'
|
|
139
|
+
*/
|
|
140
|
+
async function resolveCatalog(bakedFallback) {
|
|
141
|
+
// 1. Try remote. On success, cache and merge.
|
|
142
|
+
try {
|
|
143
|
+
const remote = await fetchRemoteCatalog();
|
|
144
|
+
writeCache(remote);
|
|
145
|
+
return { tools: mergeTools(remote, bakedFallback), source: 'remote' };
|
|
146
|
+
} catch (err) {
|
|
147
|
+
// fall through
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 2. Fresh cache wins over baked.
|
|
151
|
+
const cache = readCache();
|
|
152
|
+
if (isCacheFresh(cache)) {
|
|
153
|
+
return { tools: mergeTools(cache.tools, bakedFallback), source: 'cache' };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 3. Stale cache STILL wins over baked. The cache represents what the
|
|
157
|
+
// server said last time we could reach it; that's by definition more
|
|
158
|
+
// authoritative than what was hardcoded into this client version. The
|
|
159
|
+
// baked-in TOOLS still get merged in as the floor — mergeTools dedupes
|
|
160
|
+
// by name with cache entries winning, so users never lose a tool that
|
|
161
|
+
// used to be available even if the server temporarily forgets it.
|
|
162
|
+
if (cache) {
|
|
163
|
+
return { tools: mergeTools(cache.tools, bakedFallback), source: 'cache-stale' };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 4. Last resort: the baked-in fallback.
|
|
167
|
+
return { tools: bakedFallback, source: 'static' };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
module.exports = {
|
|
171
|
+
resolveCatalog,
|
|
172
|
+
// exported for tests
|
|
173
|
+
_internal: { mergeTools, readCache, writeCache, cachePath, fetchRemoteCatalog },
|
|
174
|
+
};
|
package/mcp.js
CHANGED
|
@@ -16,6 +16,7 @@ const { CallToolRequestSchema, ListToolsRequestSchema } = require('@modelcontext
|
|
|
16
16
|
const { ensureRegistered } = require('./lib/register');
|
|
17
17
|
const { callTool } = require('./lib/client');
|
|
18
18
|
const { fallbackRead } = require('./lib/fallback-read');
|
|
19
|
+
const { resolveCatalog } = require('./lib/discover');
|
|
19
20
|
const fs = require('fs');
|
|
20
21
|
const fsPromises = require('fs/promises');
|
|
21
22
|
const path = require('path');
|
|
@@ -826,6 +827,28 @@ const TOOLS = [
|
|
|
826
827
|
required: ['file_path'],
|
|
827
828
|
},
|
|
828
829
|
},
|
|
830
|
+
|
|
831
|
+
{
|
|
832
|
+
name: 'xlsx_post_slack',
|
|
833
|
+
description:
|
|
834
|
+
'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. BYOA — the agent must pass the user\'s Slack bot token (xoxb-…). The token is forwarded to Slack and never stored server-side.\n' +
|
|
836
|
+
'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
|
+
'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
|
+
'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 there is no Slack bot token available — the user must have installed a Slack app with files:write scope.',
|
|
840
|
+
inputSchema: {
|
|
841
|
+
type: 'object',
|
|
842
|
+
properties: {
|
|
843
|
+
file_path: { type: 'string', description: 'Absolute path to the .xlsx file to post.' },
|
|
844
|
+
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-…). Forwarded to Slack; never persisted by us.' },
|
|
846
|
+
message: { type: 'string', description: 'Optional: message to post alongside the file (Slack\'s initial_comment).' },
|
|
847
|
+
filename: { type: 'string', description: 'Optional: filename Slack will display. Defaults to the basename of file_path.' },
|
|
848
|
+
},
|
|
849
|
+
required: ['file_path', 'channel', 'slack_token'],
|
|
850
|
+
},
|
|
851
|
+
},
|
|
829
852
|
];
|
|
830
853
|
|
|
831
854
|
// ---------------------------------------------------------------------------
|
|
@@ -1039,6 +1062,20 @@ async function dispatchTool(name, args) {
|
|
|
1039
1062
|
});
|
|
1040
1063
|
}
|
|
1041
1064
|
|
|
1065
|
+
// xlsx_post_slack: outbound file-to-Slack. Top-level fields, not the
|
|
1066
|
+
// standard {file_b64, options} shape — channel + slack_token + message
|
|
1067
|
+
// + filename live alongside file_b64 in the server route's body schema.
|
|
1068
|
+
if (name === 'xlsx_post_slack') {
|
|
1069
|
+
const body = {
|
|
1070
|
+
file_b64: fileToB64(args.file_path),
|
|
1071
|
+
channel: args.channel,
|
|
1072
|
+
slack_token: args.slack_token,
|
|
1073
|
+
};
|
|
1074
|
+
if (args.message !== undefined) body.message = args.message;
|
|
1075
|
+
body.filename = args.filename || path.basename(args.file_path);
|
|
1076
|
+
return callTool('xlsx_post_slack', body);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1042
1079
|
// All other tools (list_sheets, schema, hyperlinks, conditional_formats,
|
|
1043
1080
|
// styles, etc.) — single-file relay. Forward any common option keys the
|
|
1044
1081
|
// routes accept so we don't silently drop them. New keys added here as
|
|
@@ -1061,16 +1098,33 @@ async function dispatchTool(name, args) {
|
|
|
1061
1098
|
async function main() {
|
|
1062
1099
|
await ensureRegistered();
|
|
1063
1100
|
|
|
1101
|
+
// Dynamic tool catalog: query the hosted API once at startup so new
|
|
1102
|
+
// server-side tools appear without re-publishing this npm package.
|
|
1103
|
+
// resolveCatalog returns the baked-in TOOLS as last-resort fallback so
|
|
1104
|
+
// we never fail-open on a transient network blip. See lib/discover.js.
|
|
1105
|
+
let catalog;
|
|
1106
|
+
try {
|
|
1107
|
+
catalog = await resolveCatalog(TOOLS);
|
|
1108
|
+
} catch (_) {
|
|
1109
|
+
catalog = { tools: TOOLS, source: 'static-fallback' };
|
|
1110
|
+
}
|
|
1111
|
+
const liveTools = catalog.tools;
|
|
1112
|
+
|
|
1064
1113
|
const server = new Server(
|
|
1065
1114
|
{ name: 'xlsx-for-ai', version: require('./package.json').version },
|
|
1066
1115
|
{ capabilities: { tools: {} } }
|
|
1067
1116
|
);
|
|
1068
1117
|
|
|
1069
|
-
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools:
|
|
1118
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: liveTools }));
|
|
1070
1119
|
|
|
1071
1120
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
1072
1121
|
const { name, arguments: args } = request.params;
|
|
1073
|
-
|
|
1122
|
+
// Accept any tool the live catalog advertises. dispatchTool has a
|
|
1123
|
+
// generic single-file relay path (see end of dispatchTool) that handles
|
|
1124
|
+
// any unknown tool name by forwarding {file_b64, options} to the server,
|
|
1125
|
+
// so dynamically-discovered tools "just work" as long as their server
|
|
1126
|
+
// contract matches that shape.
|
|
1127
|
+
const tool = liveTools.find((t) => t.name === name);
|
|
1074
1128
|
if (!tool) {
|
|
1075
1129
|
return {
|
|
1076
1130
|
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
|
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.21.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": {
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"lib/config.js",
|
|
17
17
|
"lib/register.js",
|
|
18
18
|
"lib/fallback-read.js",
|
|
19
|
+
"lib/discover.js",
|
|
19
20
|
"README.md",
|
|
20
21
|
"SECURITY.md",
|
|
21
22
|
"LICENSE"
|