@qe-mcp/ena 0.1.19 → 0.1.20
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 +11 -4
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/server.js +129 -38
package/README.md
CHANGED
|
@@ -183,10 +183,17 @@ privacy promise, and it says so to the model. In `--http` mode:
|
|
|
183
183
|
minutes after its last use (default 60), then it is forgotten.
|
|
184
184
|
`ena_simplify` refits, so hosted it takes `data_csv` again and checks that
|
|
185
185
|
it is the data the model was fitted on.
|
|
186
|
-
- `
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
186
|
+
- `ena_export` has nowhere to write, so it returns the ENA Web Tool project
|
|
187
|
+
ZIP inline, as a base64 resource in the result. The ZIP holds the data
|
|
188
|
+
file, which the server no longer has, so it takes `data_csv` again (checked
|
|
189
|
+
the same way) and keeps neither the data nor the ZIP afterwards. A client
|
|
190
|
+
that cannot save a file from a result asks for `format: "set"` instead: the
|
|
191
|
+
set file alone, which needs no data and sends none back; the summary says
|
|
192
|
+
how to pack it with the user's own data file. There is no download link,
|
|
193
|
+
not even a short-lived one, because that would mean keeping the data.
|
|
194
|
+
- `ena_profile`, `ena_tables` and `ena_lines` are not offered (they read or
|
|
195
|
+
write files), and the prompts are not listed (they take a path). Plots come
|
|
196
|
+
back as the inline PNG plus the SVG as text; no files are written.
|
|
190
197
|
- The server's instructions tell the model to say that the data leaves the
|
|
191
198
|
user's machine and must be de-identified, and to get their OK first.
|
|
192
199
|
- `model_id`s carry a random suffix so one conversation cannot plot another's
|
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.20",
|
|
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
package/server.js
CHANGED
|
@@ -56,7 +56,7 @@ if (ARGV.includes('--help') || ARGV.includes('-h')) {
|
|
|
56
56
|
' --http MCP over streamable HTTP at /mcp, for hosting. Data must be passed inline',
|
|
57
57
|
' (data_csv) and is discarded after each call; only fitted models are kept, for',
|
|
58
58
|
' $ENA_MCP_MODEL_TTL_MIN minutes (default 60); nothing is written to disk; ena_profile,',
|
|
59
|
-
' ena_tables
|
|
59
|
+
' ena_tables and ena_lines are not offered; ena_export returns the ZIP inline. Health check: GET /healthz.',
|
|
60
60
|
' --port N Port for --http (default: $PORT or 8000).',
|
|
61
61
|
' --host H Interface for --http (default: $HOST or 127.0.0.1; use 0.0.0.0 in a container).',
|
|
62
62
|
' --max-body-mb N Largest request body in MB (default: $ENA_MCP_MAX_BODY_MB or 25).',
|
|
@@ -860,6 +860,9 @@ const MAX_MODELS = 50;
|
|
|
860
860
|
// model — points, networks, unit and group labels — is forgotten MODEL_TTL_MS
|
|
861
861
|
// after its last use instead of waiting to be displaced.
|
|
862
862
|
const KEEP_ROWS = !REMOTE;
|
|
863
|
+
const EXPORT_HINT = REMOTE
|
|
864
|
+
? 'or ena_export (with data_csv again, or format="set") for an ENA Web Tool project.'
|
|
865
|
+
: 'or ena_export to open the model in the ENA Web Tool.';
|
|
863
866
|
let MODEL_TTL_MS = REMOTE ? Number(process.env.ENA_MCP_MODEL_TTL_MIN ?? 60) * 60_000 : 0;
|
|
864
867
|
export function setModelTtl(minutes) { MODEL_TTL_MS = Number(minutes) * 60_000; }
|
|
865
868
|
|
|
@@ -1827,7 +1830,7 @@ async function toolFit(ena, params) {
|
|
|
1827
1830
|
...(tests ? ['', ...tests.lines] : []),
|
|
1828
1831
|
'',
|
|
1829
1832
|
`Use ena_plot with model_id="${model_id}" for additional views (group, unit, subtraction, comparison), ` +
|
|
1830
|
-
|
|
1833
|
+
EXPORT_HINT,
|
|
1831
1834
|
].join('\n');
|
|
1832
1835
|
|
|
1833
1836
|
const result = {
|
|
@@ -2099,7 +2102,7 @@ async function toolCompareGroups(ena, params) {
|
|
|
2099
2102
|
),
|
|
2100
2103
|
'',
|
|
2101
2104
|
`Use ena_plot with model_id="${model_id}" for group, subtraction, comparison, or unit views, ` +
|
|
2102
|
-
|
|
2105
|
+
EXPORT_HINT,
|
|
2103
2106
|
].join('\n');
|
|
2104
2107
|
|
|
2105
2108
|
const result = {
|
|
@@ -2638,7 +2641,7 @@ async function toolLines(ena, { model_id, connection, units, group, columns, max
|
|
|
2638
2641
|
};
|
|
2639
2642
|
}
|
|
2640
2643
|
|
|
2641
|
-
async function toolExport(ena, { model_id, set_name, out_path }) {
|
|
2644
|
+
async function toolExport(ena, { model_id, set_name, out_path, data_csv, format }) {
|
|
2642
2645
|
const entry = modelGet(model_id);
|
|
2643
2646
|
if (!entry)
|
|
2644
2647
|
throw new Error(`Unknown model_id '${model_id}'. Fit the model first (ena_fit, ena_compare_groups or tma_fit).`);
|
|
@@ -2647,9 +2650,34 @@ async function toolExport(ena, { model_id, set_name, out_path }) {
|
|
|
2647
2650
|
throw new Error(`Model ${model_id} can't be exported to the ENA Web Tool, which has no setting for: ${blockers.join('; ')}. ` +
|
|
2648
2651
|
'Refit without these to export it.');
|
|
2649
2652
|
|
|
2650
|
-
|
|
2653
|
+
let p = entry.params;
|
|
2654
|
+
if (KEEP_ROWS) {
|
|
2655
|
+
format = 'zip';
|
|
2656
|
+
} else {
|
|
2657
|
+
// The hosted server kept nothing but the model. The project ZIP holds
|
|
2658
|
+
// the data file, so the data must come again (and be the same data);
|
|
2659
|
+
// the set file alone needs no data unless the set stores excluded
|
|
2660
|
+
// units or conversations, which are read from the rows.
|
|
2661
|
+
format = format ?? 'zip';
|
|
2662
|
+
if (format !== 'zip' && format !== 'set')
|
|
2663
|
+
throw new Error('format must be "zip" (the project ZIP, which needs data_csv again) or "set" (the set file alone).');
|
|
2664
|
+
if (data_csv) {
|
|
2665
|
+
if (dataStamp({ data_csv }) !== entry._data)
|
|
2666
|
+
throw new Error(`data_csv is not the data model ${model_id} was fitted on. Pass the same CSV, or fit a new model.`);
|
|
2667
|
+
p = { ...p, data_csv };
|
|
2668
|
+
} else if (format === 'zip') {
|
|
2669
|
+
throw new Error('Hosted server: the data is not stored. Pass data_csv again (the CSV the model was fitted on) to build the ZIP, ' +
|
|
2670
|
+
'or format="set" for the set file alone.');
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2651
2673
|
const unitCols = toArray(p.units);
|
|
2652
2674
|
const warnings = [];
|
|
2675
|
+
let _rows = null;
|
|
2676
|
+
const fileRows = (why) => {
|
|
2677
|
+
if (!p.data_path && p.data_csv == null)
|
|
2678
|
+
throw new Error(`Hosted server: this set needs the rows (${why}). Pass data_csv again (the CSV the model was fitted on).`);
|
|
2679
|
+
return _rows ??= loadRows({ data_path: p.data_path, data_csv: p.data_csv });
|
|
2680
|
+
};
|
|
2653
2681
|
|
|
2654
2682
|
// The web tool groups units by their first unit column; take that column's
|
|
2655
2683
|
// groups (order, colours) for the stored set.
|
|
@@ -2660,8 +2688,7 @@ async function toolExport(ena, { model_id, set_name, out_path }) {
|
|
|
2660
2688
|
if (entry.groupCol === groupCol) {
|
|
2661
2689
|
groups = entry.web.groups.map(g => ({ name: g.name, color: g.color, secondaryColor: g.secondaryColor, showMean: g.showMean }));
|
|
2662
2690
|
} else {
|
|
2663
|
-
|
|
2664
|
-
groups = web.groupOrder(rows, groupCol).map((name, i) => ({ name, ...web.groupStyle(i) }));
|
|
2691
|
+
groups = web.groupOrder(fileRows(`the ${groupCol} groups`), groupCol).map((name, i) => ({ name, ...web.groupStyle(i) }));
|
|
2665
2692
|
if (entry.groupCol)
|
|
2666
2693
|
warnings.push(`The web tool groups units by ${groupCol}, so its plots show ${groupCol} groups, not ${entry.groupCol}.`);
|
|
2667
2694
|
}
|
|
@@ -2676,9 +2703,6 @@ async function toolExport(ena, { model_id, set_name, out_path }) {
|
|
|
2676
2703
|
|
|
2677
2704
|
// The web tool's project holds a CSV; an .xlsx goes in as its first sheet.
|
|
2678
2705
|
const csvName = p.data_path ? basename(p.data_path).replace(/\.xlsx$/i, '.csv') : 'data.csv';
|
|
2679
|
-
const csv = p.data_path
|
|
2680
|
-
? (isXlsx(p.data_path) ? Buffer.from(dataFileText(p.data_path), 'utf8') : readFileSync(p.data_path))
|
|
2681
|
-
: Buffer.from(p.data_csv ?? '', 'utf8');
|
|
2682
2706
|
const setName = safeFileName(set_name ??
|
|
2683
2707
|
(plotted.secondary ? `${plotted.primary} vs ${plotted.secondary}` : `ENA model ${model_id}`));
|
|
2684
2708
|
|
|
@@ -2687,7 +2711,6 @@ async function toolExport(ena, { model_id, set_name, out_path }) {
|
|
|
2687
2711
|
// Built from the whole file: the web tool applies exclude_conversations
|
|
2688
2712
|
// and order_by itself (WasmWrapper._modelRowIndices), on the data file the
|
|
2689
2713
|
// ZIP carries unchanged.
|
|
2690
|
-
const fileRows = loadRows({ data_path: p.data_path, data_csv: p.data_csv });
|
|
2691
2714
|
const exclConvos = toArray(p.exclude_conversations).map(e => String(e).split('__').join('::'));
|
|
2692
2715
|
let unitTree = null, excluded = 0;
|
|
2693
2716
|
if (p.units_used?.length || exclConvos.length) {
|
|
@@ -2696,7 +2719,7 @@ async function toolExport(ena, { model_id, set_name, out_path }) {
|
|
|
2696
2719
|
// rebuilds the conversation tree, which drops its include flags.
|
|
2697
2720
|
const used = p.units_used?.length ? new Set(p.units_used.map(String)) : null;
|
|
2698
2721
|
const seen = new Map();
|
|
2699
|
-
for (const r of fileRows) {
|
|
2722
|
+
for (const r of fileRows('excluded units')) {
|
|
2700
2723
|
const values = unitCols.map(c => String(r[c]));
|
|
2701
2724
|
const key = values.join('__');
|
|
2702
2725
|
if (!seen.has(key)) seen.set(key, { values, include: used ? used.has(key) : true });
|
|
@@ -2709,7 +2732,7 @@ async function toolExport(ena, { model_id, set_name, out_path }) {
|
|
|
2709
2732
|
const convoCols = toArray(p.conversations);
|
|
2710
2733
|
const out = new Set(exclConvos);
|
|
2711
2734
|
const seen = new Map();
|
|
2712
|
-
for (const r of fileRows) {
|
|
2735
|
+
for (const r of fileRows('excluded conversations')) {
|
|
2713
2736
|
const values = convoCols.map(c => String(r[c]));
|
|
2714
2737
|
const key = values.join('::');
|
|
2715
2738
|
if (!seen.has(key)) seen.set(key, { values, include: !out.has(key) });
|
|
@@ -2733,19 +2756,6 @@ async function toolExport(ena, { model_id, set_name, out_path }) {
|
|
|
2733
2756
|
source: { tool: PKG.name, version: PKG.version, model_id, exported: new Date().toISOString() },
|
|
2734
2757
|
});
|
|
2735
2758
|
|
|
2736
|
-
const zipBuf = web.zip([
|
|
2737
|
-
{ name: csvName, data: csv },
|
|
2738
|
-
{ name: 'Folder/' },
|
|
2739
|
-
{ name: `Folder/${setName}.json`, data: JSON.stringify(set, null, 2) },
|
|
2740
|
-
]);
|
|
2741
|
-
|
|
2742
|
-
let target = out_path
|
|
2743
|
-
? (extname(out_path).toLowerCase() === '.zip' ? out_path : join(out_path, `${setName}.zip`))
|
|
2744
|
-
: join(p.data_path ? dirname(p.data_path) : VIZ_DIR, `${setName}.zip`);
|
|
2745
|
-
target = freePath(target);
|
|
2746
|
-
mkdirSync(dirname(target), { recursive: true });
|
|
2747
|
-
writeFileSync(target, zipBuf);
|
|
2748
|
-
|
|
2749
2759
|
const rot = set.settings.model;
|
|
2750
2760
|
const settings = {
|
|
2751
2761
|
units: unitCols, conversations: toArray(p.conversations), codes: entry.codes,
|
|
@@ -2760,14 +2770,82 @@ async function toolExport(ena, { model_id, set_name, out_path }) {
|
|
|
2760
2770
|
...(p.order_by && { order_by: p.order_by }),
|
|
2761
2771
|
...(toArray(p.mask_connections).length && { masked_connections: toArray(p.mask_connections) }),
|
|
2762
2772
|
};
|
|
2773
|
+
const howToOpen = [
|
|
2774
|
+
'To open it: start a local session in the ENA Web Tool ("Run Offline"), click Import in the Sets panel,',
|
|
2775
|
+
`choose the ZIP, then open Folder › ${setName}. (Project import works in local sessions only.)`,
|
|
2776
|
+
];
|
|
2777
|
+
|
|
2778
|
+
if (format === 'set') {
|
|
2779
|
+
// Hosted, without the data: the set file alone. The user packs the
|
|
2780
|
+
// project themselves, so their data never has to come back here.
|
|
2781
|
+
return {
|
|
2782
|
+
set_name: setName,
|
|
2783
|
+
data_file: csvName,
|
|
2784
|
+
set_file: `Folder/${setName}.json`,
|
|
2785
|
+
set,
|
|
2786
|
+
settings,
|
|
2787
|
+
warnings,
|
|
2788
|
+
privacy_note: 'Hosted server: this is the set file only — settings, groups and labels, no rows. Nothing was stored.',
|
|
2789
|
+
summary: [
|
|
2790
|
+
`Set file for model ${model_id} (ENA Web Tool), without the data.`,
|
|
2791
|
+
` Set: Folder / ${setName} Opens with: ${settings.opens_with}`,
|
|
2792
|
+
...warnings.map(w => ` Note: ${w}`),
|
|
2793
|
+
'',
|
|
2794
|
+
'To make the project ZIP: save the "set" field as JSON at Folder/' + setName + '.json, put the data file',
|
|
2795
|
+
`the model was fitted on beside it as ${csvName} (that exact name), and zip the two —`,
|
|
2796
|
+
`the ZIP holds ${csvName} and Folder/${setName}.json.`,
|
|
2797
|
+
...howToOpen,
|
|
2798
|
+
].join('\n'),
|
|
2799
|
+
};
|
|
2800
|
+
}
|
|
2801
|
+
|
|
2802
|
+
const csv = p.data_path
|
|
2803
|
+
? (isXlsx(p.data_path) ? Buffer.from(dataFileText(p.data_path), 'utf8') : readFileSync(p.data_path))
|
|
2804
|
+
: Buffer.from(p.data_csv ?? '', 'utf8');
|
|
2805
|
+
const zipBuf = web.zip([
|
|
2806
|
+
{ name: csvName, data: csv },
|
|
2807
|
+
{ name: 'Folder/' },
|
|
2808
|
+
{ name: `Folder/${setName}.json`, data: JSON.stringify(set, null, 2) },
|
|
2809
|
+
]);
|
|
2810
|
+
|
|
2811
|
+
if (!KEEP_ROWS) {
|
|
2812
|
+
// Hosted: nowhere to write, so the ZIP rides along with the result as
|
|
2813
|
+
// an embedded resource (base64). Nothing of it stays on the server.
|
|
2814
|
+
return {
|
|
2815
|
+
_zip: { name: `${setName}.zip`, data: zipBuf },
|
|
2816
|
+
zip_file: `${setName}.zip`,
|
|
2817
|
+
zip_bytes: zipBuf.length,
|
|
2818
|
+
set_name: setName,
|
|
2819
|
+
data_file: csvName,
|
|
2820
|
+
settings,
|
|
2821
|
+
warnings,
|
|
2822
|
+
privacy_note: 'Hosted server: the ZIP holds the data sent with this call. The server kept neither the data nor the ZIP.',
|
|
2823
|
+
summary: [
|
|
2824
|
+
`Exported model ${model_id} as an ENA Web Tool project ZIP: ${setName}.zip (${zipBuf.length} bytes), ` +
|
|
2825
|
+
'attached to this result as a base64 resource (application/zip). Save it as a .zip file.',
|
|
2826
|
+
` Set: Folder / ${setName} Data: ${csvName}`,
|
|
2827
|
+
` Opens with: ${settings.opens_with}`,
|
|
2828
|
+
...warnings.map(w => ` Note: ${w}`),
|
|
2829
|
+
'',
|
|
2830
|
+
...howToOpen,
|
|
2831
|
+
].join('\n'),
|
|
2832
|
+
};
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
let target = out_path
|
|
2836
|
+
? (extname(out_path).toLowerCase() === '.zip' ? out_path : join(out_path, `${setName}.zip`))
|
|
2837
|
+
: join(p.data_path ? dirname(p.data_path) : VIZ_DIR, `${setName}.zip`);
|
|
2838
|
+
target = freePath(target);
|
|
2839
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
2840
|
+
writeFileSync(target, zipBuf);
|
|
2841
|
+
|
|
2763
2842
|
const summary = [
|
|
2764
2843
|
`Exported model ${model_id} as an ENA Web Tool project: ${target}`,
|
|
2765
2844
|
` Set: Folder / ${setName} Data: ${csvName}`,
|
|
2766
2845
|
` Opens with: ${settings.opens_with}`,
|
|
2767
2846
|
...warnings.map(w => ` Note: ${w}`),
|
|
2768
2847
|
'',
|
|
2769
|
-
|
|
2770
|
-
`choose the ZIP, then open Folder › ${setName}. (Project import works in local sessions only.)`,
|
|
2848
|
+
...howToOpen,
|
|
2771
2849
|
].join('\n');
|
|
2772
2850
|
|
|
2773
2851
|
return {
|
|
@@ -2855,7 +2933,8 @@ function toolGuide({ topic } = {}) {
|
|
|
2855
2933
|
const header = REMOTE
|
|
2856
2934
|
? '> **Hosted server.** This server runs remotely, not on the user\'s machine. Where the guide says to pass ' +
|
|
2857
2935
|
'data_path, pass the data inline as data_csv instead, after the user confirms it is de-identified. ' +
|
|
2858
|
-
'ena_profile, ena_tables
|
|
2936
|
+
'ena_profile, ena_tables and ena_lines are not available here; plots come back inline, and ena_export returns ' +
|
|
2937
|
+
'the project ZIP inline (with data_csv again) or the set file alone (format="set").\n\n'
|
|
2859
2938
|
: '';
|
|
2860
2939
|
if (!topic) return header + GUIDE.text + footer;
|
|
2861
2940
|
const t = String(topic).toLowerCase().trim();
|
|
@@ -3208,13 +3287,16 @@ const TOOLS = [
|
|
|
3208
3287
|
'it rebuilds the same model and opens on the same comparison plot. ' +
|
|
3209
3288
|
'Excluded units (units_used) are stored as excluded in the web tool\'s Units tab. Models the web tool has no setting for (transmodal or time windows, a GMR on x_var, rotations by a column other than the first unit column, ' +
|
|
3210
3289
|
'center_align_to_origin=false) are refused with the reason. ' +
|
|
3211
|
-
'PRIVACY: the ZIP contains the data file. It is written locally (by default next to the data file, never overwriting one) and no rows are sent to Claude.'
|
|
3290
|
+
'PRIVACY: the ZIP contains the data file. It is written locally (by default next to the data file, never overwriting one) and no rows are sent to Claude. ' +
|
|
3291
|
+
'HOSTED: there is no disk; the ZIP comes back inline, built from data_csv sent again with the call, or format="set" returns the set file alone for the user to pack with their own data file.',
|
|
3212
3292
|
inputSchema: {
|
|
3213
3293
|
type: 'object',
|
|
3214
3294
|
properties: {
|
|
3215
3295
|
model_id: { type: 'string', description: 'model_id of the model to export' },
|
|
3216
3296
|
set_name: { type: 'string', description: 'Name of the set in the web tool (default: "<group 1> vs <group 2>", or "ENA model <id>")' },
|
|
3217
|
-
out_path: { type: 'string', description: 'Where to write: a .zip path, or a folder. Default: the data file\'s folder, as "<set name>.zip" (a number is added rather than overwrite).' },
|
|
3297
|
+
out_path: { type: 'string', description: 'Where to write: a .zip path, or a folder. Default: the data file\'s folder, as "<set name>.zip" (a number is added rather than overwrite).', _localOnly: true },
|
|
3298
|
+
format: { type: 'string', enum: ['zip', 'set'], description: 'Hosted server only. "zip" (default): the project ZIP, returned inline as a base64 resource; needs data_csv again. "set": the set file alone (no data needed, nothing sent back but settings); the user packs it with their own data file as the summary says. Locally, omit.', _hostedOnly: true },
|
|
3299
|
+
data_csv: { type: 'string', description: 'Hosted server only: the CSV the model was fitted on, again (the server does not store it). Required for format "zip". Locally, omit.', _hostedOnly: true },
|
|
3218
3300
|
},
|
|
3219
3301
|
required: ['model_id'],
|
|
3220
3302
|
},
|
|
@@ -3224,13 +3306,14 @@ const TOOLS = [
|
|
|
3224
3306
|
// Hosted: no user files to read or write, so the file tools go, data_path goes
|
|
3225
3307
|
// from every schema, and the privacy wording changes from "keep it local" to
|
|
3226
3308
|
// "send it de-identified".
|
|
3227
|
-
const REMOTE_HIDDEN_TOOLS = new Set(['ena_profile', 'ena_tables', 'ena_lines'
|
|
3309
|
+
const REMOTE_HIDDEN_TOOLS = new Set(['ena_profile', 'ena_tables', 'ena_lines']);
|
|
3228
3310
|
const REMOTE_PRIVACY = 'PRIVACY (hosted server): the data is sent inline and processed in memory; it is discarded when the call returns, and only the fitted model is kept, for a limited time. ' +
|
|
3229
3311
|
'Before sending it, confirm with the user that it is de-identified. ';
|
|
3230
3312
|
function remoteTools(tools) {
|
|
3231
3313
|
return tools.filter(t => !REMOTE_HIDDEN_TOOLS.has(t.name)).map(t => {
|
|
3232
|
-
const { data_path, ...properties } = t.inputSchema.properties ?? {};
|
|
3314
|
+
const { data_path, ...properties } = Object.fromEntries(Object.entries(t.inputSchema.properties ?? {}).filter(([, v]) => !v._localOnly));
|
|
3233
3315
|
let description = t.description
|
|
3316
|
+
.replace(/ ?It is written locally[^.]*\. HOSTED: /, ' Hosted: ')
|
|
3234
3317
|
.replace(/The ENA network plot is written to disk[^]*?Use these paths to show the plot; /,
|
|
3235
3318
|
'The ENA network plot comes back as an inline image. Use it to show the plot; ')
|
|
3236
3319
|
.replace(/The ENA network plot is written to disk[^]*?is installed\./,
|
|
@@ -3241,6 +3324,7 @@ function remoteTools(tools) {
|
|
|
3241
3324
|
.replace(/If the user has not yet indicated whether their data is sensitive, ask before using data_csv\. ?/, '')
|
|
3242
3325
|
.replace(/If they indicate sensitivity, use ena_profile instead \(no raw data transmitted\)\. ?/, '')
|
|
3243
3326
|
.trim();
|
|
3327
|
+
for (const [k, v] of Object.entries(properties)) if (v._hostedOnly) { const { _hostedOnly, ...d } = v; properties[k] = d; }
|
|
3244
3328
|
const required = (t.inputSchema.required ?? []).filter(r => r !== 'data_path');
|
|
3245
3329
|
if (properties.data_csv && !description.includes(REMOTE_PRIVACY.trim()))
|
|
3246
3330
|
description = `${description} ${REMOTE_PRIVACY.trim()}`;
|
|
@@ -3254,8 +3338,10 @@ function remoteTools(tools) {
|
|
|
3254
3338
|
}
|
|
3255
3339
|
function localTools(tools) {
|
|
3256
3340
|
return tools.map(t => {
|
|
3257
|
-
const properties = Object.fromEntries(Object.entries(t.inputSchema.properties ?? {}).filter(([, v]) => !v._hostedOnly)
|
|
3258
|
-
|
|
3341
|
+
const properties = Object.fromEntries(Object.entries(t.inputSchema.properties ?? {}).filter(([, v]) => !v._hostedOnly)
|
|
3342
|
+
.map(([k, { _localOnly, ...v }]) => [k, v]));
|
|
3343
|
+
const description = t.description.replace(/ HOSTED: .*$/, '');
|
|
3344
|
+
return { ...t, description, inputSchema: { ...t.inputSchema, properties } };
|
|
3259
3345
|
});
|
|
3260
3346
|
}
|
|
3261
3347
|
const OFFERED_TOOLS = REMOTE ? remoteTools(TOOLS) : localTools(TOOLS);
|
|
@@ -3302,10 +3388,11 @@ const serverInstructions = () => REMOTE ? [
|
|
|
3302
3388
|
'',
|
|
3303
3389
|
'Data privacy — follow in every conversation:',
|
|
3304
3390
|
'- Data reaches these tools inline, as data_csv. It is parsed in memory for the call and discarded when the call returns: the server keeps no rows and no CSV text, in memory or on disk.',
|
|
3305
|
-
`- What a model_id refers to — the fitted model: points, networks, unit labels and group values, never the rows — stays in memory for ${Math.round(MODEL_TTL_MS / 60_000)} minutes after its last use, then is forgotten. ena_simplify refits, so
|
|
3391
|
+
`- What a model_id refers to — the fitted model: points, networks, unit labels and group values, never the rows — stays in memory for ${Math.round(MODEL_TTL_MS / 60_000)} minutes after its last use, then is forgotten. ena_simplify refits and ena_export packs the data file, so they take data_csv again; ena_export with format="set" takes none.`,
|
|
3306
3392
|
'- 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.',
|
|
3307
3393
|
'- 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.',
|
|
3308
|
-
'- The guide may mention data_path, ena_profile, ena_tables
|
|
3394
|
+
'- The guide may mention data_path, ena_profile, ena_tables or ena_lines; those are for the locally installed server and are not available here.',
|
|
3395
|
+
'- ena_export returns the ENA Web Tool project ZIP inline (a base64 resource) when it gets data_csv again. If the client cannot save a file from a result, use format="set" and give the user the set file and the packing steps from the summary; their data file never has to come back here.',
|
|
3309
3396
|
'',
|
|
3310
3397
|
'Results come only from these tools — follow in every conversation:',
|
|
3311
3398
|
'- 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.',
|
|
@@ -3525,10 +3612,14 @@ on(CallToolRequestSchema, async (request) => {
|
|
|
3525
3612
|
}
|
|
3526
3613
|
|
|
3527
3614
|
// Extract viz fields before stringifying so they never appear in the text block.
|
|
3528
|
-
const { _plot, ...cleanResult } = result;
|
|
3615
|
+
const { _plot, _zip, ...cleanResult } = result;
|
|
3529
3616
|
|
|
3530
3617
|
cleanResult.provenance = PROVENANCE;
|
|
3531
3618
|
const content = [{ type: 'text', text: JSON.stringify(cleanResult, null, 2) }];
|
|
3619
|
+
if (_zip) content.push({
|
|
3620
|
+
type: 'resource',
|
|
3621
|
+
resource: { uri: `ena://export/${encodeURIComponent(_zip.name)}`, mimeType: 'application/zip', blob: _zip.data.toString('base64') },
|
|
3622
|
+
});
|
|
3532
3623
|
|
|
3533
3624
|
if (_plot) {
|
|
3534
3625
|
const slug = vizSlug(cleanResult, args) || name;
|