@qe-mcp/server-ena 0.1.1 → 0.1.3

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/.gitlab-ci.yml CHANGED
@@ -60,7 +60,8 @@ verify-tag-version:
60
60
  script:
61
61
  - |
62
62
  TAG_VERSION="${CI_COMMIT_TAG#v}"
63
- PKG_VERSION=$(node -e "console.log(require('./package.json').version)")
63
+ # Parse with sed — the template's alpine image has no node
64
+ PKG_VERSION=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' package.json | head -1)
64
65
  if [ "${TAG_VERSION}" != "${PKG_VERSION}" ]; then
65
66
  echo "-----------------------------------------------------------"
66
67
  echo " Tag/package.json mismatch — release blocked"
package/README.md CHANGED
@@ -30,6 +30,8 @@ Gives Claude a set of ENA tools that run **locally on your machine**. The WASM m
30
30
 
31
31
  ## Installation
32
32
 
33
+ The package is published to the public npm registry — no registry configuration needed.
34
+
33
35
  ### Option A — npx (no install needed)
34
36
 
35
37
  Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (Mac):
@@ -39,24 +41,17 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (Mac):
39
41
  "mcpServers": {
40
42
  "ena": {
41
43
  "command": "npx",
42
- "args": ["@qe-mcp/server-ena"]
44
+ "args": ["--yes", "@qe-mcp/server-ena"]
43
45
  }
44
46
  }
45
47
  }
46
48
  ```
47
49
 
48
- Then add the registry to your `~/.npmrc` once:
49
-
50
- ```bash
51
- echo "@qe-mcp:registry=https://gitlab.com/api/v4/projects/22522458/packages/npm/" >> ~/.npmrc
52
- ```
53
-
54
50
  Restart Claude Desktop.
55
51
 
56
52
  ### Option B — global install
57
53
 
58
54
  ```bash
59
- echo "@qe-mcp:registry=https://gitlab.com/api/v4/projects/22522458/packages/npm/" >> ~/.npmrc
60
55
  npm install -g @qe-mcp/server-ena
61
56
  ```
62
57
 
@@ -91,6 +86,17 @@ npm install
91
86
  }
92
87
  ```
93
88
 
89
+ ### Dev builds
90
+
91
+ Pre-release builds are published only to the QE GitLab registry with a `dev` tag.
92
+ To use one, add the scope mapping once and pin `@dev`:
93
+
94
+ ```bash
95
+ echo "@qe-mcp:registry=https://gitlab.com/api/v4/projects/22522458/packages/npm/" >> ~/.npmrc
96
+ echo "@qe-libs:registry=https://gitlab.com/api/v4/projects/22522458/packages/npm/" >> ~/.npmrc
97
+ npx --yes @qe-mcp/server-ena@dev
98
+ ```
99
+
94
100
  ### Verify
95
101
 
96
102
  In Claude Desktop, open a new conversation and ask:
@@ -211,10 +217,15 @@ These use `ena_plot` with the cached `model_id` — no refitting.
211
217
 
212
218
  Each fitting or plotting call returns:
213
219
 
214
- - **Inline PNG** — rendered via jsdom + qeviz + resvg, displayed directly in Claude's response
215
- - **`interactive_plot`** — path to a local HTML file with the interactive qeviz visualization, openable in any browser
220
+ - **Inline PNG** — an image content block rendered directly in clients that support it (Claude Desktop, Claude web).
221
+ - **`interactive_plot`** — path to a self-contained HTML file with the interactive qeviz visualization, openable in any browser.
222
+ - **`svg_plot`** — path to the rendered SVG (vector; embeddable).
223
+ - **`png_plot`** — path to the rendered PNG (static raster).
216
224
 
217
- The HTML file persists at `/tmp/ena-viz/` until the next system restart.
225
+ The SVG/PNG/HTML file paths let clients that don't render inline image
226
+ blocks (Claude Code, Claude Science) still open or embed the real ENA
227
+ network plot. All files are written to the OS temp dir under `ena-viz/`
228
+ and persist until the next system restart.
218
229
 
219
230
  ---
220
231
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qe-mcp/server-ena",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "MCP server for Epistemic Network Analysis — WASM backend, zero Python dependency",
5
5
  "type": "module",
6
6
  "main": "server.js",
package/server.js CHANGED
@@ -232,11 +232,11 @@ document.getElementById('vis').setModelData(modelData);
232
232
  </html>`;
233
233
  }
234
234
 
235
- function writeVizFile(html, name) {
235
+ function writeVizFile(content, name, ext = 'html') {
236
236
  const dir = join(tmpdir(), 'ena-viz');
237
237
  mkdirSync(dir, { recursive: true });
238
- const path = join(dir, `${name}.html`);
239
- writeFileSync(path, html, 'utf8');
238
+ const path = join(dir, `${name}.${ext}`);
239
+ writeFileSync(path, content);
240
240
  return path;
241
241
  }
242
242
 
@@ -1057,7 +1057,7 @@ const TOOLS = [
1057
1057
  'Returns a model_id that can be passed to ena_plot for additional views (group mean, unit, subtraction, comparison) without re-fitting. ' +
1058
1058
  'Repeated calls with identical parameters return the cached model instantly. ' +
1059
1059
  'Supports rotation="svd" (default) or rotation="mean" (requires group_col, group1_value, group2_value). ' +
1060
- 'An ENA network plot is returned as an inline image in this response — do not generate your own visualization. ' +
1060
+ 'An ENA network plot is returned as an inline image, and also written to disk as SVG, PNG, and interactive HTML (paths in svg_plot / png_plot / interactive_plot) for clients that do not render inline images — do not generate your own visualization. ' +
1061
1061
  'PRIVACY: use data_path (file stays local, only model results reach Claude) rather than data_csv (raw data passes through conversation). ' +
1062
1062
  'The WASM model runs entirely on the user\'s machine regardless of which input method is used.',
1063
1063
  inputSchema: {
@@ -1092,7 +1092,7 @@ const TOOLS = [
1092
1092
  'Axis 1 passes through the mean difference between groups. ' +
1093
1093
  'Returns a model_id for use with ena_plot (group, subtraction, comparison, unit views). ' +
1094
1094
  'Repeated calls with identical parameters return the cached model instantly. ' +
1095
- 'An ENA network plot is returned as an inline image in this response — do not generate your own visualization. ' +
1095
+ 'An ENA network plot is returned as an inline image, and also written to disk as SVG, PNG, and interactive HTML (paths in svg_plot / png_plot / interactive_plot) for clients that do not render inline images — do not generate your own visualization. ' +
1096
1096
  'PRIVACY: use data_path (file stays local, only model results reach Claude) rather than data_csv (raw data passes through conversation). ' +
1097
1097
  'The WASM model runs entirely on the user\'s machine regardless of which input method is used.',
1098
1098
  inputSchema: {
@@ -1118,7 +1118,7 @@ const TOOLS = [
1118
1118
  '"comparison" — overlay of two group mean networks (requires group1, group2); ' +
1119
1119
  '"subtraction" — difference network g1 − g2 (requires group1, group2, and group_col on the source model); ' +
1120
1120
  '"unit" — individual unit network (requires unit_label). ' +
1121
- 'An ENA network plot is returned as an inline image.',
1121
+ 'An ENA network plot is returned as an inline image, and also written to disk as SVG, PNG, and interactive HTML (paths in svg_plot / png_plot / interactive_plot) for clients that do not render inline images.',
1122
1122
  inputSchema: {
1123
1123
  type: 'object',
1124
1124
  properties: {
@@ -1145,9 +1145,69 @@ const server = new Server(
1145
1145
  { capabilities: { tools: {} } }
1146
1146
  );
1147
1147
 
1148
+ // ── rena-wasm API adapter ─────────────────────────────────────────────────────
1149
+ // rena-wasm ≥ the R-mirroring rewrite returns an R-style ENA set object
1150
+ // (lineWeights / points / rotation.nodes / model.unitLabels) instead of the
1151
+ // original flat object (networks / centroids / positions / unitLabels).
1152
+ // These shims expose the flat field names this server is written against, and
1153
+ // are idempotent no-ops on the older flat API.
1154
+
1155
+ function adaptModel(m) {
1156
+ if (!m || m.networks !== undefined || m.lineWeights === undefined) return m;
1157
+ return {
1158
+ networks: m.lineWeights, // sphere-normed adjacency (nUnits × nConn)
1159
+ centroids: m.points, // projected unit positions (nUnits × dims)
1160
+ positions: m.rotation?.nodes, // code node positions (nCodes × dims)
1161
+ connectionNames: m.connectionNames,
1162
+ unitLabels: m.model?.unitLabels ?? m.unitLabels,
1163
+ columnNames: m.rotation?.columnNames,
1164
+ eigenvalues: m.rotation?.eigenvalues,
1165
+ nUnits: m.nUnits,
1166
+ nConnections: m.nConnections,
1167
+ dims: m.dims,
1168
+ };
1169
+ }
1170
+
1171
+ function adaptAccum(a) {
1172
+ if (!a || a.networks !== undefined || a.connectionCounts === undefined) return a;
1173
+ return { ...a, networks: a.connectionCounts };
1174
+ }
1175
+
1176
+ // A WASM abort (raised by libqe on a degenerate SVD / means rotation) leaves the
1177
+ // module instance unusable — every later call would also abort. Detect it,
1178
+ // discard the cached instance so the next call reloads a fresh one, and surface
1179
+ // an actionable message instead of the raw "Aborted(undefined)".
1180
+ function isWasmAbort(e) {
1181
+ return /abort|RuntimeError|out of bounds|memory access|table index/i.test(e?.message ?? '');
1182
+ }
1183
+
1184
+ const DEGENERATE_FIT_MSG =
1185
+ 'ENA rotation could not be computed — the data is too sparse or degenerate. ' +
1186
+ 'Common causes: fewer than 2 units have any code co-occurrences within the ' +
1187
+ 'stanza window; only one code column; or all units have identical networks. ' +
1188
+ 'Use ena_accumulate to inspect the raw co-occurrence networks, and check that ' +
1189
+ 'you have 2+ code columns that actually co-occur and 2+ units with non-empty networks.';
1190
+
1148
1191
  let enaPromise = null;
1149
1192
  function getENA() {
1150
- if (!enaPromise) enaPromise = loadENA();
1193
+ if (!enaPromise) enaPromise = loadENA().then(ena => {
1194
+ const guard = (fn, adapt) => (rows, opts) => {
1195
+ try {
1196
+ return adapt(fn.call(ena, rows, opts));
1197
+ } catch (e) {
1198
+ if (isWasmAbort(e)) {
1199
+ enaPromise = null; // force a fresh WASM instance next call
1200
+ throw new Error(DEGENERATE_FIT_MSG);
1201
+ }
1202
+ throw e;
1203
+ }
1204
+ };
1205
+ return {
1206
+ ...ena,
1207
+ fit: guard(ena.fit, adaptModel),
1208
+ accumulate: guard(ena.accumulate, adaptAccum),
1209
+ };
1210
+ });
1151
1211
  return enaPromise;
1152
1212
  }
1153
1213
 
@@ -1177,16 +1237,27 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1177
1237
  const content = [{ type: 'text', text: JSON.stringify(cleanResult, null, 2) }];
1178
1238
 
1179
1239
  if (_vizData) {
1240
+ const slug = [cleanResult.model_id, cleanResult.plot_type].filter(Boolean).join('_') || name;
1241
+ const artifacts = {}; // fields to merge into the text block
1180
1242
  let renderError = null;
1243
+ let svg = null;
1244
+
1181
1245
  try {
1182
- const svg = await renderSVG(_vizData, _vizOpts ?? {});
1246
+ svg = await renderSVG(_vizData, _vizOpts ?? {});
1183
1247
  if (svg) {
1184
1248
  const png = new Resvg(svg, { fitTo: { mode: 'width', value: SVG_SIZE }, background: '#ffffff' }).render().asPng();
1249
+ // Inline image block — rendered by clients that support it (Claude Desktop / web).
1185
1250
  content.unshift({
1186
1251
  type: 'image',
1187
1252
  data: png.toString('base64'),
1188
1253
  mimeType: 'image/png',
1189
1254
  });
1255
+ // Also write SVG + PNG files for clients that don't render inline images
1256
+ // (Claude Code, Claude Science) so the real plot can be opened/embedded.
1257
+ try { artifacts.svg_plot = writeVizFile(svg, slug, 'svg'); }
1258
+ catch (e) { process.stderr.write(`[ena-mcp] SVG write failed: ${e.message}\n`); }
1259
+ try { artifacts.png_plot = writeVizFile(png, slug, 'png'); }
1260
+ catch (e) { process.stderr.write(`[ena-mcp] PNG write failed: ${e.message}\n`); }
1190
1261
  } else {
1191
1262
  renderError = 'renderSVG returned null (qe-graph svg_element not found — jsdom/qeviz may have failed silently)';
1192
1263
  }
@@ -1195,28 +1266,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1195
1266
  }
1196
1267
  if (renderError) {
1197
1268
  process.stderr.write(`[ena-mcp] render failed: ${renderError}\n`);
1198
- // Surface the failure in the text block so it's visible in chat
1199
- const parsed = JSON.parse(content[0].text);
1200
- parsed._render_error = renderError;
1201
- content[0].text = JSON.stringify(parsed, null, 2);
1269
+ artifacts._render_error = renderError;
1202
1270
  }
1203
1271
 
1204
- // Also write an HTML file for clients that don't render inline images (e.g. Claude Code).
1205
- // This is additive — the image block above still works for clients that do (e.g. Claude Chat).
1272
+ // Self-contained interactive HTML for clients that don't render inline images.
1206
1273
  try {
1207
1274
  const vizLabel = cleanResult.label ?? name;
1208
- const slug = [cleanResult.model_id, cleanResult.plot_type].filter(Boolean).join('_') || name;
1209
1275
  const html = generateHTML(_vizData, vizLabel, _vizOpts ?? {});
1210
- const htmlPath = writeVizFile(html, slug);
1211
- const textBlock = content.find(c => c.type === 'text');
1212
- if (textBlock) {
1213
- const parsed = JSON.parse(textBlock.text);
1214
- parsed.interactive_plot = htmlPath;
1215
- textBlock.text = JSON.stringify(parsed, null, 2);
1216
- }
1276
+ artifacts.interactive_plot = writeVizFile(html, slug, 'html');
1217
1277
  } catch (e) {
1218
1278
  process.stderr.write(`[ena-mcp] HTML write failed: ${e.message}\n`);
1219
1279
  }
1280
+
1281
+ // Merge all artifact paths into the text block in one pass.
1282
+ const textBlock = content.find(c => c.type === 'text');
1283
+ if (textBlock && Object.keys(artifacts).length) {
1284
+ const parsed = JSON.parse(textBlock.text);
1285
+ Object.assign(parsed, artifacts);
1286
+ textBlock.text = JSON.stringify(parsed, null, 2);
1287
+ }
1220
1288
  }
1221
1289
 
1222
1290
  return { content };