@barivia/barsom-mcp 0.22.2 → 0.22.4

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/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
1
  #!/usr/bin/env node
2
- import{McpServer as e}from"@modelcontextprotocol/sdk/server/mcp.js";import{StdioServerTransport as t}from"@modelcontextprotocol/sdk/server/stdio.js";import{z as o}from"zod";import{getUiCapability as r,registerAppResource as s,RESOURCE_MIME_TYPE as n}from"@modelcontextprotocol/ext-apps/server";import{startVizServer as i}from"./viz-server.js";import{API_KEY as a,apiCall as l,apiRawCall as c,loadViewHtml as p,setVizPort as m,setClientSupportsMcpApps as u,CLIENT_VERSION as d}from"./shared.js";import{registerDatasetsTool as f}from"./tools/datasets.js";import{registerTrainTool as g}from"./tools/train.js";import{registerJobsTool as h,JOBS_DESCRIPTION_BASE as _}from"./tools/jobs.js";import{registerResultsTool as b}from"./tools/results.js";import{registerExploreMapTool as w,RESULTS_EXPLORER_URI as y}from"./tools/explore_map.js";import{registerAccountTool as j}from"./tools/account.js";import{registerInferenceTool as v}from"./tools/inference.js";import{registerGuideBarsomTool as x}from"./tools/guide_barsom.js";import{registerTrainingGuidanceTool as P}from"./tools/training_guidance.js";import{registerFeedbackTool as k}from"./tools/feedback.js";import{registerTrainingMonitorTool as I,TRAINING_MONITOR_URI as A}from"./tools/training_monitor.js";import{resolvePrepareTrainingPrompt as M}from"./prepare_training_prompt.js";a||(console.error("Error: BARIVIA_API_KEY not set. Set it in your MCP client config."),process.exit(1));const T=new e({name:"analytics-engine",version:d,instructions:'# Barivia Mapping Analytics Engine\n\nSelf-organizing map (SOM) analytics: project high-dimensional data to a 2D grid for clusters, gradients, and anomalies.\n\n## Workflow (short)\n\nUpload (`datasets(upload)`) → `datasets(preview)` and `datasets(analyze)` before train → submit one of `train(action=map)`, `train(action=siom_map)`, or `train(action=floop_siom)` (only if plan allows FLooP) → poll `jobs(status)` every 10–15s until `completed` → `results(get)` for metrics and figures (there is no separate analyze tool), then `results_explorer(job_id)` to browse the figures interactively. Then `jobs(compare)`, `results(download/recolor)`, or `inference` (predict / compare / project_columns / impute_column / transition_flow) as needed.\n\n**Full detail:** Call `guide_barsom_workflow` for plan-scoped tool map, training modes, async rules, optional MCP App UIs, and step-by-step SOP (from the Barivia API when online).\n\n## Tool taxonomy (one tool per stage — do not mix)\n\n| Stage | Tool(s) | Contract |\n|-------|---------|----------|\n| Data | `datasets` | upload, preview, analyze, list, **get** (status/staging), subset, add_expression, reduce_spectral, delete — ingest + feature engineering, no map |\n| Train submit | `train` | map, siom_map, impute (sparse → map + dense imputed.csv), floop_siom (entitled). Async, returns job_id. Submit only |\n| Lifecycle | `jobs` | status, list, compare (**training runs**), cancel, delete. No training/scoring here |\n| Artifacts | `results` | get (default=combined only; figures="none" for metrics-only sweeps; figures="all" or results_explorer/download for every plot), export, download, recolor (async). Read/render completed jobs |\n| Frozen-map ops | `inference` | predict (regime-aware; "compact"|"annotated"), batch_predict, impute_column (neighbor-pool fill for a non-training column), compare (cohort density-diff), project_columns, transition_flow (time-ordered rows only), report. No weight updates |\n| Account | `account` | status, history, add_funds |\n| Bootstrap | `guide_barsom_workflow`, `train`, `training_guidance`, `prepare_training` prompt | orientation, submit map/siom/impute/floop, parameter hints, narrative checklist (API/tier-scoped) |\n| Explore (UI) | `results_explorer`, `training_monitor` | richer UX over the same API; `jobs(status)`+`results(get)` still suffice headless |\n| Other | `send_feedback` | only after user agrees |\n\n### Avoid these mis-routes\n- `jobs(compare)` = metrics table across **training runs** (pick best in a sweep). `inference(compare)` = density-diff of one map vs a **second dataset** (drift/cohort). Different tools.\n- Post-training map analysis (predict, compare, project_columns, impute_column, **transition_flow**) is all **inference** — not `results`. `results` is presentation/artifacts only (`recolor` stays here: it re-renders, it doesn\'t touch data).\n- Project a quantity onto the map: existing columns → `inference(project_columns)`; a formula → `datasets(add_expression, project_onto_job=<job>)`.\n\n## Async pattern\n\n- **Manual poll:** Training submits return `job_id` immediately — poll `jobs(status)` every 10–15s. **Running is not failed**; large grids or FLooP-SIOM can take many minutes. `max_nodes` (FLooP) is a total node budget, not grid side length.\n- **Often auto-polled:** `inference` actions (incl. `transition_flow`) and `results(recolor)` may wait in-proxy; if you get a `job_id`, poll `jobs(status)` the same way.\n\nCredits: jobs consume compute credits; check `account(status)` before big runs. Per-request HTTP timeout defaults to 60s (`datasets(analyze)` is async + auto-polled, so it is not bound by it); on slow networks raise `BARIVIA_FETCH_TIMEOUT_MS`. Timeouts are NOT auto-retried (avoids duplicating slow/expensive work).\n\n## Constraints\n\n- Prep ladder: `prepare_training` prompt = narrative checklist; `training_guidance` = structured hints (presets, resolved normalization/transforms, grid/epochs). Review these, then submit with `train`. Do not guess tiers or FLooP entitlement.\n- `inference(predict)`: prefer `dataset_id` for batch and for SIOM/irregular maps; single-row `rows` uses a fast path that can fail on some topologies — retry with `dataset_id`. FLooP-SIOM: if predict jobs fail while grid SIOM works, capture errors + `job_id`.\n- Column names are case-sensitive — match `datasets(preview)`.\n- Default training path is numeric/cyclic/temporal; use explicit `categorical_features` for baseline categoricals. `predict` must match the model contract.\n- After `results(recolor)`, `inference(transition_flow)`, or `inference(project_columns)`, use the **new** `job_id` returned for follow-up `results` if applicable.\n- **Large results / MCP timeouts:** For sweeps, `jobs(compare)`, or metrics-only reads use `results(action=get, figures="none")`. Default `get` inlines combined only. For every plot use `results_explorer` or `results(action=download)`, not `figures="all"` in chat unless the user wants all images inline.'});s(T,y,y,{mimeType:n},async()=>{const e=await p("results-explorer");return{contents:[{uri:y,mimeType:n,text:e??"<html><body>Results Explorer view not built yet. Run: npm run build:views</body></html>"}]}}),s(T,A,A,{mimeType:n},async()=>{const e=await p("training-monitor");return{contents:[{uri:A,mimeType:n,text:e??"<html><body>Training Monitor view not built yet.</body></html>"}]}}),x(T),w(T),I(T),f(T),g(T),h(T,_),b(T),j(T),v(T),P(T),k(T),T.prompt("info","Short orientation for the Barivia Mapping MCP. For full plan-scoped workflow, tool map, and SOP, the model should call guide_barsom_workflow. Use when the user asks what this MCP can do or how to get started.",{},()=>({messages:[{role:"user",content:{type:"text",text:["Give a concise, scannable answer (headers + bullets):","","**What it is:** MCP client to the Barivia mapping engine (2D SOM / SIOM / FLooP-SIOM when entitled) over HTTPS.","","**First step:** Call `guide_barsom_workflow` for plan-scoped bootstrap (full tool list, async rules, training modes, optional MCP Apps, SOP).","","**Core path:** `datasets(upload)` → `datasets(preview)` + `datasets(analyze)` → choose training action → poll `jobs(status)` every 10–15s until completed → `results(get)` (all main figures/metrics; no separate analyze tool) → `results_explorer(job_id)` to browse figures.","",'**Key tools:** `datasets` (data; reduce_spectral for spectra/long blocks), `train` (map/siom_map/impute/floop_siom; pass `label` for readable compare rows; preset=quick for a fast first map), `jobs` (status/list/compare/cancel/delete — lifecycle only), `results` (get/download/export/recolor; figures="none" for metrics-only), `inference` (predict; batch_predict; impute_column for topology-neighbor pool fill; compare; project_columns; transition_flow; report), `account` (status/credits/queue).',"","**Prep help:** `prepare_training` prompt (checklist) · `training_guidance` (presets/JSON hints + resolved normalization/params) — review, then submit with `train`.","","**Browse results:** `results_explorer(job_id)` is the preferred way to explore figures after a first `results(get)` glance; `training_monitor` shows live progress. Both optional — `results` + `jobs(status)` suffice headless.","","**After training:** `jobs(compare)` across runs, `results(recolor)`, `inference(project_columns)` for variables not in training, `inference(transition_flow)` only if rows are time-ordered.","","**Rules:** Running ≠ failed. Column names must match `datasets(preview)` exactly. Do not call `_fetch_figure` from chat (host/UI only); use `results(get)` or `results_explorer`.","","Offer `send_feedback` only after asking the user."].join("\n")}}]})),T.prompt("prepare_training","Narrative pre-training checklist (prompt). Use after upload and before train. Content is tier-scoped from the API when online. Prep ladder: this prompt = story checklist; training_guidance tool = JSON presets/parameter hints. Review, then submit with train.",{dataset_id:o.string().describe("Dataset ID to prepare for training")},async({dataset_id:e})=>{const t=await M(e);return{messages:[{role:"user",content:{type:"text",text:(t.used_fallback?`[offline fallback ${t.fallback_version}]\n\n`:"")+t.text}}]}});const S=new t;(async function(){try{const e=await i(l,c,p);m(e)}catch(e){process.env.BARIVIA_VIZ_PORT&&console.error("Barivia viz server failed to start:",e)}const e=T.server;e.oninitialized=()=>{const t=e.getClientCapabilities(),o=r(t);u(!!o?.mimeTypes?.includes(n))},await T.connect(S)})().catch(console.error);
2
+ import{McpServer as e}from"@modelcontextprotocol/sdk/server/mcp.js";import{StdioServerTransport as t}from"@modelcontextprotocol/sdk/server/stdio.js";import{z as o}from"zod";import{getUiCapability as r,registerAppResource as s,RESOURCE_MIME_TYPE as n}from"@modelcontextprotocol/ext-apps/server";import{startVizServer as a}from"./viz-server.js";import{API_KEY as i,apiCall as l,apiRawCall as p,loadViewHtml as c,setVizPort as u,setClientSupportsMcpApps as d,CLIENT_VERSION as m}from"./shared.js";import{registerDatasetsTool as f}from"./tools/datasets.js";import{registerTrainTool as g}from"./tools/train.js";import{registerJobsTool as h,JOBS_DESCRIPTION_BASE as _}from"./tools/jobs.js";import{registerResultsTool as b}from"./tools/results.js";import{registerExploreMapTool as y,RESULTS_EXPLORER_URI as w}from"./tools/explore_map.js";import{registerAccountTool as v}from"./tools/account.js";import{registerInferenceTool as j}from"./tools/inference.js";import{registerGuideBarsomTool as x}from"./tools/guide_barsom.js";import{registerTrainingGuidanceTool as k}from"./tools/training_guidance.js";import{registerFeedbackTool as P}from"./tools/feedback.js";import{registerTrainingMonitorTool as A,TRAINING_MONITOR_URI as I}from"./tools/training_monitor.js";import{resolvePrepareTrainingPrompt as C}from"./prepare_training_prompt.js";i||(console.error("Error: BARIVIA_API_KEY not set. Set it in your MCP client config."),process.exit(1));const M=new e({name:"analytics-engine",version:m,instructions:'# Barivia Mapping Analytics Engine\n\nSelf-organizing map (SOM) analytics: project high-dimensional data to a 2D grid for clusters, gradients, and anomalies.\n\n## Workflow (short)\n\nUpload (`datasets(upload)`) → `datasets(preview)` and `datasets(analyze)` before train → submit one of `train(action=map)`, `train(action=siom_map)`, or `train(action=floop_siom)` (only if plan allows FLooP) → poll `jobs(status)` every 10–15s until `completed` → `results(get)` for metrics and figures (there is no separate analyze tool), then `results_explorer(job_id)` to browse the figures interactively. Then `jobs(compare)`, `results(download/recolor)`, or `inference` (predict / compare / project_columns / impute_column / transition_flow) as needed.\n\n**Full detail:** Call `guide_barsom_workflow` for plan-scoped tool map, training modes, async rules, optional MCP App UIs, and step-by-step SOP (from the Barivia API when online).\n\n## Tool taxonomy (one tool per stage — do not mix)\n\n| Stage | Tool(s) | Contract |\n|-------|---------|----------|\n| Data | `datasets` | upload, preview, analyze, list, **get** (status/staging), subset, add_expression, reduce_spectral, delete — ingest + feature engineering, no map |\n| Train submit | `train` | map, siom_map, impute (sparse → map + dense imputed.csv), floop_siom (entitled). Async, returns job_id. Submit only |\n| Lifecycle | `jobs` | status, list, compare (**training runs**), cancel, delete. No training/scoring here |\n| Artifacts | `results` | get (default=combined only; figures="none" for metrics-only sweeps; figures="all" or results_explorer/download for every plot), export, download, recolor (async). Read/render completed jobs |\n| Frozen-map ops | `inference` | predict (regime-aware; "compact"|"annotated"), batch_predict, impute_column (neighbor-pool fill for a non-training column), compare (cohort density-diff), project_columns, transition_flow (time-ordered rows only), report. No weight updates |\n| Account | `account` | status, history, add_funds |\n| Bootstrap | `guide_barsom_workflow`, `train`, `training_guidance`, `prepare_training` prompt | orientation, submit map/siom/impute/floop, parameter hints, narrative checklist (API/tier-scoped) |\n| Explore (UI) | `results_explorer`, `training_monitor` | richer UX over the same API; `jobs(status)`+`results(get)` still suffice headless |\n| Other | `send_feedback` | only after user agrees |\n\n### Avoid these mis-routes\n- `jobs(compare)` = metrics table across **training runs** (pick best in a sweep). `inference(compare)` = density-diff of one map vs a **second dataset** (drift/cohort). Different tools.\n- Post-training map analysis (predict, compare, project_columns, impute_column, **transition_flow**) is all **inference** — not `results`. `results` is presentation/artifacts only (`recolor` stays here: it re-renders, it doesn\'t touch data).\n- Project a quantity onto the map: existing columns → `inference(project_columns)`; a formula → `datasets(add_expression, project_onto_job=<job>)`.\n\n## Async pattern\n\n- **Manual poll:** Training submits return `job_id` immediately — poll `jobs(status)` every 10–15s. **Running is not failed**; large grids or FLooP-SIOM can take many minutes. `max_nodes` (FLooP) is a total node budget, not grid side length.\n- **Often auto-polled:** `inference` actions (incl. `transition_flow`) and `results(recolor)` may wait in-proxy; if you get a `job_id`, poll `jobs(status)` the same way.\n\nCredits: jobs consume compute credits; check `account(status)` before big runs. Per-request HTTP timeout defaults to 60s (`datasets(analyze)` is async + auto-polled, so it is not bound by it); on slow networks raise `BARIVIA_FETCH_TIMEOUT_MS`. Timeouts are NOT auto-retried (avoids duplicating slow/expensive work).\n\n## Constraints\n\n- **Do not auto-subset:** Never call `datasets(subset)` or `sample_n` unless the user explicitly asks for a filter, slice, or random sample. Train and analyze on the uploaded `dataset_id` at full row count — GPU staging/prepare handles hundreds of thousands of rows quickly; silent downsampling hides real prepare/train behavior and wastes scale demos. Use small named fixtures (e.g. sample.csv, periodic_load) only when the user or runbook names them.\n- Prep ladder: `prepare_training` prompt = narrative checklist; `training_guidance` = structured hints (presets, resolved normalization/transforms, grid/epochs). Review these, then submit with `train`. Do not guess tiers or FLooP entitlement.\n- `inference(predict)`: prefer `dataset_id` for batch and for SIOM/irregular maps; single-row `rows` uses a fast path that can fail on some topologies — retry with `dataset_id`. FLooP-SIOM: if predict jobs fail while grid SIOM works, capture errors + `job_id`.\n- Column names are case-sensitive — match `datasets(preview)`.\n- Default training path is numeric/cyclic/temporal; use explicit `categorical_features` for baseline categoricals. `predict` must match the model contract.\n- After `results(recolor)`, `inference(transition_flow)`, or `inference(project_columns)`, use the **new** `job_id` returned for follow-up `results` if applicable.\n- **Large results / MCP timeouts:** For sweeps, `jobs(compare)`, or metrics-only reads use `results(action=get, figures="none")`. Default `get` inlines combined only. For every plot use `results_explorer` or `results(action=download)`, not `figures="all"` in chat unless the user wants all images inline.\n\n## UI delivery (heterogeneous MCP clients)\n\nHosts differ (Cursor, Claude Desktop, CLI agents): some embed MCP App panels; others list `ui://` but never mount them (expected on some Cursor builds); others are text-only. Tool results **always lead with a standalone localhost URL** — that is the intended fallback, not a broken App.\n\n| Tier | When | Agent should |\n|------|------|--------------|\n| **embedded** | Client advertises MCP Apps | Let the panel render **and** surface the standalone localhost URL from tool output / `ui_delivery.urls` (hosts often list Apps but do not mount). |\n| **localhost** | No MCP Apps; viz server running | Post the markdown link from tool output to the user (never broken workspace paths). Re-post after job completes if needed. |\n| **inline_image** | No Apps / override `inline` | Summarize metrics; inline raster (combined.png, learning curve) is attached automatically — show it in the reply. |\n| **text_only** | Viz server down | Use `jobs(status)` + `results(get)`; no panel or localhost link. |\n\nEvery `training_monitor` / `results_explorer` response includes a structured `ui_delivery` block (`delivery`, `urls`, `hint_for_agent`). Follow `hint_for_agent` without user prompting. Standalone viz pages cross-link monitor ↔ results for the same `job_id`.\n\n**Env overrides:** `BARIVIA_UI_DELIVERY=auto|apps|localhost|inline` (default `auto`). `BARIVIA_VIZ_PORT` pins the localhost viz port across proxy restarts. **Diagnostics:** `account(action=status)` reports detected MCP Apps support, viz port, and active tier.'});s(M,w,w,{mimeType:n},async()=>{const e=await c("results-explorer");return{contents:[{uri:w,mimeType:n,text:e??"<html><body>Results Explorer view not built yet. Run: npm run build:views</body></html>"}]}}),s(M,I,I,{mimeType:n},async()=>{const e=await c("training-monitor");return{contents:[{uri:I,mimeType:n,text:e??"<html><body>Training Monitor view not built yet.</body></html>"}]}}),x(M),y(M),A(M),f(M),g(M),h(M,_),b(M),v(M),j(M),k(M),P(M),M.prompt("info","Short orientation for the Barivia Mapping MCP. For full plan-scoped workflow, tool map, and SOP, the model should call guide_barsom_workflow. Use when the user asks what this MCP can do or how to get started.",{},()=>({messages:[{role:"user",content:{type:"text",text:["Give a concise, scannable answer (headers + bullets):","","**What it is:** MCP client to the Barivia mapping engine (2D SOM / SIOM / FLooP-SIOM when entitled) over HTTPS.","","**First step:** Call `guide_barsom_workflow` for plan-scoped bootstrap (full tool list, async rules, training modes, optional MCP Apps, SOP).","","**Core path:** `datasets(upload)` → `datasets(preview)` + `datasets(analyze)` → choose training action → poll `jobs(status)` every 10–15s until completed → `results(get)` (all main figures/metrics; no separate analyze tool) → `results_explorer(job_id)` to browse figures.","",'**Key tools:** `datasets` (data; reduce_spectral for spectra/long blocks), `train` (map/siom_map/impute/floop_siom; pass `label` for readable compare rows; preset=quick for a fast first map), `jobs` (status/list/compare/cancel/delete — lifecycle only), `results` (get/download/export/recolor; figures="none" for metrics-only), `inference` (predict; batch_predict; impute_column for topology-neighbor pool fill; compare; project_columns; transition_flow; report), `account` (status/credits/queue).',"","**Prep help:** `prepare_training` prompt (checklist) · `training_guidance` (presets/JSON hints + resolved normalization/params) — review, then submit with `train`.","","**Browse results:** `results_explorer(job_id)` is the preferred way to explore figures after a first `results(get)` glance; `training_monitor` shows live progress. Both optional — `results` + `jobs(status)` suffice headless.","","**After training:** `jobs(compare)` across runs, `results(recolor)`, `inference(project_columns)` for variables not in training, `inference(transition_flow)` only if rows are time-ordered.","","**Rules:** Running ≠ failed. Column names must match `datasets(preview)` exactly. Do not auto-subset — never call `datasets(subset)` / `sample_n` unless the user explicitly asks; train on the full uploaded `dataset_id`. Do not call `_fetch_figure` from chat (host/UI only); use `results(get)` or `results_explorer`.","","Offer `send_feedback` only after asking the user."].join("\n")}}]})),M.prompt("prepare_training","Narrative pre-training checklist (prompt). Use after upload and before train. Content is tier-scoped from the API when online. Prep ladder: this prompt = story checklist; training_guidance tool = JSON presets/parameter hints. Review, then submit with train.",{dataset_id:o.string().describe("Dataset ID to prepare for training")},async({dataset_id:e})=>{const t=await C(e);return{messages:[{role:"user",content:{type:"text",text:(t.used_fallback?`[offline fallback ${t.fallback_version}]\n\n`:"")+t.text}}]}});const T=new t;(async function(){try{const e=await a(l,p,c);u(e)}catch(e){process.env.BARIVIA_VIZ_PORT&&console.error("Barivia viz server failed to start:",e)}const e=M.server;e.oninitialized=()=>{const t=e.getClientCapabilities(),o=r(t);d(!!o?.mimeTypes?.includes(n))},await M.connect(T)})().catch(console.error);
@@ -2,6 +2,7 @@ import { apiCall } from "./shared.js";
2
2
  export const PREPARE_TRAINING_FALLBACK_VERSION = "2026.07-offline";
3
3
  const FALLBACK_LINES = [
4
4
  "Before upload: for train(action=map) ensure training columns have no NaNs; for train(action=impute) missing cells in training columns are OK (plain numeric only).",
5
+ "Do not auto-subset: never call datasets(subset) or sample_n unless the user explicitly asks — train on the full uploaded dataset_id.",
5
6
  'Please run datasets(action=preview, dataset_id="{id}") to inspect columns, then datasets(action=analyze, dataset_id="{id}") to see which columns and temporal periods are most informative.',
6
7
  "Choose the training path:",
7
8
  '- train(action=map, dataset_id="{id}", ...) for complete-case data',
package/dist/shared.js CHANGED
@@ -35,7 +35,7 @@ function newRequestId() {
35
35
  * X-Barsom-Client-Version so the server can annotate tool guidance with the
36
36
  * wrapper version each action requires. Keep in sync with package.json on bump.
37
37
  */
38
- export const CLIENT_VERSION = "0.22.2";
38
+ export const CLIENT_VERSION = "0.22.4";
39
39
  /** User-facing links; keep aligned with barivia.se / api.barivia.se. */
40
40
  export const PUBLIC_SITE_ORIGIN = "https://barivia.se";
41
41
  /** Self-serve account dashboard (manage plan, billing, and API keys). */
@@ -73,6 +73,51 @@ export const LARGE_UPLOAD_BYTES = 64 * 1024 * 1024; // 64 MB
73
73
  export const PRESIGNED_PUT_TIMEOUT_MS = 30 * 60_000; // 30 min
74
74
  /** Poll window for the async stage_dataset job (large files take minutes). */
75
75
  export const POLL_STAGE_MAX_MS = 30 * 60_000; // 30 min
76
+ export const BARSOM_VIZ_VIEWS = {
77
+ trainingMonitor: "training-monitor",
78
+ resultsExplorer: "results-explorer",
79
+ };
80
+ export function buildStandaloneVizPageUrl(port, viewPath, jobId) {
81
+ if (!port)
82
+ return undefined;
83
+ return `http://localhost:${port}/viz/${viewPath}?mode=standalone&job_id=${encodeURIComponent(jobId)}`;
84
+ }
85
+ export function buildVizRelatedUrls(port, jobId, views) {
86
+ if (!port)
87
+ return undefined;
88
+ return {
89
+ trainingMonitor: buildStandaloneVizPageUrl(port, views.trainingMonitor, jobId),
90
+ resultsExplorer: buildStandaloneVizPageUrl(port, views.resultsExplorer, jobId),
91
+ };
92
+ }
93
+ /** Prominent standalone URL blocks for tool results — always emitted when a viz port exists. */
94
+ export function buildVizUrlContentBlocks(options) {
95
+ const { label, toolName, standaloneUrl, port, jobId, clientSupportsMcpApps } = options;
96
+ if (!standaloneUrl)
97
+ return [];
98
+ const embedNote = clientSupportsMcpApps
99
+ ? "Embedded panel if your host supports MCP Apps — otherwise open the standalone URL below (expected on some Cursor builds)."
100
+ : "Open the standalone URL below when no inline panel appears.";
101
+ const health = port > 0
102
+ ? ` Health check: http://localhost:${port}/api/health?job_id=${encodeURIComponent(jobId)}`
103
+ : "";
104
+ const blocks = [
105
+ {
106
+ type: "text",
107
+ text: `${embedNote}\n` +
108
+ `Standalone ${label} URL (copy if Open is blocked):\n${standaloneUrl}\n` +
109
+ `AGENT: surface this URL prominently in your reply — use a markdown link "[Open ${label}](${standaloneUrl})" or tell the user to copy/paste into a browser. Do not bury it after metrics.\n` +
110
+ `This localhost port is per MCP session and goes stale if the proxy restarts — re-run ${toolName} for a fresh URL, or set BARIVIA_VIZ_PORT for a persistent port.${health}`,
111
+ },
112
+ ];
113
+ if (!clientSupportsMcpApps) {
114
+ blocks.push({
115
+ type: "text",
116
+ text: `[Open ${label}](${standaloneUrl})`,
117
+ });
118
+ }
119
+ return blocks;
120
+ }
76
121
  // ---------------------------------------------------------------------------
77
122
  // Shared state (mutable)
78
123
  // ---------------------------------------------------------------------------
@@ -860,12 +905,17 @@ export function getResultsImagesToFetch(jobType, summary, figures, includeIndivi
860
905
  }
861
906
  // train_som / train_siom
862
907
  const features = summary.features ?? [];
863
- const combinedName = `combined.${ext}`;
864
- const umatrixName = `umatrix.${ext}`;
865
- const hitHistName = `hit_histogram.${ext}`;
866
- const correlationName = `correlation.${ext}`;
908
+ const summaryFiles = summary.files ?? [];
909
+ const imageFilesFromSummary = summaryFiles.filter((f) => /\.(png|pdf|svg)$/i.test(f));
910
+ const combinedName = imageFilesFromSummary.find((f) => f.startsWith("combined.")) ?? `combined.${ext}`;
911
+ const umatrixName = imageFilesFromSummary.find((f) => f.startsWith("umatrix.")) ?? `umatrix.${ext}`;
912
+ const hitHistName = imageFilesFromSummary.find((f) => f.startsWith("hit_histogram.")) ?? `hit_histogram.${ext}`;
913
+ const correlationName = imageFilesFromSummary.find((f) => f.startsWith("correlation.")) ?? `correlation.${ext}`;
867
914
  const componentNames = features.map((f, i) => `component_${i + 1}_${f.replace(/[^a-zA-Z0-9_]/g, "_")}.${ext}`);
868
- const allList = [combinedName, umatrixName, hitHistName, correlationName, ...componentNames];
915
+ const publishedComponents = imageFilesFromSummary.filter((f) => /^component_\d+_/.test(f));
916
+ const allList = imageFilesFromSummary.length > 0
917
+ ? imageFilesFromSummary
918
+ : [combinedName, umatrixName, hitHistName, correlationName, ...componentNames];
869
919
  if (figures === undefined || figures === "default") {
870
920
  return includeIndividual ? allList : [combinedName];
871
921
  }
@@ -880,8 +930,13 @@ export function getResultsImagesToFetch(jobType, summary, figures, includeIndivi
880
930
  hit_histogram: hitHistName,
881
931
  correlation: correlationName,
882
932
  };
933
+ for (const file of imageFilesFromSummary) {
934
+ const key = file.replace(/\.(png|pdf|svg)$/i, "");
935
+ nameToFile[key] = file;
936
+ }
883
937
  features.forEach((_, i) => {
884
- nameToFile[`component_${i + 1}`] = componentNames[i];
938
+ const published = publishedComponents.find((f) => f.startsWith(`component_${i + 1}_`));
939
+ nameToFile[`component_${i + 1}`] = published ?? componentNames[i];
885
940
  });
886
941
  return figures
887
942
  .map((key) => {
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { registerAuditedTool } from "../audit.js";
3
- import { apiCall } from "../shared.js";
3
+ import { apiCall, CLIENT_VERSION } from "../shared.js";
4
+ import { getMcpClientDiagnostics } from "../ui-delivery.js";
4
5
  export function registerAccountTool(server) {
5
6
  registerAuditedTool(server, "account", `Manage your Barivia account — check plan/license info, view usage history, add funds.
6
7
 
@@ -80,6 +81,11 @@ NOT FOR: Training itself — use train(action=map). This tool only manages the a
80
81
  lines.push(` ${k}: ~${v}s`);
81
82
  }
82
83
  }
84
+ const mcpDiag = getMcpClientDiagnostics();
85
+ lines.push(``, `MCP client (local proxy @ ${CLIENT_VERSION}):`, ` MCP Apps detected: ${mcpDiag.mcp_apps_supported ? "yes" : "no"}`, ` Viz port: ${mcpDiag.viz_port ?? "none"}${mcpDiag.viz_port_pinned ? " (BARIVIA_VIZ_PORT pinned)" : " (ephemeral — set BARIVIA_VIZ_PORT for stable URLs)"}`, ` UI delivery override: ${mcpDiag.ui_delivery_override}`, ` Active delivery tier: ${mcpDiag.active_delivery_tier}`);
86
+ if (mcpDiag.health_url) {
87
+ lines.push(` Viz health: ${mcpDiag.health_url}`);
88
+ }
83
89
  return { content: [{ type: "text", text: lines.join("\n") }] };
84
90
  }
85
91
  if (action === "history") {
@@ -139,7 +139,7 @@ Formats: plain CSV/TSV or gzip (.csv.gz / .tsv.gz). For files above ~100 MB, pre
139
139
  | analyze | Pre-training column analysis: correlation matrix, periodicity ranking, column recommendations (train / drop / project later). Run after preview, before train_map. |
140
140
  | list | Finding dataset IDs for train_map, preview, or subset — see all available datasets |
141
141
  | get | Fetch one dataset by id — status, staging fields, ingest_error (use after upload or when staging is slow) |
142
- | subset | Creating a filtered/sliced view without re-uploading the full CSV |
142
+ | subset | ONLY when the user asks for row_range, filters, or sample_n — never auto-shrink before train/analyze |
143
143
  | add_expression | Add a computed column from an expression (dataset_id + name + expression). With project_onto_job=<training_job_id>, instead projects the computed expression onto that map as a component plane in one step (no column added). |
144
144
  | reduce_spectral | Collapse a long ordered numeric block (e.g. 1000-point spectrum, time series, sensor fingerprint) into a small per-row feature set so the SOM can train on signal-dense features. Methods: pca, log_sample, uniform_sample, stats. |
145
145
  | delete | Cleaning up after experiments or freeing the dataset slot |
@@ -153,13 +153,15 @@ action=preview: Show columns, stats, sample rows, cyclic/datetime detections. AL
153
153
  action=analyze: Pre-training analysis on numeric columns — Pearson correlation, autocorrelation periodicity scores, and column recommendations (train, consider_dropping, project_later, low_variance). Use to choose which columns to include in training and which to project onto the map after training.
154
154
  action=list: List all datasets belonging to the organisation with id, name, rows, cols, created_at (use created_at or id to distinguish datasets with the same name).
155
155
  action=get: Fetch one dataset by dataset_id — returns status, staged_prefix, staged_version, ingest_error, and stage_job_id when staging is pending.
156
+ DO NOT AUTO-SUBSET: Call action=subset only when the user explicitly requests a filter, slice, or random sample. Default path is upload → preview/analyze → train on the full dataset_id (GPU handles scale). Never downsample to "save time" or "be helpful".
157
+
156
158
  action=subset: Create a new dataset from a subset of an existing one. Requires name and at least one of row_range, filters, or sample_n.
157
159
  - row_range: [start, end] 1-based inclusive (e.g. [1, 2000] for first 2000 rows)
158
160
  - filters: array of conditions, ALL must match (AND logic). Each: { column, op, value }.
159
161
  Operators: eq, ne, in, gt, lt, gte, lte, between
160
162
  Examples: { column: "region", op: "eq", value: "Europe" } | { column: "age", op: "between", value: [18, 65] }
161
- - sample_n: keep a random N-row sample (seeded via sample_seed, default 42; row order preserved). Shrink a huge table server-side instead of pre-sampling locally. Applied after row_range/filters; can also be used alone.
162
- - Combine row_range + filters + sample_n to slice rows, values, then downsample.
163
+ - sample_n: keep a random N-row sample (seeded via sample_seed, default 42; row order preserved). User/ops tool only not an agent default. Applied after row_range/filters; can also be used alone.
164
+ - Combine row_range + filters + sample_n to slice rows, values, then downsample when the user asked for it.
163
165
  - Single filter object is also accepted (auto-wrapped).
164
166
  action=reduce_spectral: Run a pre-training reducer over an ordered block of numeric columns. All four methods produce one feature vector per row (rows in = rows out; only the column dimension is collapsed) and append derived columns to the dataset. Choose by data shape:
165
167
  - pca: top-k principal components — general first try when many columns are correlated (spectroscopy, gene panels, sensor arrays). Returns explained_variance_ratio.
@@ -258,7 +260,7 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
258
260
  .int()
259
261
  .min(1)
260
262
  .optional()
261
- .describe("action=subset: keep a random N-row sample (seeded, row order preserved). Use to shrink a huge table server-side instead of pre-sampling locally. Combine with row_range/filters, or use alone."),
263
+ .describe("action=subset: keep a random N-row sample (seeded, row order preserved). Only when the user explicitly requests downsampling not an agent default. Combine with row_range/filters, or use alone."),
262
264
  sample_seed: z
263
265
  .number()
264
266
  .int()
@@ -489,7 +491,7 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
489
491
  job_id: jobId,
490
492
  request_id: rid ?? null,
491
493
  message: errText,
492
- suggested_next_step: "If the dataset is very large, try datasets(action=subset, sample_n=10000) then analyze again.",
494
+ suggested_next_step: "Do not auto-subset. Report the error to the user; full-dataset analyze is supported at scale. Use datasets(subset) only if the user explicitly asks for downsampling.",
493
495
  }, errText + (rid ? ` (request_id=${rid})` : ""));
494
496
  }
495
497
  if (poll.status !== "completed") {
@@ -505,7 +507,7 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
505
507
  }
506
508
  else {
507
509
  const msg = err instanceof Error ? err.message : String(err);
508
- throw new Error(`datasets(analyze) failed: ${msg}. If the dataset is very large, try datasets(action=subset, sample_n=10000) first.`);
510
+ throw new Error(`datasets(analyze) failed: ${msg}. Do not auto-subset use datasets(subset) only if the user explicitly asked.`);
509
511
  }
510
512
  }
511
513
  return { content: [{ type: "text", text: formatAnalyzeResult(data, dataset_id) }] };
@@ -1,7 +1,9 @@
1
1
  import { z } from "zod";
2
2
  import { registerAppTool } from "@modelcontextprotocol/ext-apps/server";
3
3
  import { registerAuditedTool, runMcpToolAudit } from "../audit.js";
4
- import { apiCall, apiRawCall, getClientSupportsMcpApps, getVizPort, getCaptionForImage, getResultsImagesToFetch, mimeForFilename, structuredTextResult, tryAttachImage, } from "../shared.js";
4
+ import { apiCall, apiRawCall, BARSOM_VIZ_VIEWS, buildStandaloneVizPageUrl, buildVizRelatedUrls, getVizPort, getCaptionForImage, getResultsImagesToFetch, mimeForFilename, structuredTextResult, tryAttachImage, } from "../shared.js";
5
+ import { buildVizStandaloneUrl } from "../viz_links.js";
6
+ import { appendUiDeliveryContent, beginInlineFallback, buildStandaloneVizUrl, resolveUiDelivery, } from "../ui-delivery.js";
5
7
  export const RESULTS_EXPLORER_URI = "ui://barsom/results-explorer";
6
8
  function labelForFigure(filename) {
7
9
  const base = filename.replace(/\.(png|pdf|svg)$/i, "");
@@ -111,6 +113,10 @@ export function buildPayload(jobId, data) {
111
113
  summary.selected_columns ? `Variables: ${summary.selected_columns.join(", ")}` : "",
112
114
  ].filter(Boolean);
113
115
  const port = getVizPort();
116
+ const standaloneUrl = buildStandaloneVizPageUrl(port, BARSOM_VIZ_VIEWS.resultsExplorer, jobId) ??
117
+ buildVizStandaloneUrl("results-explorer", jobId);
118
+ const trainingMonitorUrl = buildStandaloneVizPageUrl(port, BARSOM_VIZ_VIEWS.trainingMonitor, jobId) ??
119
+ buildVizStandaloneUrl("training-monitor", jobId);
114
120
  return {
115
121
  type: "results-explorer",
116
122
  jobId,
@@ -123,42 +129,55 @@ export function buildPayload(jobId, data) {
123
129
  availableFigures: figures,
124
130
  defaultFigureKey: figures.find((figure) => figure.key === "combined")?.key
125
131
  ?? figures[0]?.key,
126
- standaloneUrl: port ? `http://localhost:${port}/viz/results-explorer?mode=standalone&job_id=${jobId}` : undefined,
132
+ standaloneUrl,
133
+ trainingMonitorUrl,
134
+ relatedUrls: buildVizRelatedUrls(port, jobId, BARSOM_VIZ_VIEWS),
127
135
  };
128
136
  }
129
137
  async function handleResultsExplorer(job_id) {
138
+ beginInlineFallback();
130
139
  const data = (await apiCall("GET", `/v1/results/${job_id}`));
131
140
  const payload = buildPayload(job_id, data);
132
141
  const summary = (data.summary ?? {});
133
- const content = [
134
- {
135
- type: "text",
136
- text: [
137
- `Results Explorer ready for job ${job_id}.`,
138
- payload.gridLabel ? `Grid: ${payload.gridLabel}` : "",
139
- payload.subtitle ? `Label: ${payload.subtitle}` : "",
140
- payload.highlights[0] ?? "",
141
- ].filter(Boolean).join("\n"),
142
- },
143
- ];
142
+ const summaryText = [
143
+ `Results Explorer ready for job ${job_id}.`,
144
+ payload.gridLabel ? `Grid: ${payload.gridLabel}` : "",
145
+ payload.subtitle ? `Label: ${payload.subtitle}` : "",
146
+ payload.highlights[0] ?? "",
147
+ ].filter(Boolean).join("\n");
148
+ const content = [{ type: "text", text: summaryText }];
144
149
  const defaultFigure = payload.availableFigures.find((figure) => figure.key === payload.defaultFigureKey);
150
+ let inlineImageAttached = false;
145
151
  if (defaultFigure) {
152
+ const before = content.length;
146
153
  await tryAttachImage(content, job_id, defaultFigure.filename);
154
+ inlineImageAttached = content.length > before && content.some((c) => c.type === "image");
147
155
  }
148
156
  else {
149
157
  const ext = summary.output_format ?? "png";
158
+ const before = content.length;
150
159
  await tryAttachImage(content, job_id, `combined.${ext}`);
160
+ inlineImageAttached = content.length > before && content.some((c) => c.type === "image");
151
161
  }
152
- if (!getClientSupportsMcpApps() && payload.standaloneUrl) {
162
+ const uiDelivery = resolveUiDelivery({
163
+ tool: "results_explorer",
164
+ jobId: job_id,
165
+ jobStatus: "completed",
166
+ inlineImageAttached,
167
+ });
168
+ appendUiDeliveryContent(content, uiDelivery, {
169
+ linkLabel: "Open results explorer",
170
+ primaryUrl: payload.standaloneUrl ?? buildStandaloneVizUrl("results_explorer", job_id),
171
+ });
172
+ const monitorUrl = payload.trainingMonitorUrl ?? buildStandaloneVizUrl("training_monitor", job_id);
173
+ if (monitorUrl) {
153
174
  content.push({
154
175
  type: "text",
155
- text: `Interactive results explorer: [Open results explorer](${payload.standaloneUrl})\n` +
156
- `AGENT: surface this to the user as a clickable markdown link (e.g. "[Open results explorer](${payload.standaloneUrl})") in your reply — do not leave it buried in tool output. ` +
157
- `This localhost port is assigned per MCP session and changes if the proxy restarts — if the page won't load, re-run results_explorer for a fresh URL, or set BARIVIA_VIZ_PORT in your MCP env for a persistent port.`,
176
+ text: `Review training curves: [Open training monitor](${monitorUrl})`,
158
177
  });
159
178
  }
160
179
  return {
161
- ...structuredTextResult(payload, undefined, content),
180
+ ...structuredTextResult({ ...payload, ui_delivery: uiDelivery }, undefined, content),
162
181
  _meta: { ui: { resourceUri: RESULTS_EXPLORER_URI } },
163
182
  };
164
183
  }
@@ -19,7 +19,7 @@ ONLY call this after jobs(action=status) returns "completed".
19
19
  ESCALATION: If job not found, verify job_id. If "job not complete", poll with jobs(action=status).
20
20
 
21
21
  action=get: Returns text summary with quality metrics and inline images.
22
- - figures: omit = combined only. "all" = all plots. "none" = metrics text only, no images (recommended for sweeps and LLM clients to keep tool payloads small). Array = specific logical names (combined, umatrix, hit_histogram, learning_curve, correlation, component_1..N).
22
+ - figures: omit = combined only. "all" = all published plots (excludes unpublished cyclic cos/sin expansions unless train used viz_upload_cyclic_components=true). "none" = metrics text only, no images (recommended for sweeps and LLM clients to keep tool payloads small). Array = specific logical names (combined, umatrix, hit_histogram, learning_curve, correlation, component_1..N). Request unpublished cyclic planes via results(recolor, figures=[component_N, ...]).
23
23
  - include_individual: if true (and figures omitted), inlines every component plane.
24
24
  - After recolor or project, use the job_id returned by that operation to get the new artifacts, not the original training job_id.
25
25
  - After showing results, guide the user: QE interpretation, whether to retrain, which features to explore.
@@ -574,14 +574,20 @@ NOT FOR: Jobs that haven't completed (use jobs(action=status) first), or tempora
574
574
  throw new Error("results(download) requires folder");
575
575
  const data = (await apiCall("GET", `/v1/results/${job_id}`));
576
576
  const summary = (data.summary ?? {});
577
+ const downloadUrls = (data.download_urls ?? {});
577
578
  const jobLabel = data.label != null && data.label !== "" ? String(data.label) : null;
578
579
  const files = summary.files ?? [];
579
580
  const jobType = summary.job_type ?? "train_som";
580
581
  const needsAllFiles = ["enrich_dataset", "predict", "impute_column", "compare_datasets"].includes(jobType);
581
582
  const isImage = (f) => f.endsWith(".png") || f.endsWith(".svg") || f.endsWith(".pdf");
583
+ const hasUrlIndex = Object.keys(downloadUrls).length > 0;
584
+ const isPublished = (f) => !hasUrlIndex || f in downloadUrls;
582
585
  let toDownload;
583
586
  if (figures === "all" || figures === "images" || figures === undefined) {
584
587
  toDownload = needsAllFiles || include_json ? files : files.filter(isImage);
588
+ if (hasUrlIndex) {
589
+ toDownload = toDownload.filter((f) => !isImage(f) || isPublished(f));
590
+ }
585
591
  }
586
592
  else if (Array.isArray(figures)) {
587
593
  toDownload = figures;
@@ -1,7 +1,8 @@
1
1
  import { z } from "zod";
2
2
  import { registerAuditedTool } from "../audit.js";
3
- import { apiCall, textResult } from "../shared.js";
3
+ import { apiCall } from "../shared.js";
4
4
  import { buildTrainSubmitExtras } from "../train_submit_message.js";
5
+ import { TRAINING_MONITOR_URI, buildTrainingMonitorResult, } from "./training_monitor.js";
5
6
  import { TRAINING_INPUT_SCHEMA, buildTrainMapParams, buildFloopParams, fetchTrainingPresets, assertValidNormalizationArgs, } from "./training_core.js";
6
7
  export const TRAIN_DESCRIPTION = `Submit a self-organizing map training job. Returns a job_id; poll with jobs(action=status) and then results(action=get). All actions are async.
7
8
 
@@ -89,7 +90,12 @@ async function submitTrainJob(opts) {
89
90
  }
90
91
  data.message = msg;
91
92
  data.suggested_next_step = extras.suggested_next_step;
92
- return textResult(data);
93
+ const monitor = await buildTrainingMonitorResult(newJobId, { preamble: msg });
94
+ return {
95
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }, ...monitor.content],
96
+ structuredContent: monitor.structuredContent,
97
+ _meta: { ui: { resourceUri: TRAINING_MONITOR_URI } },
98
+ };
93
99
  }
94
100
  /**
95
101
  * Core training dispatcher. Shared by the `train` tool and the deprecated
@@ -98,7 +104,7 @@ async function submitTrainJob(opts) {
98
104
  export async function runTrain(action, args) {
99
105
  const dataset_id = args.dataset_id;
100
106
  if (action === "map" || action === "siom_map" || action === "impute") {
101
- const { preset, grid_x, grid_y, epochs, model, periodic, columns, cyclic_features, cyclic_pairs, temporal_features, feature_weights, transforms, auto_log_transforms, time_delay_embeddings, categorical_features, normalize, normalization_methods, sigma_f, learning_rate, batch_size, quality_metrics, backend, output_format, output_dpi, colormap, row_range, gamma, gamma_f, siom_decay, siom_penalty, penalty_alpha, reset_per_epoch, siom_feature_geometry, siom_qe_backend, siom_qe_batch_size, label, cv_folds, viz_mode, viz_top_components, emit_cell_uncertainty, } = args;
107
+ const { preset, grid_x, grid_y, epochs, model, periodic, columns, cyclic_features, cyclic_pairs, temporal_features, feature_weights, transforms, auto_log_transforms, time_delay_embeddings, categorical_features, normalize, normalization_methods, sigma_f, learning_rate, batch_size, quality_metrics, backend, output_format, output_dpi, colormap, row_range, gamma, gamma_f, siom_decay, siom_penalty, penalty_alpha, reset_per_epoch, siom_feature_geometry, siom_qe_backend, siom_qe_batch_size, label, cv_folds, viz_mode, viz_top_components, viz_upload_cyclic_components, emit_cell_uncertainty, } = args;
102
108
  let PRESETS = {};
103
109
  try {
104
110
  PRESETS = await fetchTrainingPresets();
@@ -123,7 +129,8 @@ export async function runTrain(action, args) {
123
129
  temporal_features, feature_weights, transforms, auto_log_transforms,
124
130
  time_delay_embeddings, categorical_features,
125
131
  normalize, normalization_methods, sigma_f, learning_rate, batch_size, quality_metrics, backend,
126
- output_format, output_dpi, colormap, row_range,
132
+ output_format, output_dpi, colormap, row_range, siom_feature_geometry, siom_qe_backend, siom_qe_batch_size,
133
+ viz_upload_cyclic_components,
127
134
  }, PRESETS);
128
135
  if (action === "impute") {
129
136
  params._job_type = "train_impute";
@@ -113,6 +113,8 @@ export const TRAINING_INPUT_SCHEMA = {
113
113
  .describe("Visualization density; auto-capped when feature count > 40"),
114
114
  viz_top_components: z.number().int().min(0).max(64).optional()
115
115
  .describe("With viz_mode=summary_plus_top: upload top-N component maps by variance (default 8)"),
116
+ viz_upload_cyclic_components: z.boolean().optional()
117
+ .describe("When true with individual component PNGs, also publish cos/sin cyclic expansion planes (default false; recovered hour/month panels stay in combined.png)"),
116
118
  emit_cell_uncertainty: z.boolean().optional()
117
119
  .describe("impute: write imputation_uncertainty.csv (pool_std = prototype-neighbourhood dispersion, not calibrated SD; pool_n = contributing nodes)"),
118
120
  gamma: z.preprocess((v) => (v !== undefined && v !== null && typeof v === "string") ? parseFloat(v) : v, z.number().optional())
@@ -245,7 +247,7 @@ export function buildFloopParams(args) {
245
247
  return { params, paramSummary };
246
248
  }
247
249
  export function buildTrainMapParams(args, presets) {
248
- const { preset, grid_x, grid_y, epochs, model, periodic, columns, cyclic_features, cyclic_pairs, temporal_features, feature_weights, transforms, auto_log_transforms, time_delay_embeddings, categorical_features, normalize, normalization_methods, sigma_f, learning_rate, batch_size, quality_metrics, backend, output_format, output_dpi, colormap, row_range, siom_feature_geometry, siom_qe_backend, siom_qe_batch_size, } = args;
250
+ const { preset, grid_x, grid_y, epochs, model, periodic, columns, cyclic_features, cyclic_pairs, temporal_features, feature_weights, transforms, auto_log_transforms, time_delay_embeddings, categorical_features, normalize, normalization_methods, sigma_f, learning_rate, batch_size, quality_metrics, backend, output_format, output_dpi, colormap, row_range, siom_feature_geometry, siom_qe_backend, siom_qe_batch_size, viz_upload_cyclic_components, } = args;
249
251
  const p = preset ? presets[preset] : undefined;
250
252
  const params = {
251
253
  model: model ?? "SOM",
@@ -308,6 +310,9 @@ export function buildTrainMapParams(args, presets) {
308
310
  params.siom_qe_backend = siom_qe_backend;
309
311
  if (siom_qe_batch_size !== undefined)
310
312
  params.siom_qe_batch_size = siom_qe_batch_size;
313
+ if (viz_upload_cyclic_components !== undefined) {
314
+ params.viz_upload_cyclic_components = viz_upload_cyclic_components;
315
+ }
311
316
  const effectiveGrid = params.grid;
312
317
  const effectiveEpochs = params.epochs;
313
318
  const effectiveBatch = params.batch_size;
@@ -1,8 +1,10 @@
1
1
  import { z } from "zod";
2
2
  import { registerAppTool } from "@modelcontextprotocol/ext-apps/server";
3
3
  import { runMcpToolAudit } from "../audit.js";
4
- import { apiCall, getClientSupportsMcpApps, getVizPort, structuredTextResult, } from "../shared.js";
4
+ import { apiCall, BARSOM_VIZ_VIEWS, buildStandaloneVizPageUrl, buildVizRelatedUrls, getVizPort, structuredTextResult, } from "../shared.js";
5
+ import { appendUiDeliveryContent, beginInlineFallback, resolveUiDelivery, tryAttachLearningCurve, } from "../ui-delivery.js";
5
6
  import { lastEpochTeFromCurves } from "../training_monitor_curve.js";
7
+ import { buildVizStandaloneUrl, resultsExplorerStandaloneLinkText, } from "../viz_links.js";
6
8
  export const TRAINING_MONITOR_URI = "ui://barsom/training-monitor";
7
9
  export const TRAINING_MONITOR_REFRESH_MS = 5000;
8
10
  function hasCurveArrays(data) {
@@ -14,8 +16,12 @@ function hasCurveArrays(data) {
14
16
  ];
15
17
  return keys.some((k) => Array.isArray(data[k]) && data[k].length > 0);
16
18
  }
17
- function buildStructuredContent(job_id, data) {
19
+ function buildStructuredContent(job_id, data, port = getVizPort()) {
18
20
  const id = String(data.id ?? job_id);
21
+ const standaloneUrl = buildStandaloneVizPageUrl(port, BARSOM_VIZ_VIEWS.trainingMonitor, id) ??
22
+ buildVizStandaloneUrl("training-monitor", id);
23
+ const resultsExplorerUrl = buildStandaloneVizPageUrl(port, BARSOM_VIZ_VIEWS.resultsExplorer, id) ??
24
+ buildVizStandaloneUrl("results-explorer", id);
19
25
  return {
20
26
  ...data,
21
27
  type: "training-monitor",
@@ -23,6 +29,9 @@ function buildStructuredContent(job_id, data) {
23
29
  id,
24
30
  job_id: id,
25
31
  refresh_interval_ms: TRAINING_MONITOR_REFRESH_MS,
32
+ standaloneUrl,
33
+ resultsExplorerUrl,
34
+ relatedUrls: buildVizRelatedUrls(port, id, BARSOM_VIZ_VIEWS),
26
35
  };
27
36
  }
28
37
  function hasTeCurveArrays(data) {
@@ -81,6 +90,71 @@ async function enrichWithTrainingLog(job_id, data) {
81
90
  return data;
82
91
  }
83
92
  }
93
+ export async function buildTrainingMonitorResult(job_id, opts) {
94
+ const { fetch_training_log, preamble } = opts ?? {};
95
+ let data = (await apiCall("GET", `/v1/jobs/${job_id}`));
96
+ const jobStatus = String(data.status ?? "");
97
+ if (fetch_training_log || needsTrainingLogEnrichment(data)) {
98
+ data = await enrichWithTrainingLog(job_id, data);
99
+ }
100
+ const port = getVizPort();
101
+ const structuredContent = buildStructuredContent(job_id, data, port);
102
+ const status = jobStatus;
103
+ const progress = (data.progress ?? 0) * 100;
104
+ const etaSec = data.training_eta_sec != null ? Number(data.training_eta_sec) : null;
105
+ const elapsedSec = data.training_elapsed_sec != null ? Number(data.training_elapsed_sec) : null;
106
+ const epoch = data.epoch != null ? Number(data.epoch) : null;
107
+ const totalEpochs = data.total_epochs != null ? Number(data.total_epochs) : null;
108
+ const timingParts = [];
109
+ if (elapsedSec != null && elapsedSec >= 0)
110
+ timingParts.push(`elapsed ${Math.round(elapsedSec)}s`);
111
+ if (etaSec != null && etaSec > 0)
112
+ timingParts.push(`ETA ~${Math.round(etaSec)}s`);
113
+ if (epoch != null && totalEpochs != null)
114
+ timingParts.push(`epoch ${epoch}/${totalEpochs}`);
115
+ const timingNote = timingParts.length > 0 ? ` ${timingParts.join(", ")}.` : "";
116
+ const text = (preamble ? `${preamble}\n\n` : "") +
117
+ `Training monitor (live when embedded, refreshes every ${TRAINING_MONITOR_REFRESH_MS / 1000}s): job ${job_id} — ${status} (${progress.toFixed(1)}%).${timingNote} ` +
118
+ `Optional UI; jobs(action=status) is enough to finish the workflow headless.`;
119
+ const content = [{ type: "text", text }];
120
+ const terminal = status === "completed" || status === "failed" || status === "cancelled";
121
+ let inlineImageAttached = false;
122
+ if (terminal) {
123
+ inlineImageAttached = await tryAttachLearningCurve(content, job_id);
124
+ }
125
+ const uiDelivery = resolveUiDelivery({
126
+ tool: "training_monitor",
127
+ jobId: job_id,
128
+ jobStatus: status,
129
+ inlineImageAttached,
130
+ });
131
+ appendUiDeliveryContent(content, uiDelivery, {
132
+ linkLabel: "Open training monitor",
133
+ primaryUrl: structuredContent.standaloneUrl,
134
+ });
135
+ if (status === "completed" && uiDelivery.urls?.length) {
136
+ const resultsUrl = uiDelivery.urls.find((u) => u.includes("results-explorer"));
137
+ if (resultsUrl) {
138
+ content.push({
139
+ type: "text",
140
+ text: `Job completed — browse map figures: [Open results explorer](${resultsUrl})`,
141
+ });
142
+ }
143
+ }
144
+ structuredContent.ui_delivery = uiDelivery;
145
+ return { structuredContent, content, text };
146
+ }
147
+ /** After terminal + finalize, append results explorer open hint (monitor completion path). */
148
+ export function appendResultsExplorerCompletionHint(content, job_id) {
149
+ const link = resultsExplorerStandaloneLinkText(job_id);
150
+ if (link) {
151
+ content.push({
152
+ type: "text",
153
+ text: `${link}\n` +
154
+ `AGENT: when the job is fully complete (finalize done if any), call results_explorer(job_id="${job_id}") in the same turn and surface this link — do not wait for the user to ask.`,
155
+ });
156
+ }
157
+ }
84
158
  export function registerTrainingMonitorTool(server) {
85
159
  registerAppTool(server, "training_monitor", {
86
160
  title: "Training Monitor",
@@ -94,42 +168,11 @@ export function registerTrainingMonitorTool(server) {
94
168
  },
95
169
  _meta: { ui: { resourceUri: TRAINING_MONITOR_URI } },
96
170
  }, async (args) => runMcpToolAudit("training_monitor", "default", args, async () => {
171
+ beginInlineFallback();
97
172
  const { job_id, fetch_training_log } = args;
98
- let data = (await apiCall("GET", `/v1/jobs/${job_id}`));
99
- const jobStatus = String(data.status ?? "");
100
- if (fetch_training_log || needsTrainingLogEnrichment(data)) {
101
- data = await enrichWithTrainingLog(job_id, data);
102
- }
103
- const structuredContent = buildStructuredContent(job_id, data);
104
- const status = jobStatus;
105
- const progress = (data.progress ?? 0) * 100;
106
- const etaSec = data.training_eta_sec != null ? Number(data.training_eta_sec) : null;
107
- const elapsedSec = data.training_elapsed_sec != null ? Number(data.training_elapsed_sec) : null;
108
- const epoch = data.epoch != null ? Number(data.epoch) : null;
109
- const totalEpochs = data.total_epochs != null ? Number(data.total_epochs) : null;
110
- const timingParts = [];
111
- if (elapsedSec != null && elapsedSec >= 0)
112
- timingParts.push(`elapsed ${Math.round(elapsedSec)}s`);
113
- if (etaSec != null && etaSec > 0)
114
- timingParts.push(`ETA ~${Math.round(etaSec)}s`);
115
- if (epoch != null && totalEpochs != null)
116
- timingParts.push(`epoch ${epoch}/${totalEpochs}`);
117
- const timingNote = timingParts.length > 0 ? ` ${timingParts.join(", ")}.` : "";
118
- const text = `Training monitor (live when embedded, refreshes every ${TRAINING_MONITOR_REFRESH_MS / 1000}s): job ${job_id} — ${status} (${progress.toFixed(1)}%).${timingNote} ` +
119
- `Optional UI; jobs(action=status) is enough to finish the workflow.`;
120
- const content = [{ type: "text", text }];
121
- const port = getVizPort();
122
- const standaloneUrl = port
123
- ? `http://localhost:${port}/viz/training-monitor?mode=standalone&job_id=${encodeURIComponent(job_id)}`
124
- : undefined;
125
- if (!getClientSupportsMcpApps() && standaloneUrl) {
126
- content.push({
127
- type: "text",
128
- text: `Live training monitor: [Open training monitor](${standaloneUrl})\n` +
129
- `AGENT: surface this to the user as a clickable markdown link (e.g. "[Open training monitor](${standaloneUrl})") in your reply — do not leave it buried in tool output. Re-post the link after the job completes so the user can return to the curves.\n` +
130
- `This localhost port is assigned per MCP session and changes if the proxy restarts — if the page stays at "running 0%" or stops updating, the link is stale: re-run training_monitor for a fresh URL, or set BARIVIA_VIZ_PORT in your MCP env for a persistent port. Health check: http://localhost:${port}/api/health?job_id=${encodeURIComponent(job_id)}`,
131
- });
132
- }
173
+ const { structuredContent, content, text } = await buildTrainingMonitorResult(job_id, {
174
+ fetch_training_log,
175
+ });
133
176
  return {
134
177
  ...structuredTextResult(structuredContent, text, content),
135
178
  _meta: { ui: { resourceUri: TRAINING_MONITOR_URI } },
@@ -1,17 +1,15 @@
1
- import { getVizPort } from "./shared.js";
1
+ import { buildVizStandaloneUrl } from "./viz_links.js";
2
2
  export function buildTrainSubmitExtras(opts) {
3
3
  const { newJobId, totalRows, hasTransforms, prepareJobId, variantPrefix, paramSummary, impute, resultsHint } = opts;
4
- const monitorUrl = getVizPort() > 0
5
- ? `http://localhost:${getVizPort()}/viz/training-monitor?mode=standalone&job_id=${encodeURIComponent(newJobId)}`
6
- : null;
4
+ const monitorUrl = buildVizStandaloneUrl("training-monitor", newJobId);
7
5
  let msg = `Job submitted (${variantPrefix}, ${paramSummary}). `;
8
6
  if (prepareJobId) {
9
7
  msg += `Preprocessing job prepare_training_matrix (${prepareJobId}) runs on worker-io first; train job ${newJobId} starts after it completes. Poll jobs(action=status, job_id="${prepareJobId}") until completed, then poll the train job. `;
10
8
  }
11
- msg += `Recommended: training_monitor(job_id="${newJobId}")`;
9
+ msg += `Training monitor opened for job ${newJobId}`;
12
10
  if (monitorUrl)
13
- msg += ` or open ${monitorUrl}`;
14
- msg += ` while polling jobs(action=status, job_id="${newJobId}") every 60–120s for large jobs. `;
11
+ msg += ` standalone: ${monitorUrl}`;
12
+ msg += `. Poll jobs(action=status, job_id="${newJobId}") every 60–120s for large jobs if needed. `;
15
13
  msg += `When status is completed, check jobs(action=status) for finalize_job_id — if present, poll that finalize_training job before results(action=get). `;
16
14
  msg += `Then use results(action=get, job_id="${newJobId}")`;
17
15
  if (resultsHint) {