@qe-mcp/server-ena 0.1.2 → 0.1.4

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.
Files changed (3) hide show
  1. package/README.md +19 -4
  2. package/package.json +4 -2
  3. package/server.js +60 -28
package/README.md CHANGED
@@ -217,10 +217,25 @@ These use `ena_plot` with the cached `model_id` — no refitting.
217
217
 
218
218
  Each fitting or plotting call returns:
219
219
 
220
- - **Inline PNG** rendered via jsdom + qeviz + resvg, displayed directly in Claude's response
221
- - **`interactive_plot`** — path to a local HTML file with the interactive qeviz visualization, openable in any browser
222
-
223
- The HTML file persists at `/tmp/ena-viz/` until the next system restart.
220
+ - **`svg_plot`**path to the rendered SVG (vector; embeddable). Always produced.
221
+ - **`interactive_plot`** — path to a self-contained HTML file with the interactive qeviz visualization, openable in any browser. Always produced.
222
+ - **`png_plot`** — path to the rendered PNG (static raster). Only when the optional PNG rasterizer is installed.
223
+ - **Inline PNG** an image content block rendered directly in clients that support it (Claude Desktop, Claude web). Only when the optional PNG rasterizer is installed.
224
+
225
+ The file-path outputs let clients that don't render inline image blocks
226
+ (Claude Code, Claude Science) still open or embed the real ENA network
227
+ plot. All files are written to the OS temp dir under `ena-viz/` and
228
+ persist until the next system restart.
229
+
230
+ ### Optional PNG rendering
231
+
232
+ PNG output (`png_plot` and the inline image block) is powered by
233
+ [`@resvg/resvg-js`](https://www.npmjs.com/package/@resvg/resvg-js), a
234
+ native module listed under `optionalDependencies`. It is installed
235
+ automatically by `npm install` on supported platforms. When it is absent
236
+ — e.g. a cross-platform bundle built without optional dependencies — the
237
+ server logs a notice and produces `svg_plot` + `interactive_plot` only;
238
+ no error, no crash.
224
239
 
225
240
  ---
226
241
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qe-mcp/server-ena",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "MCP server for Epistemic Network Analysis — WASM backend, zero Python dependency",
5
5
  "type": "module",
6
6
  "main": "server.js",
@@ -14,9 +14,11 @@
14
14
  "@modelcontextprotocol/sdk": "^1.4.0",
15
15
  "@qe-libs/qeviz": "^0.1.0",
16
16
  "@qe-libs/rena-wasm": "^0.1.0",
17
- "@resvg/resvg-js": "^2.6.2",
18
17
  "jsdom": "^25.0.1"
19
18
  },
19
+ "optionalDependencies": {
20
+ "@resvg/resvg-js": "^2.6.2"
21
+ },
20
22
  "keywords": [
21
23
  "mcp",
22
24
  "ena",
package/server.js CHANGED
@@ -232,18 +232,38 @@ 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
 
243
243
  // ── jsdom SVG renderer ────────────────────────────────────────────────────────
244
244
 
245
245
  import { JSDOM } from 'jsdom';
246
- import { Resvg } from '@resvg/resvg-js';
246
+
247
+ // @resvg/resvg-js is a native (platform-specific) addon and is an OPTIONAL
248
+ // dependency: it powers the inline PNG image block and the png_plot file. When
249
+ // it is absent — e.g. a cross-platform .mcpb bundle built without optional deps,
250
+ // or a platform with no prebuilt binary — the server still produces the SVG and
251
+ // interactive HTML outputs. Loaded lazily so its absence never breaks startup.
252
+ let _Resvg; // undefined = not yet attempted; null = unavailable
253
+ async function getResvg() {
254
+ if (_Resvg === undefined) {
255
+ try {
256
+ ({ Resvg: _Resvg } = await import('@resvg/resvg-js'));
257
+ } catch {
258
+ _Resvg = null;
259
+ process.stderr.write(
260
+ '[ena-mcp] @resvg/resvg-js not available — inline PNG and png_plot ' +
261
+ 'disabled; svg_plot and interactive_plot are still produced.\n'
262
+ );
263
+ }
264
+ }
265
+ return _Resvg;
266
+ }
247
267
 
248
268
  const SVG_SIZE = 600;
249
269
 
@@ -1057,7 +1077,7 @@ const TOOLS = [
1057
1077
  'Returns a model_id that can be passed to ena_plot for additional views (group mean, unit, subtraction, comparison) without re-fitting. ' +
1058
1078
  'Repeated calls with identical parameters return the cached model instantly. ' +
1059
1079
  '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. ' +
1080
+ 'The ENA network plot is written to disk as SVG (svg_plot) and interactive HTML (interactive_plot), and — when the optional PNG rasterizer is installed — also as PNG (png_plot) plus an inline image block. Use these paths to show the plot; do not generate your own visualization. ' +
1061
1081
  'PRIVACY: use data_path (file stays local, only model results reach Claude) rather than data_csv (raw data passes through conversation). ' +
1062
1082
  'The WASM model runs entirely on the user\'s machine regardless of which input method is used.',
1063
1083
  inputSchema: {
@@ -1092,7 +1112,7 @@ const TOOLS = [
1092
1112
  'Axis 1 passes through the mean difference between groups. ' +
1093
1113
  'Returns a model_id for use with ena_plot (group, subtraction, comparison, unit views). ' +
1094
1114
  '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. ' +
1115
+ 'The ENA network plot is written to disk as SVG (svg_plot) and interactive HTML (interactive_plot), and — when the optional PNG rasterizer is installed — also as PNG (png_plot) plus an inline image block. Use these paths to show the plot; do not generate your own visualization. ' +
1096
1116
  'PRIVACY: use data_path (file stays local, only model results reach Claude) rather than data_csv (raw data passes through conversation). ' +
1097
1117
  'The WASM model runs entirely on the user\'s machine regardless of which input method is used.',
1098
1118
  inputSchema: {
@@ -1118,7 +1138,7 @@ const TOOLS = [
1118
1138
  '"comparison" — overlay of two group mean networks (requires group1, group2); ' +
1119
1139
  '"subtraction" — difference network g1 − g2 (requires group1, group2, and group_col on the source model); ' +
1120
1140
  '"unit" — individual unit network (requires unit_label). ' +
1121
- 'An ENA network plot is returned as an inline image.',
1141
+ 'The ENA network plot is written to disk as SVG (svg_plot) and interactive HTML (interactive_plot), plus PNG (png_plot) and an inline image when the optional PNG rasterizer is installed.',
1122
1142
  inputSchema: {
1123
1143
  type: 'object',
1124
1144
  properties: {
@@ -1237,16 +1257,31 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1237
1257
  const content = [{ type: 'text', text: JSON.stringify(cleanResult, null, 2) }];
1238
1258
 
1239
1259
  if (_vizData) {
1260
+ const slug = [cleanResult.model_id, cleanResult.plot_type].filter(Boolean).join('_') || name;
1261
+ const artifacts = {}; // fields to merge into the text block
1240
1262
  let renderError = null;
1263
+ let svg = null;
1264
+
1241
1265
  try {
1242
- const svg = await renderSVG(_vizData, _vizOpts ?? {});
1266
+ svg = await renderSVG(_vizData, _vizOpts ?? {});
1243
1267
  if (svg) {
1244
- const png = new Resvg(svg, { fitTo: { mode: 'width', value: SVG_SIZE }, background: '#ffffff' }).render().asPng();
1245
- content.unshift({
1246
- type: 'image',
1247
- data: png.toString('base64'),
1248
- mimeType: 'image/png',
1249
- });
1268
+ // SVG file always available (pure JS/WASM), vector, embeddable.
1269
+ try { artifacts.svg_plot = writeVizFile(svg, slug, 'svg'); }
1270
+ catch (e) { process.stderr.write(`[ena-mcp] SVG write failed: ${e.message}\n`); }
1271
+
1272
+ // PNG (inline image block + png_plot file) only when the optional
1273
+ // native rasterizer is present. Absent in cross-platform bundles.
1274
+ const Resvg = await getResvg();
1275
+ if (Resvg) {
1276
+ const png = new Resvg(svg, { fitTo: { mode: 'width', value: SVG_SIZE }, background: '#ffffff' }).render().asPng();
1277
+ content.unshift({
1278
+ type: 'image',
1279
+ data: png.toString('base64'),
1280
+ mimeType: 'image/png',
1281
+ });
1282
+ try { artifacts.png_plot = writeVizFile(png, slug, 'png'); }
1283
+ catch (e) { process.stderr.write(`[ena-mcp] PNG write failed: ${e.message}\n`); }
1284
+ }
1250
1285
  } else {
1251
1286
  renderError = 'renderSVG returned null (qe-graph svg_element not found — jsdom/qeviz may have failed silently)';
1252
1287
  }
@@ -1255,28 +1290,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1255
1290
  }
1256
1291
  if (renderError) {
1257
1292
  process.stderr.write(`[ena-mcp] render failed: ${renderError}\n`);
1258
- // Surface the failure in the text block so it's visible in chat
1259
- const parsed = JSON.parse(content[0].text);
1260
- parsed._render_error = renderError;
1261
- content[0].text = JSON.stringify(parsed, null, 2);
1293
+ artifacts._render_error = renderError;
1262
1294
  }
1263
1295
 
1264
- // Also write an HTML file for clients that don't render inline images (e.g. Claude Code).
1265
- // This is additive — the image block above still works for clients that do (e.g. Claude Chat).
1296
+ // Self-contained interactive HTML for clients that don't render inline images.
1266
1297
  try {
1267
1298
  const vizLabel = cleanResult.label ?? name;
1268
- const slug = [cleanResult.model_id, cleanResult.plot_type].filter(Boolean).join('_') || name;
1269
1299
  const html = generateHTML(_vizData, vizLabel, _vizOpts ?? {});
1270
- const htmlPath = writeVizFile(html, slug);
1271
- const textBlock = content.find(c => c.type === 'text');
1272
- if (textBlock) {
1273
- const parsed = JSON.parse(textBlock.text);
1274
- parsed.interactive_plot = htmlPath;
1275
- textBlock.text = JSON.stringify(parsed, null, 2);
1276
- }
1300
+ artifacts.interactive_plot = writeVizFile(html, slug, 'html');
1277
1301
  } catch (e) {
1278
1302
  process.stderr.write(`[ena-mcp] HTML write failed: ${e.message}\n`);
1279
1303
  }
1304
+
1305
+ // Merge all artifact paths into the text block in one pass.
1306
+ const textBlock = content.find(c => c.type === 'text');
1307
+ if (textBlock && Object.keys(artifacts).length) {
1308
+ const parsed = JSON.parse(textBlock.text);
1309
+ Object.assign(parsed, artifacts);
1310
+ textBlock.text = JSON.stringify(parsed, null, 2);
1311
+ }
1280
1312
  }
1281
1313
 
1282
1314
  return { content };