@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 +2 -1
- package/README.md +22 -11
- package/package.json +1 -1
- package/server.js +90 -22
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
|
-
|
|
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** —
|
|
215
|
-
- **`interactive_plot`** — path to a
|
|
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
|
|
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
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: {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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 };
|