@qe-mcp/ena 0.1.18 → 0.1.19

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 CHANGED
@@ -176,7 +176,13 @@ A hosted server has no access to anyone's files, so this mode is a different
176
176
  privacy promise, and it says so to the model. In `--http` mode:
177
177
 
178
178
  - Data arrives inline as `data_csv`; `data_path` is refused. The server
179
- processes it in memory and stores nothing.
179
+ parses it in memory and discards it when the call returns: no rows and no
180
+ CSV text are kept, in memory or on disk (locally, parsed rows are cached for
181
+ speed). What stays is the fitted model behind a `model_id` — points,
182
+ networks, unit labels and group values — for `$ENA_MCP_MODEL_TTL_MIN`
183
+ minutes after its last use (default 60), then it is forgotten.
184
+ `ena_simplify` refits, so hosted it takes `data_csv` again and checks that
185
+ it is the data the model was fitted on.
180
186
  - `ena_profile`, `ena_tables`, `ena_lines` and `ena_export` are not offered
181
187
  (they read or write files), and the prompts are not listed (they take a
182
188
  path). Plots come back as the inline PNG plus the SVG as text; no files are
@@ -187,11 +193,67 @@ privacy promise, and it says so to the model. In `--http` mode:
187
193
  model.
188
194
 
189
195
  Each request is answered statelessly, so any replica can take any request,
190
- but fitted models are cached in the process that fitted them: run one
191
- replica, or use sticky sessions, for `model_id`s to stay valid across calls.
196
+ but fitted models live in the process that fitted them, until they expire:
197
+ run one replica, or use sticky sessions, for `model_id`s to stay valid
198
+ across calls.
192
199
  Put TLS and whatever authentication or rate limiting you need in front of it;
193
200
  the server itself does neither. `node test-http.mjs` starts it on a free port
194
- and checks all of the above.
201
+ and checks all of the above; with `ENA_MCP_TEST_URL=http://host:port` it
202
+ checks a server that is already running instead.
203
+
204
+ #### Kubernetes
205
+
206
+ The `Dockerfile` builds the server as `node server.js --http` on port 8000
207
+ from the files the package ships plus production dependencies (no token
208
+ needed: the `@qe-libs` packages resolve from a public registry endpoint).
209
+ CI builds it for every main commit that touches those files and re-tags it
210
+ with each `vX.Y.Z` release tag, at
211
+ `registry.gitlab.com/epistemic-analytics/qe-packages/server-ena`.
212
+
213
+ `inst/k8s/` is a Helm chart in the shape the other qe apps use: one replica
214
+ with readiness and liveness on `/healthz`, and an nginx Ingress for
215
+ `hostUrl` with a body limit above the server's, buffering off, and a
216
+ per-client-IP rate limit (`rateLimit` in `values.yaml`: requests per second,
217
+ burst, concurrent connections; over it nginx answers 503). The limiter keys
218
+ on the address nginx sees, so it only tells clients apart if the controller
219
+ takes the real client address from the load balancer; and all claude.ai
220
+ users share Anthropic's egress addresses, so the defaults are generous.
221
+ `ENA_MCP_MODEL_TTL_MIN` and `ENA_MCP_MAX_BODY_MB` come from the chart
222
+ values. `inst/k8s/argo/production.yaml` is the Argo CD Application to copy
223
+ into the argo-deployments repository; its image updater follows the
224
+ semver image tags, so a release rolls out by itself.
225
+
226
+ Locally:
227
+
228
+ ```bash
229
+ docker build -t qe-mcp-ena . && docker run --rm -p 8000:8000 qe-mcp-ena
230
+ ENA_MCP_TEST_URL=http://127.0.0.1:8000 node test-http.mjs
231
+ ```
232
+
233
+ #### Cloudflare Workers
234
+
235
+ The same server runs as a Worker, without Node. `worker/build.mjs` bundles it
236
+ with esbuild into `worker/dist/`: the files the server reads from disk
237
+ (fonts, the qeviz bundle, the skill text) are embedded, and the WASM modules
238
+ are imported precompiled, as Workers require. Then wrangler runs or deploys
239
+ it (Node 22 or newer for wrangler):
240
+
241
+ ```bash
242
+ node worker/build.mjs
243
+ cd worker && npx wrangler dev # http://127.0.0.1:8787/mcp
244
+ cd worker && npx wrangler deploy
245
+ ```
246
+
247
+ Workers forbid `eval` and `new Function`, and Emscripten's Embind glue uses
248
+ them unless the module was linked with `-sDYNAMIC_EXECUTION=0`:
249
+ `@qe-libs/libqe-wasm` 0.2.4 and `@qe-libs/rena-wasm` 0.1.17 are the first
250
+ builds linked that way. To test a locally built module instead,
251
+ `ENA_WORKER_LIBQE_DIR` and `ENA_WORKER_LIBENA_DIR` name directories holding
252
+ `libqe.js`/`libqe.wasm` and `libena.js`/`libena.wasm`. Fits need the Workers Paid plan (the free plan's
253
+ 10 ms CPU limit is too tight; a fit on 3,800 rows with its PNG takes a few
254
+ hundred milliseconds). The model cache lives in the isolate that served the
255
+ request, so `model_id`s stay valid only while the same isolate keeps
256
+ answering; a shared store (KV or a Durable Object) would make them durable.
195
257
 
196
258
  ## Prompts
197
259
 
package/assets.js ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * assets.js — the files the server reads from its own package.
3
+ *
4
+ * Node reads them from disk next to this file. A Worker bundle (see
5
+ * worker/) has no disk: it calls setAssets() with the same contents embedded
6
+ * at build time, before server.js is imported. Every accessor is lazy so the
7
+ * Node reads only happen when they are actually used.
8
+ */
9
+
10
+ import { readFileSync } from 'node:fs';
11
+ import { createRequire } from 'node:module';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ // The bundled plot font (Inter, OFL), as paths for the native resvg and as
15
+ // bytes for the WASM one.
16
+ const FONT_FILES = ['Inter-Regular.ttf', 'Inter-Bold.ttf'];
17
+
18
+ const nodeAssets = {
19
+ pkgJson: () => JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')),
20
+ renaVersion() {
21
+ try { return JSON.parse(readFileSync(createRequire(import.meta.url).resolve('@qe-libs/rena-wasm/package.json'), 'utf8')).version; }
22
+ catch { return 'unknown'; }
23
+ },
24
+ // The qeviz UMD bundle, inlined into interactive pages.
25
+ qevizJS: () => readFileSync(createRequire(import.meta.url).resolve('@qe-libs/qeviz'), 'utf8'),
26
+ skillMarkdown() {
27
+ try { return readFileSync(new URL('./skill/ena-analysis/SKILL.md', import.meta.url), 'utf8'); }
28
+ catch { return null; }
29
+ },
30
+ // What @resvg/resvg-wasm's initWasm() accepts: bytes here, a compiled
31
+ // WebAssembly.Module in a Worker.
32
+ resvgWasm: () => readFileSync(createRequire(import.meta.url).resolve('@resvg/resvg-wasm/index_bg.wasm')),
33
+ fontPaths: () => FONT_FILES.map(f => fileURLToPath(new URL(`./fonts/${f}`, import.meta.url))),
34
+ fontBuffers: () => FONT_FILES.map(f => readFileSync(new URL(`./fonts/${f}`, import.meta.url))),
35
+ };
36
+
37
+ let impl = nodeAssets;
38
+
39
+ /** Replace the asset source (a Worker bundle does this before importing server.js). */
40
+ export function setAssets(assets) { impl = { ...nodeAssets, ...assets }; }
41
+
42
+ export const pkgJson = () => impl.pkgJson();
43
+ export const renaVersion = () => impl.renaVersion();
44
+ export const skillMarkdown = () => impl.skillMarkdown();
45
+ export const resvgWasm = () => impl.resvgWasm();
46
+ export const fontPaths = () => impl.fontPaths();
47
+ export const fontBuffers = () => impl.fontBuffers();
48
+
49
+ let _qevizJS = null;
50
+ export function qevizJS() {
51
+ // Drop the source-map comment: the .map file is not next to the inlined copy.
52
+ if (!_qevizJS) _qevizJS = String(impl.qevizJS()).replace(/\n\/\/# sourceMappingURL=\S*\s*$/, '\n');
53
+ return _qevizJS;
54
+ }
package/manifest.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "manifest_version": "0.2",
3
3
  "name": "qe-mcp-ena",
4
4
  "display_name": "ENA — Epistemic Network Analysis",
5
- "version": "0.1.18",
5
+ "version": "0.1.19",
6
6
  "description": "Epistemic Network Analysis on tabular coded data. Runs entirely on your machine — data never leaves your computer.",
7
7
  "long_description": "Run Epistemic Network Analysis (ENA), ordered network analysis (ONA) and transmodal analysis (TMA) on your coded data from a conversation with Claude. No R or Python to install.\n\n**Your data stays on your machine**\n\n- The analysis runs locally. Only results — networks, points, statistics and plots — go back to the conversation.\n- Give Claude a file path rather than pasting data; the server reads the file itself.\n- It makes no network connections, starts no other programs, and writes files only when you ask for an export.\n\n**About the install warning**\n\nClaude Desktop shows \"access to everything on your computer\" for every extension outside Anthropic's reviewed directory. It means you are trusting the source, as with any program you install. The source is published on npm as @qe-mcp/ena.\n\nDocumentation and example prompts: https://qe-libs.org/mcp/qe-mcp-ena/",
8
8
  "author": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qe-mcp/ena",
3
- "version": "0.1.18",
3
+ "version": "0.1.19",
4
4
  "description": "Epistemic Network Analysis for Claude and other assistants — runs on your machine",
5
5
  "repository": {
6
6
  "type": "git",
@@ -13,6 +13,7 @@
13
13
  },
14
14
  "files": [
15
15
  "server.js",
16
+ "assets.js",
16
17
  "webena.js",
17
18
  "xlsx.js",
18
19
  "lines.js",
@@ -27,7 +28,7 @@
27
28
  "dependencies": {
28
29
  "@modelcontextprotocol/sdk": "^1.4.0",
29
30
  "@qe-libs/qeviz": "0.7.0",
30
- "@qe-libs/rena-wasm": "^0.1.14",
31
+ "@qe-libs/rena-wasm": "^0.1.17",
31
32
  "@resvg/resvg-wasm": "^2.6.2"
32
33
  },
33
34
  "optionalDependencies": {
@@ -39,5 +40,8 @@
39
40
  "epistemic-network-analysis",
40
41
  "wasm"
41
42
  ],
42
- "license": "GPL-3.0-only"
43
+ "license": "GPL-3.0-only",
44
+ "devDependencies": {
45
+ "wrangler": "^4.156.0"
46
+ }
43
47
  }
package/server.js CHANGED
@@ -41,7 +41,8 @@ import { randomBytes } from 'crypto';
41
41
  // written to files, and the file-based tools are not offered.
42
42
  const ARGV = process.argv.slice(2);
43
43
  const argOf = (flag, dflt) => { const i = ARGV.indexOf(flag); return i >= 0 && ARGV[i + 1] != null ? ARGV[i + 1] : dflt; };
44
- const REMOTE = ARGV.includes('--http');
44
+ // Embedded in a Worker (globalThis.ENA_MCP_EMBEDDED) is always the hosted mode.
45
+ const REMOTE = ARGV.includes('--http') || !!globalThis.ENA_MCP_EMBEDDED;
45
46
  const HTTP_PORT = Number(argOf('--port', process.env.PORT ?? 8000));
46
47
  const HTTP_HOST = argOf('--host', process.env.HOST ?? '127.0.0.1');
47
48
  const HTTP_PATH = '/mcp';
@@ -53,8 +54,9 @@ if (ARGV.includes('--help') || ARGV.includes('-h')) {
53
54
  '',
54
55
  ' (no flags) MCP over stdio — what Claude Desktop, Claude Code and other local clients run.',
55
56
  ' --http MCP over streamable HTTP at /mcp, for hosting. Data must be passed inline',
56
- ' (data_csv); nothing is written to disk; ena_profile, ena_tables, ena_lines and',
57
- ' ena_export are not offered. Health check: GET /healthz.',
57
+ ' (data_csv) and is discarded after each call; only fitted models are kept, for',
58
+ ' $ENA_MCP_MODEL_TTL_MIN minutes (default 60); nothing is written to disk; ena_profile,',
59
+ ' ena_tables, ena_lines and ena_export are not offered. Health check: GET /healthz.',
58
60
  ' --port N Port for --http (default: $PORT or 8000).',
59
61
  ' --host H Interface for --http (default: $HOST or 127.0.0.1; use 0.0.0.0 in a container).',
60
62
  ' --max-body-mb N Largest request body in MB (default: $ENA_MCP_MAX_BODY_MB or 25).',
@@ -73,17 +75,9 @@ import loadENA, { detectParams, isBinary } from '@qe-libs/rena-wasm';
73
75
  // qeviz: renderSVG() draws the static plot here, with no DOM; the UMD bundle
74
76
  // (self-contained, no external deps) is inlined into the interactive page.
75
77
  import { renderSVG as qevizRenderSVG } from '@qe-libs/qeviz';
76
- import { createRequire } from 'module';
77
- const _require = createRequire(import.meta.url);
78
- const QEVIZ_BUNDLE = _require.resolve('@qe-libs/qeviz');
79
- let _qevizJS = null;
80
- function qevizJS() {
81
- // The bundle is inlined into pages, so drop its source-map comment
82
- // (the .map file is not next to the inlined copy).
83
- if (!_qevizJS) _qevizJS = readFileSync(QEVIZ_BUNDLE, 'utf8')
84
- .replace(/\n\/\/# sourceMappingURL=\S*\s*$/, '\n');
85
- return _qevizJS;
86
- }
78
+ // Package files (qeviz bundle, fonts, skill text, versions): read from disk
79
+ // in Node, embedded at build time in a Worker (see assets.js).
80
+ import { qevizJS, pkgJson, renaVersion, skillMarkdown, resvgWasm, fontBuffers, fontPaths } from './assets.js';
87
81
 
88
82
  // ── CSV parser ────────────────────────────────────────────────────────────────
89
83
 
@@ -388,7 +382,7 @@ function buildGmrParams(rows, unitCols, unitLabels, p) {
388
382
  // rotationSet (= R's rotation.set): centred on the source's centerVec, rotated
389
383
  // by its rotation matrix, with its node positions.
390
384
  function rotationSetFrom(sourceId, codes, ordered) {
391
- const src = cacheGet(modelCache, sourceId);
385
+ const src = modelGet(sourceId);
392
386
  if (!src)
393
387
  throw new Error(`rotation_from: unknown model_id '${sourceId}'. Fit the source model first ` +
394
388
  `(the ${MAX_MODELS} most recently used models are kept).`);
@@ -688,30 +682,41 @@ function vizSlug(result, args = {}) {
688
682
  // ── static SVG renderer ───────────────────────────────────────────────────────
689
683
 
690
684
  // PNG output (the inline image block and the png_plot file) prefers the native
691
- // @resvg/resvg-js, an OPTIONAL dependency: fastest, and it reads system fonts.
692
- // Where it is absent — the cross-platform .mcpb bundle, or a platform without a
693
- // prebuilt binary — @resvg/resvg-wasm renders the same image. It cannot see
694
- // system fonts, so plot labels are drawn with the bundled Inter (fonts/, OFL).
685
+ // @resvg/resvg-js, an OPTIONAL dependency: fastest. Where it is absent — the
686
+ // cross-platform .mcpb bundle, or a platform without a prebuilt binary —
687
+ // @resvg/resvg-wasm renders the same image. Both draw plot labels with the
688
+ // bundled Inter (fonts/, OFL) and never scan system fonts: resvg's default
689
+ // loadSystemFonts walks the whole system font set on EVERY render, which on
690
+ // macOS costs ~1.5 s per PNG against ~25 ms with the two bundled files.
695
691
  // Loaded lazily; if neither loads, SVG and interactive HTML are still produced.
692
+ //
693
+ // The two builds take the fonts differently. The WASM build takes the bytes
694
+ // (fontBuffers). The native build takes paths (fontFiles): given fontBuffers,
695
+ // resvg-js 2.6 scans the system fonts anyway, loadSystemFonts: false or not,
696
+ // and the 1.5 s is back.
697
+ const FONT_FAMILY = { defaultFontFamily: 'Inter', sansSerifFamily: 'Inter', loadSystemFonts: false };
698
+ // Wrap a Resvg class so every render uses the bundled fonts and asPng() yields
699
+ // a Buffer (the WASM build returns a Uint8Array).
700
+ function withBundledFonts(Resvg, fonts) {
701
+ let font;
702
+ return function FontedResvg(svg, opts = {}) {
703
+ font ??= { ...FONT_FAMILY, ...fonts() };
704
+ const r = new Resvg(svg, { ...opts, font: { ...font, ...opts.font } });
705
+ return { render: () => { const img = r.render(); return { asPng: () => Buffer.from(img.asPng()) }; } };
706
+ };
707
+ }
696
708
  let _Resvg; // undefined = not yet attempted; null = unavailable
697
709
  async function getResvg() {
698
710
  if (_Resvg !== undefined) return _Resvg;
699
711
  try {
700
- ({ Resvg: _Resvg } = await import('@resvg/resvg-js'));
712
+ const { Resvg } = await import('@resvg/resvg-js');
713
+ _Resvg = withBundledFonts(Resvg, () => ({ fontFiles: fontPaths() }));
701
714
  return _Resvg;
702
715
  } catch { /* fall back to WASM */ }
703
716
  try {
704
717
  const { initWasm, Resvg } = await import('@resvg/resvg-wasm');
705
- await initWasm(readFileSync(_require.resolve('@resvg/resvg-wasm/index_bg.wasm')));
706
- const fontBuffers = ['Inter-Regular.ttf', 'Inter-Bold.ttf']
707
- .map(f => readFileSync(new URL(`./fonts/${f}`, import.meta.url)));
708
- const font = { fontBuffers, defaultFontFamily: 'Inter', sansSerifFamily: 'Inter' };
709
- // Same shape as the native class — new Resvg(svg, opts).render().asPng()
710
- // — but asPng() gives a Uint8Array here, so hand back a Buffer as native does.
711
- _Resvg = function WasmResvg(svg, opts = {}) {
712
- const r = new Resvg(svg, { ...opts, font });
713
- return { render: () => { const img = r.render(); return { asPng: () => Buffer.from(img.asPng()) }; } };
714
- };
718
+ await initWasm(await resvgWasm());
719
+ _Resvg = withBundledFonts(Resvg, () => ({ fontBuffers: fontBuffers() }));
715
720
  } catch (e) {
716
721
  _Resvg = null;
717
722
  process.stderr.write(
@@ -849,6 +854,15 @@ let modelSeq = 0;
849
854
  const MAX_ACCUM = 8;
850
855
  const MAX_MODELS = 50;
851
856
 
857
+ // Hosted, the process serves strangers, so it keeps no data: parsed rows and
858
+ // accumulations are not cached (every call re-parses the CSV it was sent, tens
859
+ // of ms), the CSV text is dropped from a model's stored parameters, and a
860
+ // model — points, networks, unit and group labels — is forgotten MODEL_TTL_MS
861
+ // after its last use instead of waiting to be displaced.
862
+ const KEEP_ROWS = !REMOTE;
863
+ let MODEL_TTL_MS = REMOTE ? Number(process.env.ENA_MCP_MODEL_TTL_MIN ?? 60) * 60_000 : 0;
864
+ export function setModelTtl(minutes) { MODEL_TTL_MS = Number(minutes) * 60_000; }
865
+
852
866
  function cacheGet(map, key) {
853
867
  if (!map.has(key)) return undefined;
854
868
  const v = map.get(key);
@@ -861,6 +875,27 @@ function cacheSet(map, key, value, max) {
861
875
  while (map.size > max) map.delete(map.keys().next().value);
862
876
  }
863
877
 
878
+ function expireModels(now = Date.now()) {
879
+ if (!MODEL_TTL_MS) return;
880
+ for (const [id, e] of modelCache)
881
+ if (now - e._used > MODEL_TTL_MS) modelCache.delete(id);
882
+ }
883
+ function modelGet(model_id) {
884
+ expireModels();
885
+ const e = cacheGet(modelCache, model_id);
886
+ if (e) e._used = Date.now();
887
+ return e;
888
+ }
889
+ function modelSet(model_id, entry) {
890
+ expireModels();
891
+ if (!KEEP_ROWS && entry.params) {
892
+ entry._data = dataStamp(entry.params); // to check resent data against
893
+ entry.params = { ...entry.params, data_csv: undefined };
894
+ }
895
+ entry._used = Date.now();
896
+ cacheSet(modelCache, model_id, entry, MAX_MODELS);
897
+ }
898
+
864
899
  // Identifies the input data. For a file, include its mtime and size so an
865
900
  // edited CSV is re-read instead of serving a model fitted on the old contents.
866
901
  function dataStamp(p) {
@@ -1027,13 +1062,13 @@ function loadRows(p) {
1027
1062
  const key = dataStamp(p);
1028
1063
  let rows;
1029
1064
  if (rowsCache.has(key)) rows = cacheGet(rowsCache, key);
1030
- else { rows = loadData(p.data_path, p.data_csv); cacheSet(rowsCache, key, rows, MAX_ROWS); }
1065
+ else { rows = loadData(p.data_path, p.data_csv); if (KEEP_ROWS) cacheSet(rowsCache, key, rows, MAX_ROWS); }
1031
1066
  if (!toArray(p.exclude_conversations).length && !p.order_by) return rows;
1032
1067
 
1033
1068
  const vKey = JSON.stringify([key, toArray(p.conversations), toArray(p.exclude_conversations), p.order_by ?? null]);
1034
1069
  if (rowsCache.has(vKey)) return cacheGet(rowsCache, vKey);
1035
1070
  const view = prepareRows(rows, p);
1036
- cacheSet(rowsCache, vKey, view, MAX_ROWS);
1071
+ if (KEEP_ROWS) cacheSet(rowsCache, vKey, view, MAX_ROWS);
1037
1072
  return view;
1038
1073
  }
1039
1074
 
@@ -1437,7 +1472,7 @@ async function toolTmaFit(ena, params) {
1437
1472
  result,
1438
1473
  };
1439
1474
  entry.view = defaultView(params, entry.web);
1440
- cacheSet(modelCache, model_id, entry, MAX_MODELS);
1475
+ modelSet(model_id, entry);
1441
1476
 
1442
1477
  return { ...result, _plot: web.buildPlot(entry, entry.view) };
1443
1478
  }
@@ -1700,9 +1735,9 @@ async function toolFit(ena, params) {
1700
1735
  // Return cached model if params are identical
1701
1736
  const fKey = fitCacheKey(params);
1702
1737
  const cachedId = fitCache.get(fKey);
1703
- if (cachedId && modelCache.has(cachedId)) { // the model may have been evicted
1704
- const model_id = cachedId;
1705
- const entry = cacheGet(modelCache, model_id);
1738
+ const cached = cachedId && modelGet(cachedId); // the model may have been evicted or expired
1739
+ if (cached) {
1740
+ const model_id = cachedId, entry = cached;
1706
1741
  return {
1707
1742
  model_id,
1708
1743
  ...entry.result,
@@ -1722,7 +1757,7 @@ async function toolFit(ena, params) {
1722
1757
  codes, units: unitCols, conversations: convoCols,
1723
1758
  window: windowValue(window_size), binary, ...accumOpts(params),
1724
1759
  });
1725
- cacheSet(accumCache, aKey, { rows, acc }, MAX_ACCUM);
1760
+ if (KEEP_ROWS) cacheSet(accumCache, aKey, { rows, acc }, MAX_ACCUM);
1726
1761
  }
1727
1762
 
1728
1763
  const { unitLabels, nConnections, connectionNames, nUnits } = acc;
@@ -1822,7 +1857,7 @@ async function toolFit(ena, params) {
1822
1857
  result,
1823
1858
  };
1824
1859
  entry.view = defaultView(params, entry.web);
1825
- cacheSet(modelCache, model_id, entry, MAX_MODELS);
1860
+ modelSet(model_id, entry);
1826
1861
  cacheSet(fitCache, fKey, model_id, MAX_MODELS);
1827
1862
 
1828
1863
  return { ...result, _plot: web.buildPlot(entry, entry.view) };
@@ -1943,9 +1978,9 @@ async function toolCompareGroups(ena, params) {
1943
1978
  // Return cached model if params are identical
1944
1979
  const fKey = fitCacheKey(params, 'compare');
1945
1980
  const cachedId = fitCache.get(fKey);
1946
- if (cachedId && modelCache.has(cachedId)) { // the model may have been evicted
1947
- const model_id = cachedId;
1948
- const entry = cacheGet(modelCache, model_id);
1981
+ const cached = cachedId && modelGet(cachedId); // the model may have been evicted or expired
1982
+ if (cached) {
1983
+ const model_id = cachedId, entry = cached;
1949
1984
  return {
1950
1985
  model_id,
1951
1986
  ...entry.result,
@@ -1969,7 +2004,7 @@ async function toolCompareGroups(ena, params) {
1969
2004
  codes, units: unitCols, conversations: convoCols,
1970
2005
  window: windowValue(window_size), binary, ...accumOpts(params),
1971
2006
  });
1972
- cacheSet(accumCache, aKey, { rows, acc }, MAX_ACCUM);
2007
+ if (KEEP_ROWS) cacheSet(accumCache, aKey, { rows, acc }, MAX_ACCUM);
1973
2008
  }
1974
2009
 
1975
2010
  const { unitLabels, connectionNames, nUnits, nConnections } = acc;
@@ -2101,7 +2136,7 @@ async function toolCompareGroups(ena, params) {
2101
2136
  result,
2102
2137
  };
2103
2138
  entry.view = defaultView(params, entry.web);
2104
- cacheSet(modelCache, model_id, entry, MAX_MODELS);
2139
+ modelSet(model_id, entry);
2105
2140
  cacheSet(fitCache, fKey, model_id, MAX_MODELS);
2106
2141
 
2107
2142
  return { ...result, _plot: web.buildPlot(entry, entry.view) };
@@ -2180,7 +2215,7 @@ async function toolPlot({ model_id, type = 'all', group_value, group1, group2, u
2180
2215
  x_dim = 1, y_dim = 2, flip_x = false, flip_y = false,
2181
2216
  min_edge_weight, hide_unconnected_codes, text_size, colors,
2182
2217
  confidence_intervals, outlier_intervals }) {
2183
- const entry = cacheGet(modelCache, model_id);
2218
+ const entry = modelGet(model_id);
2184
2219
  if (!entry)
2185
2220
  throw new Error(`Unknown model_id '${model_id}'. Call ena_fit, ena_compare_groups or tma_fit first ` +
2186
2221
  `(the ${MAX_MODELS} most recently used models are kept; refitting is cheap).`);
@@ -2354,7 +2389,7 @@ function csvCell(v) {
2354
2389
  const csvText = (header, rows) => [header, ...rows].map(r => r.map(csvCell).join(',')).join('\n') + '\n';
2355
2390
 
2356
2391
  async function toolTables(ena, { model_id, out_path }) {
2357
- const entry = cacheGet(modelCache, model_id);
2392
+ const entry = modelGet(model_id);
2358
2393
  if (!entry)
2359
2394
  throw new Error(`Unknown model_id '${model_id}'. Fit the model first (ena_fit, ena_compare_groups or tma_fit).`);
2360
2395
  const { model, codes, unitLabels } = entry;
@@ -2409,12 +2444,20 @@ async function toolTables(ena, { model_id, out_path }) {
2409
2444
  // then, as the web tool does, refit without the removed codes.
2410
2445
  const PRIA_MIN = 0.8, PRIA_MAX = 0.99;
2411
2446
 
2412
- async function toolSimplify(ena, { model_id, threshold = 0.95, remove_num = 3 }) {
2413
- const entry = cacheGet(modelCache, model_id);
2447
+ async function toolSimplify(ena, { model_id, threshold = 0.95, remove_num = 3, data_csv }) {
2448
+ const entry = modelGet(model_id);
2414
2449
  if (!entry)
2415
2450
  throw new Error(`Unknown model_id '${model_id}'. Fit the model first (ena_fit, ena_compare_groups or tma_fit).`);
2416
2451
  if (!entry.fitArgs) throw new Error(`Model ${model_id} can't be simplified. Refit it with this server version.`);
2417
- const p = entry.params ?? {};
2452
+ let p = entry.params ?? {};
2453
+ if (!KEEP_ROWS) {
2454
+ // The hosted server kept nothing but the model: PRIA refits, so the
2455
+ // same data must come again, and it must be the same data.
2456
+ if (!data_csv) throw new Error('Hosted server: the data is not stored. Pass data_csv again (the CSV the model was fitted on).');
2457
+ if (dataStamp({ data_csv }) !== entry._data)
2458
+ throw new Error(`data_csv is not the data model ${model_id} was fitted on. Pass the same CSV, or fit a new model.`);
2459
+ p = { ...p, data_csv };
2460
+ }
2418
2461
  const codes = entry.codes;
2419
2462
  if (codes.length < 4)
2420
2463
  throw new Error(`Model ${model_id} has ${codes.length} codes; PRIA needs more than 3 (it never leaves fewer than 3).`);
@@ -2461,7 +2504,7 @@ async function toolSimplify(ena, { model_id, threshold = 0.95, remove_num = 3 })
2461
2504
  const DEFAULT_MAX_WINDOWS = 500;
2462
2505
 
2463
2506
  async function toolLines(ena, { model_id, connection, units, group, columns, max_windows, out_path }) {
2464
- const entry = cacheGet(modelCache, model_id);
2507
+ const entry = modelGet(model_id);
2465
2508
  if (!entry)
2466
2509
  throw new Error(`Unknown model_id '${model_id}'. Fit the model first (ena_fit, ena_compare_groups or tma_fit).`);
2467
2510
  const { model, codes, unitLabels } = entry;
@@ -2596,7 +2639,7 @@ async function toolLines(ena, { model_id, connection, units, group, columns, max
2596
2639
  }
2597
2640
 
2598
2641
  async function toolExport(ena, { model_id, set_name, out_path }) {
2599
- const entry = cacheGet(modelCache, model_id);
2642
+ const entry = modelGet(model_id);
2600
2643
  if (!entry)
2601
2644
  throw new Error(`Unknown model_id '${model_id}'. Fit the model first (ena_fit, ena_compare_groups or tma_fit).`);
2602
2645
  const blockers = exportBlockers(entry);
@@ -2793,8 +2836,8 @@ const ROTATION_PROPS = {
2793
2836
 
2794
2837
  const GUIDE = (() => {
2795
2838
  let md;
2796
- try { md = readFileSync(new URL('./skill/ena-analysis/SKILL.md', import.meta.url), 'utf8'); }
2797
- catch { return null; }
2839
+ md = skillMarkdown();
2840
+ if (md == null) return null;
2798
2841
  md = md.replace(/^---\n[\s\S]*?\n---\n+/, ''); // frontmatter
2799
2842
  const sections = [];
2800
2843
  for (const part of md.split(/^(?=## )/m).slice(1)) {
@@ -3111,6 +3154,7 @@ const TOOLS = [
3111
3154
  model_id: { type: 'string', description: 'model_id from ena_fit, ena_compare_groups or tma_fit' },
3112
3155
  threshold: { type: 'number', minimum: 0.8, maximum: 0.99, description: 'How close the simplified model must stay to the full one (default 0.95; the web tool allows 0.80–0.99).' },
3113
3156
  remove_num: { type: 'integer', minimum: 1, description: 'Most codes to remove (default 3).' },
3157
+ data_csv: { type: 'string', description: 'Hosted server only: the CSV the model was fitted on, again (the server does not store it). Locally, omit.', _hostedOnly: true },
3114
3158
  },
3115
3159
  required: ['model_id'],
3116
3160
  },
@@ -3181,7 +3225,7 @@ const TOOLS = [
3181
3225
  // from every schema, and the privacy wording changes from "keep it local" to
3182
3226
  // "send it de-identified".
3183
3227
  const REMOTE_HIDDEN_TOOLS = new Set(['ena_profile', 'ena_tables', 'ena_lines', 'ena_export']);
3184
- const REMOTE_PRIVACY = 'PRIVACY (hosted server): the data is sent inline and processed in memory; it is not stored. ' +
3228
+ const REMOTE_PRIVACY = 'PRIVACY (hosted server): the data is sent inline and processed in memory; it is discarded when the call returns, and only the fitted model is kept, for a limited time. ' +
3185
3229
  'Before sending it, confirm with the user that it is de-identified. ';
3186
3230
  function remoteTools(tools) {
3187
3231
  return tools.filter(t => !REMOTE_HIDDEN_TOOLS.has(t.name)).map(t => {
@@ -3200,22 +3244,26 @@ function remoteTools(tools) {
3200
3244
  const required = (t.inputSchema.required ?? []).filter(r => r !== 'data_path');
3201
3245
  if (properties.data_csv && !description.includes(REMOTE_PRIVACY.trim()))
3202
3246
  description = `${description} ${REMOTE_PRIVACY.trim()}`;
3203
- if (properties.data_csv) properties.data_csv = {
3247
+ if (properties.data_csv && !properties.data_csv._hostedOnly) properties.data_csv = {
3204
3248
  type: 'string',
3205
3249
  description: 'The data as CSV text. The server has no access to the user\'s files; it processes this in memory and does not store it. Send only de-identified data.',
3206
3250
  };
3251
+ if (properties.data_csv?._hostedOnly) { const { _hostedOnly, ...d } = properties.data_csv; properties.data_csv = d; }
3207
3252
  return { ...t, description, inputSchema: { ...t.inputSchema, properties, ...(required.length ? { required } : {}) } };
3208
3253
  });
3209
3254
  }
3210
- const OFFERED_TOOLS = REMOTE ? remoteTools(TOOLS) : TOOLS;
3255
+ function localTools(tools) {
3256
+ return tools.map(t => {
3257
+ const properties = Object.fromEntries(Object.entries(t.inputSchema.properties ?? {}).filter(([, v]) => !v._hostedOnly));
3258
+ return { ...t, inputSchema: { ...t.inputSchema, properties } };
3259
+ });
3260
+ }
3261
+ const OFFERED_TOOLS = REMOTE ? remoteTools(TOOLS) : localTools(TOOLS);
3211
3262
 
3212
3263
  // ── Server setup ──────────────────────────────────────────────────────────────
3213
3264
 
3214
- const PKG = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
3215
- const RENA_VERSION = (() => {
3216
- try { return JSON.parse(readFileSync(_require.resolve('@qe-libs/rena-wasm/package.json'), 'utf8')).version; }
3217
- catch { return 'unknown'; }
3218
- })();
3265
+ const PKG = pkgJson();
3266
+ const RENA_VERSION = renaVersion();
3219
3267
  // Stamped on every result and plot, so results from this server can be told
3220
3268
  // apart from anything computed in the conversation.
3221
3269
  const PROVENANCE = `${PKG.name} ${PKG.version} (rena-wasm ${RENA_VERSION})`;
@@ -3249,11 +3297,12 @@ function withholdDataValues(msg, args) {
3249
3297
 
3250
3298
  // Sent to the client at connection; MCP clients (Claude, Codex, Gemini CLI,
3251
3299
  // Cursor, ...) pass it to the model, so the privacy rule reaches every one.
3252
- const SERVER_INSTRUCTIONS = REMOTE ? [
3300
+ const serverInstructions = () => REMOTE ? [
3253
3301
  'ENA / ONA / TMA analysis on a hosted server. The server does not run on the user\'s machine and cannot read their files.',
3254
3302
  '',
3255
3303
  'Data privacy — follow in every conversation:',
3256
- '- Data reaches these tools inline, as data_csv. It is processed in memory for the duration of the call and is not stored.',
3304
+ '- Data reaches these tools inline, as data_csv. It is parsed in memory for the call and discarded when the call returns: the server keeps no rows and no CSV text, in memory or on disk.',
3305
+ `- What a model_id refers to — the fitted model: points, networks, unit labels and group values, never the rows — stays in memory for ${Math.round(MODEL_TTL_MS / 60_000)} minutes after its last use, then is forgotten. ena_simplify refits, so it takes data_csv again.`,
3257
3306
  '- Before the first analysis, tell the user that the data leaves their machine and must be de-identified: no names, e-mail addresses or identifying free text. Ask them to confirm before sending it.',
3258
3307
  '- Results include unit labels (the values of the unit columns, often participant IDs) and group values. If those are identifying, tell the user before fitting so they can pseudonymize the data first.',
3259
3308
  '- The guide may mention data_path, ena_profile, ena_tables, ena_lines or ena_export; those are for the locally installed server and are not available here.',
@@ -3329,7 +3378,7 @@ const on = (schema, handler) => HANDLERS.push([schema, handler]);
3329
3378
  function buildServer() {
3330
3379
  const server = new Server(
3331
3380
  { name: 'ena', version: PKG.version },
3332
- { capabilities: { tools: {}, prompts: {} }, instructions: SERVER_INSTRUCTIONS }
3381
+ { capabilities: { tools: {}, prompts: {} }, instructions: serverInstructions() }
3333
3382
  );
3334
3383
  for (const [schema, handler] of HANDLERS) server.setRequestHandler(schema, handler);
3335
3384
  return server;
@@ -3543,6 +3592,7 @@ on(CallToolRequestSchema, async (request) => {
3543
3592
 
3544
3593
  return { content };
3545
3594
  } catch (err) {
3595
+ if (process.env.ENA_MCP_DEBUG) process.stderr.write((err.stack ?? '') + '\n');
3546
3596
  return {
3547
3597
  content: [{ type: 'text', text: `Error: ${withholdDataValues(err.message, args)}\n\n${NO_SUBSTITUTE}` }],
3548
3598
  isError: true,
@@ -3550,10 +3600,43 @@ on(CallToolRequestSchema, async (request) => {
3550
3600
  }
3551
3601
  });
3552
3602
 
3553
- // ── Start ─────────────────────────────────────────────────────────────────────
3603
+ // ── Web-standard entry (Workers and other fetch runtimes) ─────────────────────
3604
+ // Same stateless scheme as --http below, over Request/Response. A Worker
3605
+ // bundle imports this and never reaches the start block.
3606
+
3607
+ export { buildServer };
3608
+
3609
+ export async function handleWebRequest(request, { maxBody = HTTP_MAX_BODY } = {}) {
3610
+ const url = new URL(request.url);
3611
+ const json = (status, body) => Response.json(body, { status });
3612
+ if (url.pathname === '/healthz') return json(200, { ok: true, server: PKG.name, version: PKG.version });
3613
+ if (url.pathname !== HTTP_PATH) return json(404, { error: `MCP endpoint is ${HTTP_PATH}` });
3614
+ let parsedBody;
3615
+ if (request.method === 'POST') {
3616
+ const tooLarge = () => json(413, { error: `request body too large (limit ${Math.round(maxBody / 1048576)} MB)` });
3617
+ if (Number(request.headers.get('content-length')) > maxBody) return tooLarge();
3618
+ const raw = await request.text();
3619
+ if (raw.length > maxBody) return tooLarge();
3620
+ try { parsedBody = JSON.parse(raw); }
3621
+ catch { return json(400, { jsonrpc: '2.0', error: { code: -32700, message: 'Parse error' }, id: null }); }
3622
+ }
3623
+ const { WebStandardStreamableHTTPServerTransport } = await import('@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js');
3624
+ const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
3625
+ await buildServer().connect(transport);
3626
+ return transport.handleRequest(request, { parsedBody });
3627
+ }
3554
3628
 
3555
- if (!REMOTE) {
3556
- await buildServer().connect(new StdioServerTransport());
3629
+ // ── Start ─────────────────────────────────────────────────────────────────────
3630
+ // Skipped when embedded (a Worker sets globalThis.ENA_MCP_EMBEDDED before
3631
+ // importing this module and calls handleWebRequest itself).
3632
+
3633
+ if (globalThis.ENA_MCP_EMBEDDED) {
3634
+ // nothing to start
3635
+ } else if (!REMOTE) {
3636
+ buildServer().connect(new StdioServerTransport()).catch((e) => {
3637
+ process.stderr.write(`[ena-mcp] stdio: ${e.message}\n`);
3638
+ process.exit(1);
3639
+ });
3557
3640
  } else {
3558
3641
  // Streamable HTTP, stateless: every POST gets a fresh Server + transport
3559
3642
  // and is answered in full, so no session state lives in the process and
@@ -3602,4 +3685,5 @@ if (!REMOTE) {
3602
3685
  http.listen(HTTP_PORT, HTTP_HOST, () => {
3603
3686
  process.stderr.write(`[ena-mcp] ${PKG.name} ${PKG.version} serving MCP over HTTP at http://${HTTP_HOST}:${HTTP_PORT}${HTTP_PATH} (health: /healthz)\n`);
3604
3687
  });
3688
+ setInterval(expireModels, 60_000).unref(); // idle models leave on time, not at the next request
3605
3689
  }