@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.
- package/README.md +9 -4
- package/package.json +1 -1
- 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** —
|
|
221
|
-
- **`interactive_plot`** — path to a
|
|
222
|
-
|
|
223
|
-
|
|
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
package/server.js
CHANGED
|
@@ -232,11 +232,11 @@ document.getElementById('vis').setModelData(modelData);
|
|
|
232
232
|
</html>`;
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
-
function writeVizFile(
|
|
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}
|
|
239
|
-
writeFileSync(path,
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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 };
|