@qe-mcp/ena 0.1.17 → 0.1.18
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 +37 -1
- package/manifest.json +1 -1
- package/package.json +3 -4
- package/server.js +210 -54
- package/xlsx.js +56 -5
package/README.md
CHANGED
|
@@ -157,6 +157,42 @@ manual install under [Get started](#get-started). Don't edit
|
|
|
157
157
|
be pasted. A plain-text copy of this page is at
|
|
158
158
|
[llms.txt](https://qe-libs.org/mcp/qe-mcp-ena/llms.txt).
|
|
159
159
|
|
|
160
|
+
### Hosting it (remote MCP)
|
|
161
|
+
|
|
162
|
+
Everything above runs the server on the user's own machine, which is what
|
|
163
|
+
keeps their data there. For web clients that can only reach remote MCP
|
|
164
|
+
endpoints (Claude.ai custom connectors, ChatGPT connectors, Gemini custom
|
|
165
|
+
apps), the same package can serve MCP over streamable HTTP instead:
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
npx --yes @qe-mcp/ena --http --port 8000 --host 0.0.0.0
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
The endpoint is `/mcp`; `GET /healthz` answers for probes. `--port` and
|
|
172
|
+
`--host` fall back to `$PORT` and `$HOST`; `--max-body-mb` caps request size
|
|
173
|
+
(default 25).
|
|
174
|
+
|
|
175
|
+
A hosted server has no access to anyone's files, so this mode is a different
|
|
176
|
+
privacy promise, and it says so to the model. In `--http` mode:
|
|
177
|
+
|
|
178
|
+
- Data arrives inline as `data_csv`; `data_path` is refused. The server
|
|
179
|
+
processes it in memory and stores nothing.
|
|
180
|
+
- `ena_profile`, `ena_tables`, `ena_lines` and `ena_export` are not offered
|
|
181
|
+
(they read or write files), and the prompts are not listed (they take a
|
|
182
|
+
path). Plots come back as the inline PNG plus the SVG as text; no files are
|
|
183
|
+
written.
|
|
184
|
+
- The server's instructions tell the model to say that the data leaves the
|
|
185
|
+
user's machine and must be de-identified, and to get their OK first.
|
|
186
|
+
- `model_id`s carry a random suffix so one conversation cannot plot another's
|
|
187
|
+
model.
|
|
188
|
+
|
|
189
|
+
Each request is answered statelessly, so any replica can take any request,
|
|
190
|
+
but fitted models are cached in the process that fitted them: run one
|
|
191
|
+
replica, or use sticky sessions, for `model_id`s to stay valid across calls.
|
|
192
|
+
Put TLS and whatever authentication or rate limiting you need in front of it;
|
|
193
|
+
the server itself does neither. `node test-http.mjs` starts it on a free port
|
|
194
|
+
and checks all of the above.
|
|
195
|
+
|
|
160
196
|
## Prompts
|
|
161
197
|
|
|
162
198
|
Describe what you want; the assistant picks the tools. These use the columns
|
|
@@ -299,7 +335,7 @@ rule such as `Read(./data/**)` in `.claude/settings.json`, plus the hook below.
|
|
|
299
335
|
**Results come only from the server.** Every number and plot should come from
|
|
300
336
|
this server, never from code the assistant writes; the server and every tool
|
|
301
337
|
error say so. Each result carries `provenance` (e.g.
|
|
302
|
-
`@qe-mcp/ena 0.1.
|
|
338
|
+
`@qe-mcp/ena 0.1.18 (rena-wasm 0.1.14)`), the interactive plot names it
|
|
303
339
|
in its footer and the SVG in its `<desc>`. A plot or table without it didn't
|
|
304
340
|
come from this server.
|
|
305
341
|
|
package/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"manifest_version": "0.2",
|
|
3
3
|
"name": "qe-mcp-ena",
|
|
4
4
|
"display_name": "ENA — Epistemic Network Analysis",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.18",
|
|
6
6
|
"description": "Epistemic Network Analysis on tabular coded data. Runs entirely on your machine — data never leaves your computer.",
|
|
7
7
|
"long_description": "Run Epistemic Network Analysis (ENA), ordered network analysis (ONA) and transmodal analysis (TMA) on your coded data from a conversation with Claude. No R or Python to install.\n\n**Your data stays on your machine**\n\n- The analysis runs locally. Only results — networks, points, statistics and plots — go back to the conversation.\n- Give Claude a file path rather than pasting data; the server reads the file itself.\n- It makes no network connections, starts no other programs, and writes files only when you ask for an export.\n\n**About the install warning**\n\nClaude Desktop shows \"access to everything on your computer\" for every extension outside Anthropic's reviewed directory. It means you are trusting the source, as with any program you install. The source is published on npm as @qe-mcp/ena.\n\nDocumentation and example prompts: https://qe-libs.org/mcp/qe-mcp-ena/",
|
|
8
8
|
"author": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qe-mcp/ena",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.18",
|
|
4
4
|
"description": "Epistemic Network Analysis for Claude and other assistants — runs on your machine",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -26,10 +26,9 @@
|
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"@modelcontextprotocol/sdk": "^1.4.0",
|
|
29
|
-
"@qe-libs/qeviz": "0.
|
|
29
|
+
"@qe-libs/qeviz": "0.7.0",
|
|
30
30
|
"@qe-libs/rena-wasm": "^0.1.14",
|
|
31
|
-
"@resvg/resvg-wasm": "^2.6.2"
|
|
32
|
-
"jsdom": "^25.0.1"
|
|
31
|
+
"@resvg/resvg-wasm": "^2.6.2"
|
|
33
32
|
},
|
|
34
33
|
"optionalDependencies": {
|
|
35
34
|
"@resvg/resvg-js": "^2.6.2"
|
package/server.js
CHANGED
|
@@ -29,6 +29,39 @@ import { connectionWindows, viewerHtml } from './lines.js';
|
|
|
29
29
|
import { defaultTensor } from '@qe-libs/rena-wasm/src/tensor.js';
|
|
30
30
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
31
31
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
32
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
33
|
+
import { createServer as createHttpServer } from 'http';
|
|
34
|
+
import { randomBytes } from 'crypto';
|
|
35
|
+
|
|
36
|
+
// ── Mode ──────────────────────────────────────────────────────────────────────
|
|
37
|
+
// Default: stdio, launched by the client on the user's machine (the bundle,
|
|
38
|
+
// npx, claude mcp add). `--http` serves MCP over streamable HTTP instead, for
|
|
39
|
+
// hosting the server where web clients (Claude.ai, ChatGPT, Gemini) can reach
|
|
40
|
+
// it. In that mode there is no user disk: data arrives inline, nothing is
|
|
41
|
+
// written to files, and the file-based tools are not offered.
|
|
42
|
+
const ARGV = process.argv.slice(2);
|
|
43
|
+
const argOf = (flag, dflt) => { const i = ARGV.indexOf(flag); return i >= 0 && ARGV[i + 1] != null ? ARGV[i + 1] : dflt; };
|
|
44
|
+
const REMOTE = ARGV.includes('--http');
|
|
45
|
+
const HTTP_PORT = Number(argOf('--port', process.env.PORT ?? 8000));
|
|
46
|
+
const HTTP_HOST = argOf('--host', process.env.HOST ?? '127.0.0.1');
|
|
47
|
+
const HTTP_PATH = '/mcp';
|
|
48
|
+
// Largest request body accepted in HTTP mode (the CSV travels inside it).
|
|
49
|
+
const HTTP_MAX_BODY = Math.round(Number(argOf('--max-body-mb', process.env.ENA_MCP_MAX_BODY_MB ?? 25)) * 1024 * 1024);
|
|
50
|
+
if (ARGV.includes('--help') || ARGV.includes('-h')) {
|
|
51
|
+
process.stdout.write([
|
|
52
|
+
'Usage: qe-mcp-ena [--http [--port N] [--host H] [--max-body-mb N]]',
|
|
53
|
+
'',
|
|
54
|
+
' (no flags) MCP over stdio — what Claude Desktop, Claude Code and other local clients run.',
|
|
55
|
+
' --http MCP over streamable HTTP at /mcp, for hosting. Data must be passed inline',
|
|
56
|
+
' (data_csv); nothing is written to disk; ena_profile, ena_tables, ena_lines and',
|
|
57
|
+
' ena_export are not offered. Health check: GET /healthz.',
|
|
58
|
+
' --port N Port for --http (default: $PORT or 8000).',
|
|
59
|
+
' --host H Interface for --http (default: $HOST or 127.0.0.1; use 0.0.0.0 in a container).',
|
|
60
|
+
' --max-body-mb N Largest request body in MB (default: $ENA_MCP_MAX_BODY_MB or 25).',
|
|
61
|
+
'',
|
|
62
|
+
].join('\n'));
|
|
63
|
+
process.exit(0);
|
|
64
|
+
}
|
|
32
65
|
import {
|
|
33
66
|
CallToolRequestSchema,
|
|
34
67
|
ListToolsRequestSchema,
|
|
@@ -37,13 +70,15 @@ import {
|
|
|
37
70
|
} from '@modelcontextprotocol/sdk/types.js';
|
|
38
71
|
import loadENA, { detectParams, isBinary } from '@qe-libs/rena-wasm';
|
|
39
72
|
|
|
40
|
-
//
|
|
73
|
+
// qeviz: renderSVG() draws the static plot here, with no DOM; the UMD bundle
|
|
74
|
+
// (self-contained, no external deps) is inlined into the interactive page.
|
|
75
|
+
import { renderSVG as qevizRenderSVG } from '@qe-libs/qeviz';
|
|
41
76
|
import { createRequire } from 'module';
|
|
42
77
|
const _require = createRequire(import.meta.url);
|
|
43
78
|
const QEVIZ_BUNDLE = _require.resolve('@qe-libs/qeviz');
|
|
44
79
|
let _qevizJS = null;
|
|
45
80
|
function qevizJS() {
|
|
46
|
-
// The bundle is inlined into pages
|
|
81
|
+
// The bundle is inlined into pages, so drop its source-map comment
|
|
47
82
|
// (the .map file is not next to the inlined copy).
|
|
48
83
|
if (!_qevizJS) _qevizJS = readFileSync(QEVIZ_BUNDLE, 'utf8')
|
|
49
84
|
.replace(/\n\/\/# sourceMappingURL=\S*\s*$/, '\n');
|
|
@@ -101,7 +136,12 @@ function dataFileText(dataPath) {
|
|
|
101
136
|
return readFileSync(dataPath, 'utf8');
|
|
102
137
|
}
|
|
103
138
|
|
|
139
|
+
const REMOTE_NO_PATH =
|
|
140
|
+
'data_path is not available on a hosted server: it has no access to the user\'s files. ' +
|
|
141
|
+
'Pass the data inline as data_csv, after confirming with the user that it is de-identified.';
|
|
142
|
+
|
|
104
143
|
function loadData(dataPath, dataCsv) {
|
|
144
|
+
if (dataPath && REMOTE) throw new Error(REMOTE_NO_PATH);
|
|
105
145
|
if (dataPath) return parseCSV(dataFileText(dataPath));
|
|
106
146
|
if (dataCsv) return parseCSV(dataCsv);
|
|
107
147
|
throw new Error('Provide either data_path (path to a .csv or .xlsx file) or data_csv (raw CSV string).');
|
|
@@ -444,7 +484,7 @@ function detectColumns(rows) {
|
|
|
444
484
|
// <qe-graph>), attrs (<qe-graph> attributes), edgePx, title }.
|
|
445
485
|
|
|
446
486
|
// Group values and titles come from the data and from tool arguments; escape
|
|
447
|
-
// them before they go into
|
|
487
|
+
// them before they go into the interactive page's markup.
|
|
448
488
|
const escHtml = (v) => String(v).replace(/[&<>"']/g, ch =>
|
|
449
489
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch]);
|
|
450
490
|
|
|
@@ -645,9 +685,7 @@ function vizSlug(result, args = {}) {
|
|
|
645
685
|
.join('_');
|
|
646
686
|
}
|
|
647
687
|
|
|
648
|
-
// ──
|
|
649
|
-
|
|
650
|
-
import { JSDOM } from 'jsdom';
|
|
688
|
+
// ── static SVG renderer ───────────────────────────────────────────────────────
|
|
651
689
|
|
|
652
690
|
// PNG output (the inline image block and the png_plot file) prefers the native
|
|
653
691
|
// @resvg/resvg-js, an OPTIONAL dependency: fastest, and it reads system fonts.
|
|
@@ -686,34 +724,27 @@ async function getResvg() {
|
|
|
686
724
|
|
|
687
725
|
const SVG_SIZE = 600;
|
|
688
726
|
|
|
727
|
+
// qeviz's headless renderSVG() draws the same <qe-graph> the interactive page
|
|
728
|
+
// shows (same attributes, same layer children) as a standalone SVG string,
|
|
729
|
+
// without a DOM: no browser, no timers. Anything qeviz logs while drawing goes
|
|
730
|
+
// to stderr, since stdout is the stdio transport.
|
|
689
731
|
async function renderSVG(plot) {
|
|
690
|
-
const dom = new JSDOM(
|
|
691
|
-
`<!DOCTYPE html><body><qe-visual id="vis"><qe-graph id="g" width="${SVG_SIZE}" height="${SVG_SIZE}"` +
|
|
692
|
-
`${attrString({ ...plot.attrs, 'edge-width': edgeWidth(plot.edgePx, SVG_SIZE) })}>${layerMarkup(plot.layers)}</qe-graph></qe-visual></body>`,
|
|
693
|
-
{ runScripts: 'dangerously', pretendToBeVisual: true }
|
|
694
|
-
);
|
|
695
|
-
const { window } = dom;
|
|
696
|
-
|
|
697
|
-
window.Element.prototype.getBoundingClientRect = () =>
|
|
698
|
-
({ x:0, y:0, width:SVG_SIZE, height:SVG_SIZE, top:0, left:0, right:SVG_SIZE, bottom:SVG_SIZE });
|
|
699
|
-
window.requestAnimationFrame = cb => setTimeout(cb, 0);
|
|
700
|
-
|
|
701
732
|
const toStderr = (...a) => process.stderr.write(a.map(String).join(' ') + '\n');
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
733
|
+
const saved = { log: console.log, info: console.info, warn: console.warn, debug: console.debug };
|
|
734
|
+
Object.assign(console, { log: toStderr, info: toStderr, warn: toStderr, debug: () => {} });
|
|
735
|
+
let svgHtml;
|
|
736
|
+
try {
|
|
737
|
+
svgHtml = qevizRenderSVG(plot.data, {
|
|
738
|
+
width: SVG_SIZE,
|
|
739
|
+
height: SVG_SIZE,
|
|
740
|
+
graph: { ...plot.attrs, 'edge-width': edgeWidth(plot.edgePx, SVG_SIZE) },
|
|
741
|
+
layers: plot.layers,
|
|
742
|
+
});
|
|
743
|
+
} finally {
|
|
744
|
+
Object.assign(console, saved);
|
|
745
|
+
}
|
|
746
|
+
if (!svgHtml) return null;
|
|
713
747
|
|
|
714
|
-
// XML serialization: outerHTML (HTML rules) leaves '<' unescaped inside
|
|
715
|
-
// attribute values, which resvg rejects when a label contains one.
|
|
716
|
-
let svgHtml = new window.XMLSerializer().serializeToString(svgEl);
|
|
717
748
|
// resvg requires the SVG namespace declaration to parse the document
|
|
718
749
|
if (!svgHtml.includes('xmlns=')) {
|
|
719
750
|
svgHtml = svgHtml.replace('<svg', '<svg xmlns="http://www.w3.org/2000/svg"');
|
|
@@ -981,7 +1012,11 @@ function optsLines(p, nUnits) {
|
|
|
981
1012
|
];
|
|
982
1013
|
}
|
|
983
1014
|
|
|
984
|
-
|
|
1015
|
+
// Hosted, the one process serves every client, so a model_id must not be
|
|
1016
|
+
// guessable: m1 from one conversation is not for another to plot.
|
|
1017
|
+
function nextModelId() {
|
|
1018
|
+
return REMOTE ? `m${++modelSeq}-${randomBytes(6).toString('hex')}` : `m${++modelSeq}`;
|
|
1019
|
+
}
|
|
985
1020
|
|
|
986
1021
|
// ── Web tool defaults ─────────────────────────────────────────────────────────
|
|
987
1022
|
|
|
@@ -1587,7 +1622,7 @@ function toolInspect({ data_path, data_csv }) {
|
|
|
1587
1622
|
nextStep = {
|
|
1588
1623
|
tool: 'ena_fit',
|
|
1589
1624
|
parameters: {
|
|
1590
|
-
data_path:
|
|
1625
|
+
...(REMOTE ? { data_csv: '<the same CSV text>' } : { data_path: data_path ?? '<save CSV to a path first>' }),
|
|
1591
1626
|
units: bestUnit,
|
|
1592
1627
|
conversations: bestConvo,
|
|
1593
1628
|
codes: codeCols,
|
|
@@ -1627,6 +1662,8 @@ function toolInspect({ data_path, data_csv }) {
|
|
|
1627
1662
|
|
|
1628
1663
|
const privacyNote = isPrivacySafe
|
|
1629
1664
|
? 'PRIVACY SAFE — data was read from a local file path. No raw row data was sent to Claude; only this analysis result was transmitted.'
|
|
1665
|
+
: REMOTE
|
|
1666
|
+
? 'HOSTED SERVER — the data was sent inline, processed in memory and not stored. It left the user\'s machine, so it must be de-identified; confirm that with the user if you have not.'
|
|
1630
1667
|
: 'RAW DATA TRANSMITTED — data_csv was used, so row data passed through this conversation. For sensitive datasets, ask the user to save the file and provide data_path instead.';
|
|
1631
1668
|
|
|
1632
1669
|
return {
|
|
@@ -2073,8 +2110,8 @@ async function toolCompareGroups(ena, params) {
|
|
|
2073
2110
|
// ena_plot's display options → buildPlot's view.style (the web tool's Plot
|
|
2074
2111
|
// Settings). Validated here so a bad colour or group fails with a message
|
|
2075
2112
|
// instead of a blank plot.
|
|
2076
|
-
//
|
|
2077
|
-
//
|
|
2113
|
+
// Colour names are turned into hex here so the SVG and the interactive page
|
|
2114
|
+
// agree whatever resolves them: the CSS named colours.
|
|
2078
2115
|
const CSS_NAMED = Object.fromEntries((
|
|
2079
2116
|
'aliceblue:f0f8ff antiquewhite:faebd7 aqua:00ffff aquamarine:7fffd4 azure:f0ffff beige:f5f5dc ' +
|
|
2080
2117
|
'bisque:ffe4c4 black:000000 blanchedalmond:ffebcd blue:0000ff blueviolet:8a2be2 brown:a52a2a ' +
|
|
@@ -2772,12 +2809,17 @@ const GUIDE_TOPICS = GUIDE?.sections.map(s => s.topic) ?? [];
|
|
|
2772
2809
|
function toolGuide({ topic } = {}) {
|
|
2773
2810
|
if (!GUIDE) throw new Error('The analysis guide is missing from this installation (skill/ena-analysis/SKILL.md).');
|
|
2774
2811
|
const footer = `\n\n---\nTopics: ${GUIDE_TOPICS.join(', ')}`;
|
|
2775
|
-
|
|
2812
|
+
const header = REMOTE
|
|
2813
|
+
? '> **Hosted server.** This server runs remotely, not on the user\'s machine. Where the guide says to pass ' +
|
|
2814
|
+
'data_path, pass the data inline as data_csv instead, after the user confirms it is de-identified. ' +
|
|
2815
|
+
'ena_profile, ena_tables, ena_lines and ena_export are not available here; plots come back inline.\n\n'
|
|
2816
|
+
: '';
|
|
2817
|
+
if (!topic) return header + GUIDE.text + footer;
|
|
2776
2818
|
const t = String(topic).toLowerCase().trim();
|
|
2777
2819
|
const hit = GUIDE.sections.find(s => s.topic === t)
|
|
2778
2820
|
?? GUIDE.sections.find(s => s.topic.includes(t.replace(/[^a-z0-9]+/g, '-')));
|
|
2779
2821
|
if (!hit) throw new Error(`Unknown topic. Topics: ${GUIDE_TOPICS.join(', ')}`);
|
|
2780
|
-
return hit.text + footer;
|
|
2822
|
+
return header + hit.text + footer;
|
|
2781
2823
|
}
|
|
2782
2824
|
|
|
2783
2825
|
const TOOLS = [
|
|
@@ -3135,6 +3177,38 @@ const TOOLS = [
|
|
|
3135
3177
|
},
|
|
3136
3178
|
];
|
|
3137
3179
|
|
|
3180
|
+
// Hosted: no user files to read or write, so the file tools go, data_path goes
|
|
3181
|
+
// from every schema, and the privacy wording changes from "keep it local" to
|
|
3182
|
+
// "send it de-identified".
|
|
3183
|
+
const REMOTE_HIDDEN_TOOLS = new Set(['ena_profile', 'ena_tables', 'ena_lines', 'ena_export']);
|
|
3184
|
+
const REMOTE_PRIVACY = 'PRIVACY (hosted server): the data is sent inline and processed in memory; it is not stored. ' +
|
|
3185
|
+
'Before sending it, confirm with the user that it is de-identified. ';
|
|
3186
|
+
function remoteTools(tools) {
|
|
3187
|
+
return tools.filter(t => !REMOTE_HIDDEN_TOOLS.has(t.name)).map(t => {
|
|
3188
|
+
const { data_path, ...properties } = t.inputSchema.properties ?? {};
|
|
3189
|
+
let description = t.description
|
|
3190
|
+
.replace(/The ENA network plot is written to disk[^]*?Use these paths to show the plot; /,
|
|
3191
|
+
'The ENA network plot comes back as an inline image. Use it to show the plot; ')
|
|
3192
|
+
.replace(/The ENA network plot is written to disk[^]*?is installed\./,
|
|
3193
|
+
'The ENA network plot comes back as an inline image (and the SVG as text).')
|
|
3194
|
+
.replace(/The interactive HTML has its own controls[^.]*\. /, '')
|
|
3195
|
+
.replace(/PRIVACY: .*?\.(?: (?=[A-Z])|$)/g, REMOTE_PRIVACY)
|
|
3196
|
+
.replace(/ ?The WASM model runs entirely on the user's machine[^.]*\./, '')
|
|
3197
|
+
.replace(/If the user has not yet indicated whether their data is sensitive, ask before using data_csv\. ?/, '')
|
|
3198
|
+
.replace(/If they indicate sensitivity, use ena_profile instead \(no raw data transmitted\)\. ?/, '')
|
|
3199
|
+
.trim();
|
|
3200
|
+
const required = (t.inputSchema.required ?? []).filter(r => r !== 'data_path');
|
|
3201
|
+
if (properties.data_csv && !description.includes(REMOTE_PRIVACY.trim()))
|
|
3202
|
+
description = `${description} ${REMOTE_PRIVACY.trim()}`;
|
|
3203
|
+
if (properties.data_csv) properties.data_csv = {
|
|
3204
|
+
type: 'string',
|
|
3205
|
+
description: 'The data as CSV text. The server has no access to the user\'s files; it processes this in memory and does not store it. Send only de-identified data.',
|
|
3206
|
+
};
|
|
3207
|
+
return { ...t, description, inputSchema: { ...t.inputSchema, properties, ...(required.length ? { required } : {}) } };
|
|
3208
|
+
});
|
|
3209
|
+
}
|
|
3210
|
+
const OFFERED_TOOLS = REMOTE ? remoteTools(TOOLS) : TOOLS;
|
|
3211
|
+
|
|
3138
3212
|
// ── Server setup ──────────────────────────────────────────────────────────────
|
|
3139
3213
|
|
|
3140
3214
|
const PKG = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
|
|
@@ -3175,7 +3249,23 @@ function withholdDataValues(msg, args) {
|
|
|
3175
3249
|
|
|
3176
3250
|
// Sent to the client at connection; MCP clients (Claude, Codex, Gemini CLI,
|
|
3177
3251
|
// Cursor, ...) pass it to the model, so the privacy rule reaches every one.
|
|
3178
|
-
const SERVER_INSTRUCTIONS = [
|
|
3252
|
+
const SERVER_INSTRUCTIONS = REMOTE ? [
|
|
3253
|
+
'ENA / ONA / TMA analysis on a hosted server. The server does not run on the user\'s machine and cannot read their files.',
|
|
3254
|
+
'',
|
|
3255
|
+
'Data privacy — follow in every conversation:',
|
|
3256
|
+
'- Data reaches these tools inline, as data_csv. It is processed in memory for the duration of the call and is not stored.',
|
|
3257
|
+
'- Before the first analysis, tell the user that the data leaves their machine and must be de-identified: no names, e-mail addresses or identifying free text. Ask them to confirm before sending it.',
|
|
3258
|
+
'- Results include unit labels (the values of the unit columns, often participant IDs) and group values. If those are identifying, tell the user before fitting so they can pseudonymize the data first.',
|
|
3259
|
+
'- The guide may mention data_path, ena_profile, ena_tables, ena_lines or ena_export; those are for the locally installed server and are not available here.',
|
|
3260
|
+
'',
|
|
3261
|
+
'Results come only from these tools — follow in every conversation:',
|
|
3262
|
+
'- Every ENA, ONA or TMA result shown to the user (accumulated networks, points, rotations, statistics, plots) must come from these tools. Do not compute any part of an ENA analysis yourself — in code, a notebook, R, Python or by hand — and do not draw ENA-style network plots of your own.',
|
|
3263
|
+
'- If a tool fails, read its error, fix the call and retry, or tell the user what failed and stop. Never fill the gap with a substitute that looks like ENA, and never present estimated or illustrative numbers as results.',
|
|
3264
|
+
'- If the user wants something these tools cannot do, say so, and suggest the locally installed server (https://qe-libs.org/mcp/qe-mcp-ena/), rENA in R or the ENA Web Tool, rather than improvising it.',
|
|
3265
|
+
`- Results carry provenance ("${PKG.name} <version>"); anything without it did not come from this server.`,
|
|
3266
|
+
'',
|
|
3267
|
+
'Before the first analysis in a conversation, call ena_guide (no arguments) for the analysis workflow: triage, window, rotations, masking, ONA/TMA and reading the results. ena_guide with a topic returns one section.',
|
|
3268
|
+
].join('\n') : [
|
|
3179
3269
|
'ENA / ONA / TMA analysis that runs locally on the user\'s machine.',
|
|
3180
3270
|
'',
|
|
3181
3271
|
'Data privacy — follow in every conversation:',
|
|
@@ -3231,10 +3321,19 @@ const PROMPTS = [
|
|
|
3231
3321
|
},
|
|
3232
3322
|
];
|
|
3233
3323
|
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3324
|
+
// Handlers are collected, then attached to each Server instance: stdio has one
|
|
3325
|
+
// for the process, HTTP mode builds one per request (stateless, so any replica
|
|
3326
|
+
// can answer any request). The caches above are per process either way.
|
|
3327
|
+
const HANDLERS = [];
|
|
3328
|
+
const on = (schema, handler) => HANDLERS.push([schema, handler]);
|
|
3329
|
+
function buildServer() {
|
|
3330
|
+
const server = new Server(
|
|
3331
|
+
{ name: 'ena', version: PKG.version },
|
|
3332
|
+
{ capabilities: { tools: {}, prompts: {} }, instructions: SERVER_INSTRUCTIONS }
|
|
3333
|
+
);
|
|
3334
|
+
for (const [schema, handler] of HANDLERS) server.setRequestHandler(schema, handler);
|
|
3335
|
+
return server;
|
|
3336
|
+
}
|
|
3238
3337
|
|
|
3239
3338
|
// ── rena-wasm API adapter ─────────────────────────────────────────────────────
|
|
3240
3339
|
// rena-wasm ≥ the R-mirroring rewrite returns an R-style ENA set object
|
|
@@ -3322,15 +3421,16 @@ function getENA() {
|
|
|
3322
3421
|
return enaPromise;
|
|
3323
3422
|
}
|
|
3324
3423
|
|
|
3325
|
-
|
|
3424
|
+
on(ListToolsRequestSchema, async () => ({ tools: OFFERED_TOOLS }));
|
|
3326
3425
|
|
|
3327
|
-
|
|
3328
|
-
|
|
3426
|
+
// The prompts take a data_path, so hosted there are none.
|
|
3427
|
+
on(ListPromptsRequestSchema, async () => ({
|
|
3428
|
+
prompts: REMOTE ? [] : PROMPTS.map(({ text, ...p }) => p),
|
|
3329
3429
|
}));
|
|
3330
3430
|
|
|
3331
|
-
|
|
3431
|
+
on(GetPromptRequestSchema, async (request) => {
|
|
3332
3432
|
const { name, arguments: a = {} } = request.params;
|
|
3333
|
-
const p = PROMPTS.find(x => x.name === name);
|
|
3433
|
+
const p = REMOTE ? null : PROMPTS.find(x => x.name === name);
|
|
3334
3434
|
if (!p) throw new Error(`Unknown prompt: ${name}`);
|
|
3335
3435
|
for (const arg of p.arguments)
|
|
3336
3436
|
if (arg.required && !a[arg.name]) throw new Error(`Missing argument: ${arg.name}`);
|
|
@@ -3340,9 +3440,12 @@ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
|
|
|
3340
3440
|
};
|
|
3341
3441
|
});
|
|
3342
3442
|
|
|
3343
|
-
|
|
3443
|
+
on(CallToolRequestSchema, async (request) => {
|
|
3344
3444
|
const { name, arguments: args } = request.params;
|
|
3345
3445
|
try {
|
|
3446
|
+
if (REMOTE && REMOTE_HIDDEN_TOOLS.has(name))
|
|
3447
|
+
throw new Error(`${name} is not available on a hosted server: it writes or reads files on the user's machine. ` +
|
|
3448
|
+
'It is available in the locally installed server (https://qe-libs.org/mcp/qe-mcp-ena/).');
|
|
3346
3449
|
// rena-wasm windows look backward only (= rENA's window.size.back); a
|
|
3347
3450
|
// forward window would otherwise be silently ignored.
|
|
3348
3451
|
if (Number(args?.window_forward) > 0)
|
|
@@ -3388,7 +3491,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3388
3491
|
svg = await renderSVG(_plot);
|
|
3389
3492
|
if (svg) {
|
|
3390
3493
|
// SVG file — always available (pure JS/WASM), vector, embeddable.
|
|
3391
|
-
|
|
3494
|
+
// Hosted there is no disk the user can reach: the SVG rides
|
|
3495
|
+
// along as a text block instead, after the JSON.
|
|
3496
|
+
if (REMOTE) content.push({ type: 'text', text: svg });
|
|
3497
|
+
else try { artifacts.svg_plot = writeVizFile(svg, slug, 'svg'); }
|
|
3392
3498
|
catch (e) { process.stderr.write(`[ena-mcp] SVG write failed: ${e.message}\n`); }
|
|
3393
3499
|
|
|
3394
3500
|
// PNG (inline image block + png_plot file) only when the optional
|
|
@@ -3401,11 +3507,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3401
3507
|
data: png.toString('base64'),
|
|
3402
3508
|
mimeType: 'image/png',
|
|
3403
3509
|
});
|
|
3404
|
-
try { artifacts.png_plot = writeVizFile(png, slug, 'png'); }
|
|
3510
|
+
if (!REMOTE) try { artifacts.png_plot = writeVizFile(png, slug, 'png'); }
|
|
3405
3511
|
catch (e) { process.stderr.write(`[ena-mcp] PNG write failed: ${e.message}\n`); }
|
|
3406
3512
|
}
|
|
3407
3513
|
} else {
|
|
3408
|
-
renderError = 'renderSVG returned null (
|
|
3514
|
+
renderError = 'renderSVG returned null (qeviz produced no SVG)';
|
|
3409
3515
|
}
|
|
3410
3516
|
} catch (e) {
|
|
3411
3517
|
renderError = e.message;
|
|
@@ -3418,12 +3524,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3418
3524
|
}
|
|
3419
3525
|
|
|
3420
3526
|
// Self-contained interactive HTML for clients that don't render inline images.
|
|
3421
|
-
try {
|
|
3527
|
+
if (!REMOTE) try {
|
|
3422
3528
|
const html = generateHTML(_plot, _plot.title ?? cleanResult.label ?? name);
|
|
3423
3529
|
artifacts.interactive_plot = writeVizFile(html, slug, 'html');
|
|
3424
3530
|
} catch (e) {
|
|
3425
3531
|
process.stderr.write(`[ena-mcp] HTML write failed: ${e.message}\n`);
|
|
3426
3532
|
}
|
|
3533
|
+
if (REMOTE) artifacts.plot_note = 'Hosted server: the plot is the inline image above (PNG) and the SVG text block below; no files were written.';
|
|
3427
3534
|
|
|
3428
3535
|
// Merge all artifact paths into the text block in one pass.
|
|
3429
3536
|
const textBlock = content.find(c => c.type === 'text');
|
|
@@ -3445,5 +3552,54 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3445
3552
|
|
|
3446
3553
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
|
3447
3554
|
|
|
3448
|
-
|
|
3449
|
-
await
|
|
3555
|
+
if (!REMOTE) {
|
|
3556
|
+
await buildServer().connect(new StdioServerTransport());
|
|
3557
|
+
} else {
|
|
3558
|
+
// Streamable HTTP, stateless: every POST gets a fresh Server + transport
|
|
3559
|
+
// and is answered in full, so no session state lives in the process and
|
|
3560
|
+
// any replica can take any request. Model caches are per process (see
|
|
3561
|
+
// README: one replica, or sticky sessions, keeps model_ids valid).
|
|
3562
|
+
// Reads the body up to the cap; past it the rest is drained unread so the
|
|
3563
|
+
// client gets the 413 instead of a dropped connection.
|
|
3564
|
+
const readBody = (req) => new Promise((resolve, reject) => {
|
|
3565
|
+
const chunks = []; let size = 0, tooLarge = Number(req.headers['content-length']) > HTTP_MAX_BODY;
|
|
3566
|
+
req.on('data', (c) => {
|
|
3567
|
+
size += c.length;
|
|
3568
|
+
if (tooLarge || size > HTTP_MAX_BODY) { tooLarge = true; chunks.length = 0; }
|
|
3569
|
+
else chunks.push(c);
|
|
3570
|
+
});
|
|
3571
|
+
req.on('end', () => tooLarge
|
|
3572
|
+
? reject(Object.assign(new Error(`request body too large (limit ${Math.round(HTTP_MAX_BODY / 1048576)} MB)`), { status: 413 }))
|
|
3573
|
+
: resolve(Buffer.concat(chunks).toString('utf8')));
|
|
3574
|
+
req.on('error', reject);
|
|
3575
|
+
});
|
|
3576
|
+
const json = (res, status, body) => {
|
|
3577
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
3578
|
+
res.end(JSON.stringify(body));
|
|
3579
|
+
};
|
|
3580
|
+
const http = createHttpServer(async (req, res) => {
|
|
3581
|
+
const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
|
|
3582
|
+
if (url.pathname === '/healthz') return json(res, 200, { ok: true, server: PKG.name, version: PKG.version });
|
|
3583
|
+
if (url.pathname !== HTTP_PATH) return json(res, 404, { error: `MCP endpoint is ${HTTP_PATH}` });
|
|
3584
|
+
try {
|
|
3585
|
+
let parsedBody;
|
|
3586
|
+
if (req.method === 'POST') {
|
|
3587
|
+
const raw = await readBody(req);
|
|
3588
|
+
try { parsedBody = JSON.parse(raw); }
|
|
3589
|
+
catch { return json(res, 400, { jsonrpc: '2.0', error: { code: -32700, message: 'Parse error' }, id: null }); }
|
|
3590
|
+
}
|
|
3591
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
3592
|
+
res.on('close', () => transport.close());
|
|
3593
|
+
const server = buildServer();
|
|
3594
|
+
await server.connect(transport);
|
|
3595
|
+
await transport.handleRequest(req, res, parsedBody);
|
|
3596
|
+
} catch (e) {
|
|
3597
|
+
process.stderr.write(`[ena-mcp] http: ${e.message}\n`);
|
|
3598
|
+
if (!res.headersSent) json(res, e.status ?? 500, { error: e.status === 413 ? e.message : 'internal error' });
|
|
3599
|
+
else res.end();
|
|
3600
|
+
}
|
|
3601
|
+
});
|
|
3602
|
+
http.listen(HTTP_PORT, HTTP_HOST, () => {
|
|
3603
|
+
process.stderr.write(`[ena-mcp] ${PKG.name} ${PKG.version} serving MCP over HTTP at http://${HTTP_HOST}:${HTTP_PORT}${HTTP_PATH} (health: /healthz)\n`);
|
|
3604
|
+
});
|
|
3605
|
+
}
|
package/xlsx.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* xlsx.js — read the first worksheet of an .xlsx file as CSV text, as the ENA
|
|
3
3
|
* Web Tool does for an uploaded .xlsx (rENA-webtool js/XlsxReader.js, ported
|
|
4
|
-
* to Node: zlib instead of JSZip,
|
|
4
|
+
* to Node: zlib instead of JSZip, a small XML reader instead of the browser's
|
|
5
|
+
* DOMParser).
|
|
5
6
|
*
|
|
6
7
|
* Parity with the web tool, and so with R's openxlsx::read.xlsx(sheet = 1):
|
|
7
8
|
* - first sheet in tab order only; its first row is the header row
|
|
@@ -11,7 +12,6 @@
|
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
import { inflateRawSync } from 'zlib';
|
|
14
|
-
import { JSDOM } from 'jsdom';
|
|
15
15
|
|
|
16
16
|
// ── ZIP: the entries of an .xlsx (a ZIP of XML parts) ────────────────────────
|
|
17
17
|
function unzip(buf) {
|
|
@@ -42,10 +42,61 @@ function unzip(buf) {
|
|
|
42
42
|
return { file: (name) => files.get(name)?.() ?? null };
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
// ── XML: the little of it that SpreadsheetML needs ───────────────────────────
|
|
46
|
+
// Elements with getElementsByTagName (descendants, local name), getAttribute
|
|
47
|
+
// (prefixed or not) and textContent — the subset the readers below use. No
|
|
48
|
+
// namespaces, DTDs or entities beyond the five predefined and numeric ones.
|
|
49
|
+
const XML_ENTITIES = { lt: '<', gt: '>', amp: '&', quot: '"', apos: "'" };
|
|
50
|
+
const unescapeXml = (s) => s.indexOf('&') < 0 ? s : s.replace(/&(#x[0-9a-fA-F]+|#\d+|\w+);/g, (m, e) =>
|
|
51
|
+
e[0] === '#' ? String.fromCodePoint(e[1] === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10))
|
|
52
|
+
: (XML_ENTITIES[e] ?? m));
|
|
53
|
+
|
|
54
|
+
class XmlElement {
|
|
55
|
+
constructor(name, attrs) { this.name = name; this.attrs = attrs; this.children = []; }
|
|
56
|
+
get localName() { const i = this.name.indexOf(':'); return i < 0 ? this.name : this.name.slice(i + 1); }
|
|
57
|
+
getAttribute(name) {
|
|
58
|
+
if (name in this.attrs) return this.attrs[name];
|
|
59
|
+
const i = name.indexOf(':'); // "r:id" also matches an unprefixed "id"
|
|
60
|
+
return i >= 0 && name.slice(i + 1) in this.attrs ? this.attrs[name.slice(i + 1)] : null;
|
|
61
|
+
}
|
|
62
|
+
getAttributeNS(ns, local) {
|
|
63
|
+
for (const k in this.attrs) if (k === local || k.endsWith(':' + local)) return this.attrs[k];
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
get textContent() {
|
|
67
|
+
let out = '';
|
|
68
|
+
for (const c of this.children) out += typeof c === 'string' ? c : c.textContent;
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
getElementsByTagName(local) {
|
|
72
|
+
const out = [];
|
|
73
|
+
const walk = (el) => { for (const c of el.children) if (typeof c !== 'string') { if (c.localName === local) out.push(c); walk(c); } };
|
|
74
|
+
walk(this);
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const ATTR_RE = /([^\s=\/>]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
|
46
80
|
function parseXml(buf) {
|
|
47
|
-
|
|
48
|
-
|
|
81
|
+
const xml = buf.toString('utf8');
|
|
82
|
+
const root = new XmlElement('#document', {});
|
|
83
|
+
const stack = [root];
|
|
84
|
+
const TAG_RE = /<(\/)?([^\s\/>!?]+)([^>]*?)(\/)?>|<!\[CDATA\[([\s\S]*?)\]\]>|<!--[\s\S]*?-->|<[!?][^>]*>/g;
|
|
85
|
+
let last = 0, m;
|
|
86
|
+
while ((m = TAG_RE.exec(xml))) {
|
|
87
|
+
const text = xml.slice(last, m.index);
|
|
88
|
+
if (text) stack[stack.length - 1].children.push(unescapeXml(text));
|
|
89
|
+
last = TAG_RE.lastIndex;
|
|
90
|
+
if (m[5] !== undefined) { stack[stack.length - 1].children.push(m[5]); continue; } // CDATA
|
|
91
|
+
if (m[2] === undefined) continue; // comment, PI, doctype
|
|
92
|
+
if (m[1]) { if (stack.length > 1) stack.pop(); continue; } // closing tag
|
|
93
|
+
const attrs = {};
|
|
94
|
+
for (const a of m[3].matchAll(ATTR_RE)) attrs[a[1]] = unescapeXml(a[2] ?? a[3] ?? '');
|
|
95
|
+
const el = new XmlElement(m[2], attrs);
|
|
96
|
+
stack[stack.length - 1].children.push(el);
|
|
97
|
+
if (!m[4]) stack.push(el);
|
|
98
|
+
}
|
|
99
|
+
return root;
|
|
49
100
|
}
|
|
50
101
|
|
|
51
102
|
// "A1" / "BC12" → 0-based column. Column letters are base-26 (bijective).
|