xlsx-for-ai 2.19.0 → 2.20.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.
@@ -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/lib/register.js CHANGED
@@ -8,6 +8,12 @@
8
8
  *
9
9
  * Writes result to config and returns { client_id, api_key }.
10
10
  * Idempotent: if config already has api_key, returns it immediately.
11
+ *
12
+ * CI gate: when running in a CI environment (CI=true, GITHUB_ACTIONS=true,
13
+ * or XLSX_FOR_AI_CI=1) we skip registration entirely. This stops automated
14
+ * smoke tests + clean-install verifications from polluting the production
15
+ * client_id pool with synthetic per-publish UUIDs that don't represent
16
+ * real human users.
11
17
  */
12
18
 
13
19
  const os = require('os');
@@ -19,7 +25,33 @@ function platform() {
19
25
  return `${process.platform}-${process.arch}`;
20
26
  }
21
27
 
28
+ // Detect common CI signals. Bias is toward FALSE POSITIVES on the CI side
29
+ // (a real user running with CI=true in their shell will get the same skip).
30
+ // Those cases are vanishingly rare, and the cost of a missed CI gate is much
31
+ // higher: polluted analytics + 1M MAU dilution.
32
+ function isCiEnvironment() {
33
+ if (process.env.XLSX_FOR_AI_CI === '1') return true;
34
+ // GitHub Actions auto-sets CI=true AND GITHUB_ACTIONS=true. Other major
35
+ // providers also set CI=true (CircleCI, GitLab, Travis, Azure Pipelines,
36
+ // BuildKite, Drone, Jenkins via plugin).
37
+ if (process.env.CI === 'true' || process.env.CI === '1') return true;
38
+ if (process.env.GITHUB_ACTIONS === 'true') return true;
39
+ return false;
40
+ }
41
+
22
42
  async function ensureRegistered() {
43
+ if (isCiEnvironment()) {
44
+ // Return a sentinel handle. api_key prefix 'xfa_ci_' is invalid format,
45
+ // so any tool call would 401 with a clear "Invalid API key" rather than
46
+ // silently using a leaked real key. CI smoke tests that only call
47
+ // --version short-circuit before reaching this anyway.
48
+ return {
49
+ client_id: '00000000-0000-0000-0000-000000000000',
50
+ api_key: 'xfa_ci_skip_registration',
51
+ ci_skipped: true,
52
+ };
53
+ }
54
+
23
55
  const cfg = readConfig();
24
56
  if (cfg && cfg.api_key && cfg.client_id) {
25
57
  return { client_id: cfg.client_id, api_key: cfg.api_key };
@@ -34,4 +66,4 @@ async function ensureRegistered() {
34
66
  return { client_id: data.client_id, api_key: data.api_key };
35
67
  }
36
68
 
37
- module.exports = { ensureRegistered };
69
+ module.exports = { ensureRegistered, isCiEnvironment };
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');
@@ -1061,16 +1062,33 @@ async function dispatchTool(name, args) {
1061
1062
  async function main() {
1062
1063
  await ensureRegistered();
1063
1064
 
1065
+ // Dynamic tool catalog: query the hosted API once at startup so new
1066
+ // server-side tools appear without re-publishing this npm package.
1067
+ // resolveCatalog returns the baked-in TOOLS as last-resort fallback so
1068
+ // we never fail-open on a transient network blip. See lib/discover.js.
1069
+ let catalog;
1070
+ try {
1071
+ catalog = await resolveCatalog(TOOLS);
1072
+ } catch (_) {
1073
+ catalog = { tools: TOOLS, source: 'static-fallback' };
1074
+ }
1075
+ const liveTools = catalog.tools;
1076
+
1064
1077
  const server = new Server(
1065
1078
  { name: 'xlsx-for-ai', version: require('./package.json').version },
1066
1079
  { capabilities: { tools: {} } }
1067
1080
  );
1068
1081
 
1069
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
1082
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: liveTools }));
1070
1083
 
1071
1084
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
1072
1085
  const { name, arguments: args } = request.params;
1073
- const tool = TOOLS.find((t) => t.name === name);
1086
+ // Accept any tool the live catalog advertises. dispatchTool has a
1087
+ // generic single-file relay path (see end of dispatchTool) that handles
1088
+ // any unknown tool name by forwarding {file_b64, options} to the server,
1089
+ // so dynamically-discovered tools "just work" as long as their server
1090
+ // contract matches that shape.
1091
+ const tool = liveTools.find((t) => t.name === name);
1074
1092
  if (!tool) {
1075
1093
  return {
1076
1094
  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.19.0",
4
+ "version": "2.20.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"