@qe-mcp/server-ena 0.1.2 → 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.
Files changed (3) hide show
  1. package/README.md +9 -4
  2. package/package.json +1 -1
  3. package/server.js +29 -21
package/README.md CHANGED
@@ -217,10 +217,15 @@ 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
+ - **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).
224
+
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.
224
229
 
225
230
  ---
226
231
 
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.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: {
@@ -1237,16 +1237,27 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1237
1237
  const content = [{ type: 'text', text: JSON.stringify(cleanResult, null, 2) }];
1238
1238
 
1239
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
1240
1242
  let renderError = null;
1243
+ let svg = null;
1244
+
1241
1245
  try {
1242
- const svg = await renderSVG(_vizData, _vizOpts ?? {});
1246
+ svg = await renderSVG(_vizData, _vizOpts ?? {});
1243
1247
  if (svg) {
1244
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).
1245
1250
  content.unshift({
1246
1251
  type: 'image',
1247
1252
  data: png.toString('base64'),
1248
1253
  mimeType: 'image/png',
1249
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`); }
1250
1261
  } else {
1251
1262
  renderError = 'renderSVG returned null (qe-graph svg_element not found — jsdom/qeviz may have failed silently)';
1252
1263
  }
@@ -1255,28 +1266,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1255
1266
  }
1256
1267
  if (renderError) {
1257
1268
  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);
1269
+ artifacts._render_error = renderError;
1262
1270
  }
1263
1271
 
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).
1272
+ // Self-contained interactive HTML for clients that don't render inline images.
1266
1273
  try {
1267
1274
  const vizLabel = cleanResult.label ?? name;
1268
- const slug = [cleanResult.model_id, cleanResult.plot_type].filter(Boolean).join('_') || name;
1269
1275
  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
- }
1276
+ artifacts.interactive_plot = writeVizFile(html, slug, 'html');
1277
1277
  } catch (e) {
1278
1278
  process.stderr.write(`[ena-mcp] HTML write failed: ${e.message}\n`);
1279
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
+ }
1280
1288
  }
1281
1289
 
1282
1290
  return { content };