@barivia/barsom-mcp 0.23.10 → 0.23.11

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 CHANGED
@@ -1,8 +1,8 @@
1
1
  # @barivia/barsom-mcp
2
2
 
3
- MCP proxy for the Barivia Analytics Engine — connects any stdio MCP client (Cursor, Claude Desktop, etc.) to the barSOM cloud API. The npm package is **`@barivia/barsom-mcp`**; many configs label the server **`analytics-engine`** (the MCP server name in the client JSON). **`guide_barsom_workflow`** is the canonical bootstrap: it loads plan-scoped workflow text from the Barivia API when online (tool map, async rules, training modes, SOP). If the API is unreachable, it returns a short offline stub. Core tools are `datasets`, `train` (`map`, `siom_map`, `impute`, `floop_siom` where entitled), `jobs` (status/list/compare/cancel/delete — lifecycle only), `results` (get/export/download/recolor), and `inference` (predict/batch_predict/impute_column/compare/project_columns/transition_flow/report). **Optional MCP App tools** (`training_monitor`, `results_explorer`) add embedded or localhost viz; they are **not required** to complete upload → train → poll → results, though `results_explorer` is the preferred way to browse figures.
3
+ MCP proxy for the Barivia Analytics Engine — connects any stdio MCP client (Cursor, Claude Desktop, etc.) to the barSOM cloud API. The npm package is **`@barivia/barsom-mcp`**; many configs label the server **`analytics-engine`** (the MCP server name in the client JSON). **`guide_barsom_workflow`** is the canonical bootstrap: it loads plan-scoped workflow text from the Barivia API when online (tool map, async rules, training modes, SOP). If the API is unreachable, it returns a short **connectivity-only** offline stub. Core tools are `datasets`, `train` (`map`, `siom_map`, `impute`, `floop_siom` where entitled), `jobs` (status/list/compare/cancel/delete — lifecycle only), `results` (get/export/download/recolor), and `inference` (predict/batch_predict/impute_column/compare/project_columns/transition_flow/report). **Optional MCP App tools** (`training_monitor`, `results_explorer`) add embedded or localhost viz; they are **not required** to complete upload → train → poll → results, though `results_explorer` is the preferred way to browse figures.
4
4
 
5
- **Pre-training help (pick one):** **`prepare_training`** prompt = narrative checklist (tier-scoped from the API when online); **`training_guidance`** tool = structured presets and parameter hints (including the resolved per-column normalization). Review either, then submit with **`train`**.
5
+ **Pre-training help (pick one):** **`prepare_training`** prompt = narrative checklist (tier-scoped from the API when online); **`training_guidance`** tool = structured presets, quality/timing heuristics, and parameter hints (including resolved per-column normalization). Review either, then submit with **`train`**. Parameter encyclopedias and quality targets are **API-gated** (valid key + plan); the published proxy catalog keeps short routing text only.
6
6
 
7
7
  ## Installation
8
8
 
@@ -56,7 +56,8 @@ MCP clients typically run it with **`npx`** (downloads on first use):
56
56
  | `BARIVIA_WORKSPACE_EXTRA_ROOTS` | No | (empty) | Comma- or colon-separated absolute directories whose **realpath** targets are also allowed for uploads when the sandbox is on. Use for shared fixture trees reached via symlinks under the case workspace (e.g. `…/barivia-testing/data`). Does not weaken the default sandbox for paths outside these roots. |
57
57
  | `BARIVIA_FETCH_TIMEOUT_MS` | No | `60000` | Per-request HTTP timeout (ms) to the API. Increase (e.g. `120000`) on slow networks or when the API does long-running work. Large dataset uploads use a separate longer timeout internally, and `datasets(analyze)` runs asynchronously (auto-polled) so it is not bound by this. A timeout is not auto-retried (re-firing slow work would only multiply load / risk duplicates). |
58
58
  | `BARIVIA_VIZ_PORT` | No | OS-assigned | Local viz server on `127.0.0.1`. Port is **per MCP session** unless pinned — standalone `…/viz/…` URLs go stale after proxy restart. Set a fixed port for stable bookmark URLs. Diagnostic: `GET http://127.0.0.1:<port>/api/health?job_id=<id>`. |
59
- | `BARIVIA_UI_DELIVERY` | No | `auto` | Client-capability override for App tools: `auto` \| `apps` \| `localhost` \| `inline`. Tool responses always lead with a standalone localhost URL (hosts may list `ui://` without mounting widgets) and include a structured `ui_delivery` block. `account(status)` reports detected Apps support, viz port, and active tier. |
59
+ | `BARIVIA_UI_DELIVERY` | No | `auto` | Client-capability override for App tools: `auto` \| `apps` \| `localhost` \| `inline` \| `text_only`. Tool responses always lead with a standalone localhost URL (hosts may list `ui://` without mounting widgets) and include a structured `ui_delivery` block. `account(status)` reports detected Apps support, viz port, and active tier. |
60
+ | `BARIVIA_UI_PREFER_LOCALHOST` | No | (off) | Set `1` to force `localhost` under `auto` even when the host advertises MCP Apps (Cursor list-without-mount workaround). Default `auto` behavior unchanged. |
60
61
  | `BARIVIA_ENFORCE_WORKSPACE_SANDBOX` | No | `1` (enabled) | File uploads are constrained to the MCP workspace root (and optional `BARIVIA_WORKSPACE_EXTRA_ROOTS`) by default. Set to `0` or `false` to allow absolute paths anywhere on the machine (high trust). If uploads fail with "symlink resolves outside workspace", either point `BARIVIA_WORKSPACE_ROOT` / `BARIVIA_WORKSPACE_EXTRA_ROOTS` at the real data dir, copy the file into the workspace, or opt out with `BARIVIA_ENFORCE_WORKSPACE_SANDBOX=0`. |
61
62
 
62
63
  Legacy `BARSOM_API_KEY` / `BARSOM_API_URL` / `BARSOM_WORKSPACE_ROOT` are also accepted as fallbacks.
package/dist/audit.js CHANGED
@@ -161,14 +161,20 @@ export async function runMcpToolAudit(tool, action, args, handler) {
161
161
  /**
162
162
  * Register an MCP tool with structured audit logging on each invocation.
163
163
  */
164
- export function registerAuditedTool(server, name, description, schema, handler) {
165
- server.tool(name, description, schema, (async (args) => {
164
+ export function registerAuditedTool(server, name, description, schema, handler, annotations) {
165
+ const cb = (async (args) => {
166
166
  const rec = args;
167
167
  const action = typeof rec.action === "string" && rec.action.length > 0 ? rec.action : "default";
168
168
  // One trace per tool invocation: every apiCall inside this handler shares one
169
169
  // trace_id, so the API + job chain reconstruct the whole logical action.
170
170
  return runWithTrace(() => runMcpToolAudit(name, action, rec, () => handler(args)));
171
- }));
171
+ });
172
+ if (annotations) {
173
+ server.tool(name, description, schema, annotations, cb);
174
+ }
175
+ else {
176
+ server.tool(name, description, schema, cb);
177
+ }
172
178
  }
173
179
  /** Attach audit flags to a tool result (stripped before returning to MCP client). */
174
180
  export function withAuditFlags(result, flags) {
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 a}from"./viz-server.js";import{API_KEY as i,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 _}from"./tools/train.js";import{registerJobsTool as g,JOBS_DESCRIPTION_BASE as h}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 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 I}from"./tools/feedback.js";import{registerTrainingMonitorTool as P,TRAINING_MONITOR_URI as A}from"./tools/training_monitor.js";import{resolvePrepareTrainingPrompt as S}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 T=new e({name:"analytics-engine",version:d,instructions:'# Barivia Mapping Analytics Engine\n\nSelf-organizing map (SOM) analytics via the Barivia API.\n\n## Bootstrap (do this first)\n\nCall `guide_barsom_workflow` for the plan-scoped tool map, training modes, async rules, and SOP (API when online). Call `training_guidance` / `prepare_training` for parameter and quality hints before guessing knobs.\n\n## Workflow (short)\n\n`datasets(upload)` → preview/analyze → `train(action=...)` (only modes your plan allows) → poll `jobs(status)` every 10–15s → `results(get)` then optional `results_explorer`. Scoring and frozen-map ops live in `inference`.\n\n## Tool taxonomy (one tool per stage)\n\n| Stage | Tool(s) |\n|-------|---------|\n| Data | `datasets` |\n| Train submit | `train` (map / siom_map / impute / floop_siom where entitled) |\n| Lifecycle | `jobs` (status/list/compare/cancel/delete — no train/score) |\n| Artifacts | `results` (get/export/download/recolor) |\n| Frozen-map ops | `inference` (predict/batch_predict/impute_column/compare/project_columns/transition_flow/report) |\n| Account | `account` (status/inventory/health) |\n| Bootstrap | `guide_barsom_workflow`, `training_guidance`, `prepare_training` |\n| Explore (UI) | `results_explorer`, `training_monitor` |\n| Feedback | `send_feedback` (after user agrees) |\n\n### Avoid these mis-routes\n- `jobs(compare)` = metrics across **training runs**. `inference(compare)` = density-diff vs a **second dataset**.\n- Post-training analysis is `inference`, not `results` (presentation/artifacts only).\n- Project columns → `inference(project_columns)`; formula → `datasets(add_expression, project_onto_job=...)`.\n\n## Async & constraints\n\n- Training returns `job_id` immediately — poll `jobs(status)` every 10–15s; running is not failed.\n- **Do not auto-subset** unless the user asks. Prefer `results(get, figures="none")` for sweeps.\n- Column names are case-sensitive. After recolor/transition_flow/project_columns, use the **new** job_id when returned.\n- On `api_unreachable`: one calm sentence, honor `retry_after_sec`; use `account(health)`. Timeouts are not auto-retried.\n\n## UI delivery\n\nTool results lead with a localhost viz URL and a structured `ui_delivery` block — follow `hint_for_agent`. Env: `BARIVIA_UI_DELIVERY`, `BARIVIA_VIZ_PORT`. Details: `account(status)`.'});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),P(T),f(T),_(T),g(T,h),b(T),v(T),j(T),k(T),I(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 with SIOM entitlement; floop_siom when entitled; 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")}}]})),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 S(e);return{messages:[{role:"user",content:{type:"text",text:(t.used_fallback?`[offline fallback ${t.fallback_version}]\n\n`:"")+t.text}}]}});const C=new t;(async function(){try{const e=await a(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(C)})().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 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 _}from"./tools/train.js";import{registerJobsTool as g,JOBS_DESCRIPTION_BASE as h}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 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 I}from"./tools/feedback.js";import{registerTrainingMonitorTool as A,TRAINING_MONITOR_URI as P}from"./tools/training_monitor.js";import{resolvePrepareTrainingPrompt as S}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 T=new e({name:"analytics-engine",version:d,instructions:'# Barivia Mapping Analytics Engine\n\nSelf-organizing map (SOM) analytics via the Barivia API.\n\n## Bootstrap (do this first)\n\nCall `guide_barsom_workflow` for the plan-scoped tool map, training modes, async rules, and SOP (API when online). Call `training_guidance` / `prepare_training` for parameter and quality hints before guessing knobs.\n\n## Workflow (short)\n\n`datasets(upload)` → preview/analyze → `train(action=...)` (only modes your plan allows) → poll `jobs(status)` every 10–15s → `results(get)` then optional `results_explorer`. Scoring and frozen-map ops live in `inference`.\n\n## Tool taxonomy (one tool per stage)\n\n| Stage | Tool(s) |\n|-------|---------|\n| Data | `datasets` |\n| Train submit | `train` (map / siom_map / impute / floop_siom where entitled) |\n| Lifecycle | `jobs` (status/list/compare/cancel/delete — no train/score) |\n| Artifacts | `results` (get/export/download/recolor) |\n| Frozen-map ops | `inference` (predict/batch_predict/impute_column/compare/project_columns/transition_flow/report) |\n| Account | `account` (status/inventory/health) |\n| Bootstrap | `guide_barsom_workflow`, `training_guidance`, `prepare_training` |\n| Explore (UI) | `results_explorer`, `training_monitor` |\n| Feedback | `send_feedback` (after user agrees) |\n\n### Avoid these mis-routes\n- `jobs(compare)` = metrics across **training runs**. `inference(compare)` = density-diff vs a **second dataset**.\n- Post-training analysis is `inference`, not `results` (presentation/artifacts only).\n- Project columns → `inference(project_columns)`; formula → `datasets(add_expression, project_onto_job=...)`.\n\n## Async & constraints\n\n- Training returns `job_id` immediately — poll `jobs(status)` every 10–15s; running is not failed.\n- **Do not auto-subset** unless the user asks. Prefer `results(get, figures="none")` for sweeps.\n- Column names are case-sensitive. After recolor/transition_flow/project_columns, use the **new** job_id when returned.\n- On `api_unreachable`: one calm sentence, honor `retry_after_sec`; use `account(health)`. Timeouts are not auto-retried.\n\n## UI delivery\n\nTool results lead with a localhost viz URL and a structured `ui_delivery` block — follow `hint_for_agent`. Env: `BARIVIA_UI_DELIVERY` (auto|apps|localhost|inline|text_only), `BARIVIA_UI_PREFER_LOCALHOST=1` (opt-in localhost even when Apps advertised), `BARIVIA_VIZ_PORT`. Details: `account(status)`.'});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,P,P,{mimeType:n},async()=>{const e=await p("training-monitor");return{contents:[{uri:P,mimeType:n,text:e??"<html><body>Training Monitor view not built yet.</body></html>"}]}}),x(T),w(T),A(T),f(T),_(T),g(T,h),b(T),v(T),j(T),k(T),I(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 with SIOM entitlement; floop_siom when entitled; 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")}}]})),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 S(e);return{messages:[{role:"user",content:{type:"text",text:(t.used_fallback?`[offline fallback ${t.fallback_version}]\n\n`:"")+t.text}}]}});const O=new t;(async function(){try{const e=await a(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(O)})().catch(console.error);
@@ -10,6 +10,9 @@ export const DEFAULT_BLOCK_UNTIL_SEC = 900;
10
10
  export const DEFAULT_POLL_INTERVAL_SEC = 5;
11
11
  export const MIN_POLL_INTERVAL_SEC = 5;
12
12
  export const HEARTBEAT_POLLS = 1;
13
+ /** After this many unchanged polls, gently raise interval (never below MIN_POLL). */
14
+ export const IDLE_BACKOFF_AFTER_POLLS = 8;
15
+ export const MAX_POLL_INTERVAL_SEC = 15;
13
16
  function sleep(ms) {
14
17
  return new Promise((r) => setTimeout(r, ms));
15
18
  }
@@ -218,12 +221,22 @@ export async function monitorJob(job_id, options = {}) {
218
221
  let lastSnap = null;
219
222
  let data = {};
220
223
  let heartbeat = 0;
224
+ let idleStreak = 0;
221
225
  while (Date.now() - start < blockMs) {
222
226
  data = (await apiCall("GET", `/v1/jobs/${job_id}`));
223
227
  const elapsedSec = Math.round((Date.now() - start) / 1000);
224
228
  const snap = snapshotFromJob(data, elapsedSec);
225
229
  heartbeat += 1;
226
230
  const heartbeatDue = heartbeat >= HEARTBEAT_POLLS;
231
+ const unchanged = lastSnap != null &&
232
+ lastSnap.status === snap.status &&
233
+ lastSnap.progress_pct === snap.progress_pct &&
234
+ lastSnap.phase === snap.phase &&
235
+ lastSnap.epoch === snap.epoch;
236
+ if (unchanged)
237
+ idleStreak += 1;
238
+ else
239
+ idleStreak = 0;
227
240
  if (shouldRecordSnapshot(lastSnap, snap) || heartbeatDue) {
228
241
  snapshots.push(snap);
229
242
  lastSnap = snap;
@@ -233,7 +246,13 @@ export async function monitorJob(job_id, options = {}) {
233
246
  if (status === "completed" || status === "failed" || status === "cancelled") {
234
247
  break;
235
248
  }
236
- await sleep(pollMs);
249
+ // Gentle backoff on idle polls — raise interval, never below the 5s floor.
250
+ let waitMs = pollMs;
251
+ if (idleStreak >= IDLE_BACKOFF_AFTER_POLLS) {
252
+ const steps = Math.floor((idleStreak - IDLE_BACKOFF_AFTER_POLLS) / 2);
253
+ waitMs = Math.min(pollMs + steps * 5000, MAX_POLL_INTERVAL_SEC * 1000);
254
+ }
255
+ await sleep(Math.max(MIN_POLL_INTERVAL_SEC * 1000, waitMs));
237
256
  }
238
257
  const status = String(data.status ?? "");
239
258
  const terminal = status === "completed" || status === "failed" || status === "cancelled";
package/dist/shared.js CHANGED
@@ -27,10 +27,37 @@ export const RETRY_BASE_MS = parseInt(process.env.BARIVIA_RETRY_BASE_MS ?? "2000
27
27
  export const RETRYABLE_STATUS = new Set([502, 503, 504]);
28
28
  /** Cool-down hint after the burst is exhausted — agents should wait before another round. */
29
29
  export const UNAVAILABLE_RETRY_AFTER_SEC = 30;
30
- /** Fixed delay between burst retries (not exponential). */
31
- export function retryDelayMs(_attempt) {
30
+ /**
31
+ * Delay between burst retries. Keeps the flat RETRY_BASE_MS default (short gateway
32
+ * blips); when the server sends Retry-After / retry_after_sec, sleep at least that long.
33
+ */
34
+ export function retryDelayMs(_attempt, retryAfterMs) {
35
+ if (retryAfterMs != null && Number.isFinite(retryAfterMs) && retryAfterMs > 0) {
36
+ return Math.max(RETRY_BASE_MS, Math.floor(retryAfterMs));
37
+ }
32
38
  return RETRY_BASE_MS;
33
39
  }
40
+ /** Parse Retry-After header (delta-seconds) and/or JSON body retry_after_sec → ms. */
41
+ export function parseRetryAfterMs(resp, bodyText) {
42
+ const header = resp.headers.get("Retry-After");
43
+ if (header) {
44
+ const sec = parseInt(header.trim(), 10);
45
+ if (!Number.isNaN(sec) && sec >= 0)
46
+ return sec * 1000;
47
+ }
48
+ if (bodyText) {
49
+ try {
50
+ const j = JSON.parse(bodyText);
51
+ const sec = Number(j.retry_after_sec);
52
+ if (Number.isFinite(sec) && sec >= 0)
53
+ return sec * 1000;
54
+ }
55
+ catch {
56
+ /* not JSON */
57
+ }
58
+ }
59
+ return undefined;
60
+ }
34
61
  function newRequestId() {
35
62
  return randomUUID();
36
63
  }
@@ -39,7 +66,7 @@ function newRequestId() {
39
66
  * X-Barsom-Client-Version so the server can annotate tool guidance with the
40
67
  * wrapper version each action requires. Keep in sync with package.json on bump.
41
68
  */
42
- export const CLIENT_VERSION = "0.23.10";
69
+ export const CLIENT_VERSION = "0.23.11";
43
70
  /** User-facing links; keep aligned with barivia.se / api.barivia.se. */
44
71
  export const PUBLIC_SITE_ORIGIN = "https://barivia.se";
45
72
  /** Self-serve account dashboard (manage plan, billing, and API keys). */
@@ -244,7 +271,11 @@ export function isTransientError(err, status) {
244
271
  return true; // network-level fetch failure
245
272
  return false;
246
273
  }
247
- /** Streaming SHA-256 of a file (for idempotency keys) without reading it into memory. */
274
+ /**
275
+ * Streaming SHA-256 of a file without reading it into memory.
276
+ * Used by tools/datasets.ts upload as Idempotency-Key = sha256(name+body) — do not
277
+ * invent a second key path here.
278
+ */
248
279
  export async function streamFileSha256(srcPath) {
249
280
  return new Promise((resolve, reject) => {
250
281
  const h = createHash("sha256");
@@ -814,7 +845,7 @@ export async function apiCall(method, path, body, extraHeaders, requestTimeoutMs
814
845
  const text = await resp.text();
815
846
  if (!resp.ok) {
816
847
  if (attempt < MAX_RETRIES && isTransientError(null, resp.status)) {
817
- const delayMs = retryDelayMs(attempt);
848
+ const delayMs = retryDelayMs(attempt, parseRetryAfterMs(resp, text));
818
849
  logWarn("API retry", {
819
850
  rid: requestId,
820
851
  method,
@@ -845,7 +876,10 @@ export async function apiCall(method, path, body, extraHeaders, requestTimeoutMs
845
876
  catch (err) {
846
877
  lastError = err;
847
878
  if (attempt < MAX_RETRIES && isTransientError(err)) {
848
- const delayMs = retryDelayMs(attempt);
879
+ const errRetryMs = typeof err.retryAfterSec === "number"
880
+ ? err.retryAfterSec * 1000
881
+ : undefined;
882
+ const delayMs = retryDelayMs(attempt, errRetryMs);
849
883
  logWarn("API retry", {
850
884
  rid: requestId,
851
885
  method,
@@ -889,8 +923,9 @@ export async function apiRawCall(path, requestTimeoutMs) {
889
923
  },
890
924
  }, effectiveTimeout);
891
925
  if (!resp.ok) {
926
+ const text = await resp.text();
892
927
  if (attempt < MAX_RETRIES && isTransientError(null, resp.status)) {
893
- const delayMs = retryDelayMs(attempt);
928
+ const delayMs = retryDelayMs(attempt, parseRetryAfterMs(resp, text));
894
929
  logWarn("API retry", {
895
930
  rid: requestId,
896
931
  path,
@@ -902,7 +937,6 @@ export async function apiRawCall(path, requestTimeoutMs) {
902
937
  await new Promise((r) => setTimeout(r, delayMs));
903
938
  continue;
904
939
  }
905
- const text = await resp.text();
906
940
  throwApiError(resp.status, text, requestId);
907
941
  }
908
942
  const arrayBuf = await resp.arrayBuffer();
@@ -914,7 +948,10 @@ export async function apiRawCall(path, requestTimeoutMs) {
914
948
  catch (err) {
915
949
  lastError = err;
916
950
  if (attempt < MAX_RETRIES && isTransientError(err)) {
917
- const delayMs = retryDelayMs(attempt);
951
+ const errRetryMs = typeof err.retryAfterSec === "number"
952
+ ? err.retryAfterSec * 1000
953
+ : undefined;
954
+ const delayMs = retryDelayMs(attempt, errRetryMs);
918
955
  logWarn("API retry", {
919
956
  rid: requestId,
920
957
  path,
@@ -46,7 +46,7 @@ export function registerAccountTool(server) {
46
46
  action=status: Returns plan tier, compute class (CPU/GPU), usage limits, live queue state, training time estimates, and credit balance.
47
47
  Use BEFORE large jobs to check GPU availability and estimate wait time.
48
48
  When the API/gateway is down, returns structured \`{ok:false, retry_after_sec, …}\` — treat as a pause, not a crash.
49
- action=inventory: Lists datasets (name/id/rows/cols/description/tags) and slim recent jobs (label/id/status/job_type/dataset_id/created_at/result). Prefer this over hand-assembling datasets(list)+jobs(list). Page jobs with jobs(action=list, cursor=…).
49
+ action=inventory: One-shot markdown overview — all datasets (slim) plus the latest N jobs (limit default 50). Not paginated for datasets; for more jobs use jobs(action=list, cursor=next_cursor, limit=…). Prefer this over hand-assembling datasets(list)+jobs(list).
50
50
  action=health: Lightweight liveness/readiness (no R2). Use when tools return api_unreachable or before retrying deferred send_feedback.
51
51
  Capacity is shared LOCAL/GKE worker pools — there is no per-key cloud burst lease.
52
52
  NOT FOR: Training itself — use train(action=map). This tool only manages the account.`, {
@@ -164,6 +164,7 @@ async function handleResultsExplorer(job_id) {
164
164
  jobId: job_id,
165
165
  jobStatus: "completed",
166
166
  inlineImageAttached,
167
+ uiResourceUri: RESULTS_EXPLORER_URI,
167
168
  });
168
169
  appendUiDeliveryContent(content, uiDelivery, {
169
170
  linkLabel: "Open results explorer",
@@ -23,7 +23,7 @@ export function registerFeedbackTool(server) {
23
23
 
24
24
  For a substantial issue, prefer feedback_items: submit several focused instances (each max 1400 chars) covering, e.g., symptoms, exact reproduction steps, environment, and concrete asks — they are stored together as one batch so developers see the full picture. Use the single feedback field for a short one-off note.
25
25
 
26
- If the API or object storage is unavailable, this tool queues feedback locally (~/.barivia/deferred-feedback) and returns deferred=true with request_id / api_health — call again later to flush. Probe API liveness with account(action=health) (GET /health; does not touch R2).`, {
26
+ If the API or object storage is unavailable, this tool queues feedback locally (~/.barivia/deferred-feedback) and returns deferred=true with request_id / api_health — call again later to flush (idempotent enqueue of the same draft). Probe API liveness with account(action=health) (GET /health; does not touch R2).`, {
27
27
  feedback: z.string().max(1400).optional().describe("Single feedback note (max 1400 characters). Use feedback_items instead for a multi-part report."),
28
28
  feedback_items: z.array(z.string().max(1400)).max(10).optional().describe("Several feedback instances (each max 1400 characters, up to 10), stored together as one batch. Prefer this for a detailed issue: split it into focused parts (symptoms, reproduction, environment, asks)."),
29
29
  }, async ({ feedback, feedback_items }) => {
@@ -104,5 +104,10 @@ If the API or object storage is unavailable, this tool queues feedback locally (
104
104
  }
105
105
  throw err;
106
106
  }
107
+ }, {
108
+ readOnlyHint: false,
109
+ destructiveHint: false,
110
+ idempotentHint: true,
111
+ openWorldHint: true,
107
112
  });
108
113
  }
@@ -24,13 +24,13 @@ ASYNC POLLING PROTOCOL (action=monitor):
24
24
  - Optional UI: training_monitor(job_id) for embedded charts; jobs(action=monitor) is the headless equivalent.
25
25
 
26
26
  ASYNC POLLING PROTOCOL (action=status):
27
- - One-shot check only. If not using monitor, poll every 10-15 seconds manually.
27
+ - One-shot check only. If not using monitor, poll every 10-15 seconds manually — do not give up after one or two polls; large jobs need patience.
28
28
  - Progress increases through phases (ordering then convergence for map training); other job types differ.
29
29
  - For large grids (40×40+), do not assume failure before 3 minutes on CPU.
30
30
  - Map training typical times: 10×10 ~30s | 20×20 ~3–5 min | 40×40 ~15–30 min.
31
31
  - completed → results(action=get); failed → read error + optional failure_stage (preprocessing/training/metrics/visualization/upload): memory error → smaller grid/batch and retrain; column missing → datasets(action=preview); NaN on a standard map → train(action=impute) for sparse columns or clean the CSV.
32
32
 
33
- action=list: Slim rows by default (label,id,status,job_type,dataset_id,created_at,result_ref,description,tags — no fat params). Use status/job_type/has_results/limit/cursor to page. has_results=true is the results catalog. Pass fields="full" only when you need params/error.
33
+ action=list: Slim rows by default (label,id,status,job_type,dataset_id,created_at,result_ref,description,tags — no fat params). Use status/job_type/has_results/limit/cursor to page; when the response includes next_cursor, call again with cursor=that value. has_results=true is the results catalog. Pass fields="full" only when you need params/error.
34
34
  action=update: PATCH label/description/tags (pass at train submit when possible).
35
35
  action=compare: metrics table (QE, TE, explained variance, silhouette) for 2+ COMPLETED TRAINING jobs — pick the best run in a sweep. NOT inference(action=compare) (single trained job + second dataset → density-diff heatmap). Only hyperparameter columns that differ across runs are shown (including SIOM knobs gamma/gamma_f/siom_decay/siom_penalty/reset_per_epoch when they vary); pass label="…" at train time for readable rows.
36
36
  action=cancel: Not instant — worker checks between phases. Expect up to 30s delay.
@@ -19,7 +19,8 @@ Temporal state-transition arrows moved to **inference(action=transition_flow)**
19
19
  ONLY call this after jobs(action=status) returns "completed".
20
20
  ESCALATION: If job not found, verify job_id. If "job not complete", poll with jobs(action=status).
21
21
 
22
- action=get: Returns text summary with quality metrics and inline images.
22
+ Job states (for async parents): pending running completed | failed | cancelled (finalize_training may run after compute completes — wait until status=completed before get).
23
+ action=get: Returns text summary with quality metrics and inline images; structuredContent includes a slim verification block (status, file_count, has_summary).
23
24
  - figures: omit = combined only. Prefer string "all" / "none" (not an array). "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, divergence_kl, component_1..N) or download filenames. Hosts that stringify arrays are coerced. Request unpublished cyclic planes via results(recolor, figures=[component_N, ...]).
24
25
  - include_individual: if true (and figures omitted), inlines every component plane.
25
26
  - After recolor or project, use the job_id returned by that operation to get the new artifacts, not the original training job_id.
@@ -478,6 +479,13 @@ NOT FOR: Jobs that haven't completed (use jobs(action=status) first), or tempora
478
479
  summary,
479
480
  files,
480
481
  figures_inlined: [...inlinedImages],
482
+ verification: {
483
+ status: "completed",
484
+ has_summary: Object.keys(summary).length > 0,
485
+ file_count: files.length,
486
+ figures_inlined_count: inlinedImages.size,
487
+ job_type: jobType2,
488
+ },
481
489
  };
482
490
  const summaryText = content.filter((c) => c.type === "text").map((c) => c.text).join("\n\n");
483
491
  return structuredTextResult(structuredPayload, summaryText, content);
@@ -15,7 +15,7 @@ export const TRAIN_DESCRIPTION = `Submit a self-organizing map training job. Ret
15
15
 
16
16
  Call **guide_barsom_workflow**, **training_guidance**, and/or the **prepare_training** prompt for plan-scoped parameter details, timing heuristics, quality.csv semantics, SIOM/FLooP knobs, and impute constraints — do not guess. Backend: "cpu" | "gpu" | "gpu_graphs" (on CPU-only hosts pass backend=cpu).
17
17
 
18
- ASYNC: every action returns a job_id immediately. Poll jobs(action=status) every 10–15s (not faster). When completed, call results(action=get). Timing and quality heuristics: **training_guidance**.
18
+ ASYNC: every action returns a job_id immediately. Prefer jobs(action=monitor) to block with snapshots, or poll jobs(action=status) every 10–15s (not faster) — do not abandon after one poll. When completed, call results(action=get). Timing and quality heuristics: **training_guidance**.
19
19
  NOT FOR: lifecycle → jobs; scoring → inference.
20
20
  Rank transform (map/siom/floop only) is train-only — see training_guidance.`;
21
21
  function assertImputeTrainingArgs(args) {
@@ -1,7 +1,16 @@
1
1
  import { z } from "zod";
2
2
  import { registerAppTool } from "@modelcontextprotocol/ext-apps/server";
3
3
  import { runMcpToolAudit } from "../audit.js";
4
+ import { logWarn } from "../logger.js";
4
5
  import { apiCall, BARSOM_VIZ_VIEWS, buildStandaloneVizPageUrl, buildVizRelatedUrls, getVizPort, structuredTextResult, } from "../shared.js";
6
+ /** Loose shape check — warn + pass through on failure (never drop a successful monitor payload). */
7
+ const TrainingMonitorStructuredSchema = z
8
+ .object({
9
+ type: z.literal("training-monitor"),
10
+ jobId: z.string(),
11
+ refresh_interval_ms: z.number(),
12
+ })
13
+ .passthrough();
5
14
  import { appendUiDeliveryContent, beginInlineFallback, resolveUiDelivery, tryAttachLearningCurve, } from "../ui-delivery.js";
6
15
  import { lastEpochTeFromCurves } from "../training_monitor_curve.js";
7
16
  import { buildVizStandaloneUrl, resultsExplorerStandaloneLinkText, } from "../viz_links.js";
@@ -114,8 +123,8 @@ export async function buildTrainingMonitorResult(job_id, opts) {
114
123
  timingParts.push(`epoch ${epoch}/${totalEpochs}`);
115
124
  const timingNote = timingParts.length > 0 ? ` ${timingParts.join(", ")}.` : "";
116
125
  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.`;
126
+ `Training monitor (standalone URL + optional App panel; refreshes every ${TRAINING_MONITOR_REFRESH_MS / 1000}s when mounted): job ${job_id} — ${status} (${progress.toFixed(1)}%).${timingNote} ` +
127
+ `For headless progress use jobs(action=status); this tool is optional.`;
119
128
  const content = [{ type: "text", text }];
120
129
  const terminal = status === "completed" || status === "failed" || status === "cancelled";
121
130
  let inlineImageAttached = false;
@@ -127,6 +136,7 @@ export async function buildTrainingMonitorResult(job_id, opts) {
127
136
  jobId: job_id,
128
137
  jobStatus: status,
129
138
  inlineImageAttached,
139
+ uiResourceUri: TRAINING_MONITOR_URI,
130
140
  });
131
141
  appendUiDeliveryContent(content, uiDelivery, {
132
142
  linkLabel: "Open training monitor",
@@ -142,6 +152,13 @@ export async function buildTrainingMonitorResult(job_id, opts) {
142
152
  }
143
153
  }
144
154
  structuredContent.ui_delivery = uiDelivery;
155
+ const parsed = TrainingMonitorStructuredSchema.safeParse(structuredContent);
156
+ if (!parsed.success) {
157
+ logWarn("training_monitor structuredContent validation failed — passing through", {
158
+ job_id,
159
+ issues: parsed.error.issues.slice(0, 5).map((i) => i.message),
160
+ });
161
+ }
145
162
  return { structuredContent, content, text };
146
163
  }
147
164
  /** After terminal + finalize, append results explorer open hint (monitor completion path). */
@@ -158,13 +175,13 @@ export function appendResultsExplorerCompletionHint(content, job_id) {
158
175
  export function registerTrainingMonitorTool(server) {
159
176
  registerAppTool(server, "training_monitor", {
160
177
  title: "Training Monitor",
161
- description: "Optional embedded or standalone view of training progress for a job_id. When embedded in a client that supports MCP Apps, the panel auto-refreshes every 5s until the job completes: live QE/TE learning curves (uniformly subsampled to ≤1000 batch samples per phase, not one point per epoch) plus a per-epoch hit-distribution heatmap (sampled BMU counts across the map, showing where observations are landing as training proceeds). A standalone browser URL is also available — its localhost port is per MCP session and goes stale if the proxy restarts (set BARIVIA_VIZ_PORT for a persistent port; re-run this tool for a fresh link). The same scalar fields are available via jobs(action=status)—not required to complete training or read results.",
178
+ description: "Optional view of training progress for a job_id. Prefer the standalone localhost URL in the tool result (always surfaced when the viz server is up) — some hosts list MCP Apps without mounting panels. When a host mounts the App, the panel auto-refreshes every 5s: QE/TE curves (≤1000 batch samples per phase) plus a hit-distribution heatmap. Set BARIVIA_VIZ_PORT for a stable port; re-run for a fresh link after proxy restart. Headless: jobs(action=status) has the same scalars not required to finish training or read results.",
162
179
  inputSchema: {
163
180
  job_id: z.string().describe("Training job ID to monitor"),
164
181
  fetch_training_log: z
165
182
  .boolean()
166
183
  .optional()
167
- .describe("Internal: merge completed-job training-log arrays when live progress is empty"),
184
+ .describe("[INTERNAL App/refresh path] Merge completed-job training-log arrays when live progress is empty. Agents should omit this."),
168
185
  },
169
186
  _meta: { ui: { resourceUri: TRAINING_MONITOR_URI } },
170
187
  }, async (args) => runMcpToolAudit("training_monitor", "default", args, async () => {
@@ -6,11 +6,20 @@ import { apiRawCall, getClientSupportsMcpApps, getVizPort, mimeForFilename, rese
6
6
  import { buildVizStandaloneUrl } from "./viz_links.js";
7
7
  export function parseUiDeliveryOverride() {
8
8
  const raw = (process.env.BARIVIA_UI_DELIVERY ?? "auto").trim().toLowerCase();
9
- if (raw === "apps" || raw === "localhost" || raw === "inline" || raw === "auto") {
9
+ if (raw === "apps" ||
10
+ raw === "localhost" ||
11
+ raw === "inline" ||
12
+ raw === "text_only" ||
13
+ raw === "auto") {
10
14
  return raw;
11
15
  }
12
16
  return "auto";
13
17
  }
18
+ /** Opt-in: force localhost under auto even when the host advertises MCP Apps (e.g. Cursor list-without-mount). */
19
+ export function preferLocalhostUi() {
20
+ const v = (process.env.BARIVIA_UI_PREFER_LOCALHOST ?? "").trim().toLowerCase();
21
+ return v === "1" || v === "true" || v === "yes";
22
+ }
14
23
  function vizPortPinned() {
15
24
  return Boolean(process.env.BARIVIA_VIZ_PORT?.trim());
16
25
  }
@@ -30,17 +39,26 @@ export function buildHealthUrl(jobId) {
30
39
  }
31
40
  function hintForTier(tier, tool, opts) {
32
41
  const label = tool === "training_monitor" ? "training monitor" : "results explorer";
42
+ const linkFirst = opts.standaloneUrl
43
+ ? `AGENT: Post a clickable markdown link first (e.g. "[Open ${label}](${opts.standaloneUrl})") — do not bury it after metrics.`
44
+ : undefined;
33
45
  switch (tier) {
34
46
  case "embedded":
35
- return `The ${label} MCP App panel is embedded in this client. Also mention the standalone URL if the user may open a browser tab.`;
47
+ return linkFirst
48
+ ? `${linkFirst} Embedded MCP App panel if the host mounts it; some hosts list ui:// without mounting (use the URL).`
49
+ : `The ${label} MCP App panel may embed. Use jobs(status) / results(get) if no panel or URL appears.`;
36
50
  case "localhost":
37
- return opts.standaloneUrl
38
- ? `AGENT: Post a clickable markdown link to the user (e.g. "[Open ${label}](${opts.standaloneUrl})") — do not bury it in tool output or use broken workspace file paths. Re-post after the job completes if status was running.`
51
+ return linkFirst
52
+ ? `${linkFirst} Re-post after the job completes if status was running. Set BARIVIA_VIZ_PORT for a stable port.`
39
53
  : `AGENT: Viz server unavailable — use jobs(status) and results(get) for progress and figures.`;
40
54
  case "inline_image":
41
- return `AGENT: Inline raster figure(s) are attached below. Summarize metrics in your reply; offer results_explorer or results(get) for additional figures.`;
55
+ return linkFirst
56
+ ? `${linkFirst} Inline raster figure(s) may also be attached; summarize metrics; offer results_explorer or results(get) for more.`
57
+ : `AGENT: Inline raster figure(s) are attached below. Summarize metrics; offer results_explorer or results(get) for additional figures.`;
42
58
  case "text_only":
43
- return `AGENT: No embedded panel or localhost viz — use jobs(status) for training progress and results(get) or results_explorer for figures.`;
59
+ return linkFirst
60
+ ? `${linkFirst} Otherwise use jobs(status) for progress and results(get) for figures.`
61
+ : `AGENT: No embedded panel or localhost viz — use jobs(status) for training progress and results(get) or results_explorer for figures.`;
44
62
  }
45
63
  }
46
64
  export function resolveUiDelivery(options) {
@@ -58,9 +76,15 @@ export function resolveUiDelivery(options) {
58
76
  else if (override === "inline") {
59
77
  delivery = options.inlineImageAttached ? "inline_image" : "text_only";
60
78
  }
79
+ else if (override === "text_only") {
80
+ delivery = "text_only";
81
+ }
61
82
  else {
62
- // auto
63
- if (mcpApps) {
83
+ // auto — default unchanged: prefer embedded when Apps advertised unless opt-in prefer-localhost
84
+ if (preferLocalhostUi() && port) {
85
+ delivery = "localhost";
86
+ }
87
+ else if (mcpApps) {
64
88
  delivery = "embedded";
65
89
  }
66
90
  else if (port) {
@@ -73,12 +97,13 @@ export function resolveUiDelivery(options) {
73
97
  delivery = "text_only";
74
98
  }
75
99
  }
100
+ // Always include standalone URL when the viz port exists (all tiers) — hosts may not mount Apps.
76
101
  const urls = [];
77
- if (standaloneUrl && (delivery === "embedded" || delivery === "localhost")) {
102
+ if (standaloneUrl) {
78
103
  urls.push(standaloneUrl);
79
104
  }
80
105
  const healthUrl = buildHealthUrl(options.jobId);
81
- if (healthUrl && delivery === "localhost") {
106
+ if (healthUrl && (delivery === "localhost" || preferLocalhostUi())) {
82
107
  urls.push(healthUrl);
83
108
  }
84
109
  const crossLink = options.tool === "training_monitor" && options.jobStatus === "completed"
@@ -97,6 +122,7 @@ export function resolveUiDelivery(options) {
97
122
  viz_port: port,
98
123
  port_pinned: vizPortPinned(),
99
124
  override,
125
+ ...(options.uiResourceUri ? { ui_resource_uri: options.uiResourceUri } : {}),
100
126
  };
101
127
  }
102
128
  /**
@@ -154,6 +180,7 @@ export function getMcpClientDiagnostics() {
154
180
  const override = parseUiDeliveryOverride();
155
181
  const mcpApps = getClientSupportsMcpApps();
156
182
  const port = getVizPort() || null;
183
+ const preferLocal = preferLocalhostUi();
157
184
  let activeTier = "text_only";
158
185
  if (override === "apps") {
159
186
  activeTier = mcpApps ? "embedded" : port ? "localhost" : "text_only";
@@ -164,6 +191,12 @@ export function getMcpClientDiagnostics() {
164
191
  else if (override === "inline") {
165
192
  activeTier = "inline_image";
166
193
  }
194
+ else if (override === "text_only") {
195
+ activeTier = "text_only";
196
+ }
197
+ else if (preferLocal && port) {
198
+ activeTier = "localhost";
199
+ }
167
200
  else if (mcpApps) {
168
201
  activeTier = "embedded";
169
202
  }
@@ -175,6 +208,7 @@ export function getMcpClientDiagnostics() {
175
208
  viz_port: port,
176
209
  viz_port_pinned: vizPortPinned(),
177
210
  ui_delivery_override: override,
211
+ ui_prefer_localhost: preferLocal,
178
212
  active_delivery_tier: activeTier,
179
213
  health_url: buildHealthUrl(),
180
214
  };
@@ -305,7 +305,7 @@ Note: This type uses \`Record<K, string | undefined>\` rather than \`Partial<Rec
305
305
  for compatibility with Zod schema generation. Both are functionally equivalent for validation.`);c.object({method:c.literal("ui/open-link"),params:c.object({url:c.string().describe("URL to open in the host's browser")})});var lI=c.object({isError:c.boolean().optional().describe("True if the host failed to open the URL (e.g., due to security policy).")}).passthrough(),cI=c.object({isError:c.boolean().optional().describe("True if the host rejected or failed to deliver the message.")}).passthrough();c.object({method:c.literal("ui/notifications/sandbox-proxy-ready"),params:c.object({})});var oo=c.object({connectDomains:c.array(c.string()).optional().describe("Origins for network requests (fetch/XHR/WebSocket)."),resourceDomains:c.array(c.string()).optional().describe("Origins for static resources (scripts, images, styles, fonts)."),frameDomains:c.array(c.string()).optional().describe("Origins for nested iframes (frame-src directive)."),baseUriDomains:c.array(c.string()).optional().describe("Allowed base URIs for the document (base-uri directive).")}),so=c.object({camera:c.object({}).optional().describe("Request camera access (Permission Policy `camera` feature)."),microphone:c.object({}).optional().describe("Request microphone access (Permission Policy `microphone` feature)."),geolocation:c.object({}).optional().describe("Request geolocation access (Permission Policy `geolocation` feature)."),clipboardWrite:c.object({}).optional().describe("Request clipboard write access (Permission Policy `clipboard-write` feature).")});c.object({method:c.literal("ui/notifications/size-changed"),params:c.object({width:c.number().optional().describe("New width in pixels."),height:c.number().optional().describe("New height in pixels.")})});var dI=c.object({method:c.literal("ui/notifications/tool-input"),params:c.object({arguments:c.record(c.string(),c.unknown().describe("Complete tool call arguments as key-value pairs.")).optional().describe("Complete tool call arguments as key-value pairs.")})}),mI=c.object({method:c.literal("ui/notifications/tool-input-partial"),params:c.object({arguments:c.record(c.string(),c.unknown().describe("Partial tool call arguments (incomplete, may change).")).optional().describe("Partial tool call arguments (incomplete, may change).")})}),fI=c.object({method:c.literal("ui/notifications/tool-cancelled"),params:c.object({reason:c.string().optional().describe('Optional reason for the cancellation (e.g., "user action", "timeout").')})}),pI=c.object({fonts:c.string().optional()}),vI=c.object({variables:uI.optional().describe("CSS variables for theming the app."),css:pI.optional().describe("CSS blocks that apps can inject.")}),hI=c.object({method:c.literal("ui/resource-teardown"),params:c.object({})});c.record(c.string(),c.unknown());var Go=c.object({text:c.object({}).optional().describe("Host supports text content blocks."),image:c.object({}).optional().describe("Host supports image content blocks."),audio:c.object({}).optional().describe("Host supports audio content blocks."),resource:c.object({}).optional().describe("Host supports resource content blocks."),resourceLink:c.object({}).optional().describe("Host supports resource link content blocks."),structuredContent:c.object({}).optional().describe("Host supports structured content.")}),gI=c.object({experimental:c.object({}).optional().describe("Experimental features (structure TBD)."),openLinks:c.object({}).optional().describe("Host supports opening external URLs."),serverTools:c.object({listChanged:c.boolean().optional().describe("Host supports tools/list_changed notifications.")}).optional().describe("Host can proxy tool calls to the MCP server."),serverResources:c.object({listChanged:c.boolean().optional().describe("Host supports resources/list_changed notifications.")}).optional().describe("Host can proxy resource reads to the MCP server."),logging:c.object({}).optional().describe("Host accepts log messages."),sandbox:c.object({permissions:so.optional().describe("Permissions granted by the host (camera, microphone, geolocation)."),csp:oo.optional().describe("CSP domains approved by the host.")}).optional().describe("Sandbox configuration applied by the host."),updateModelContext:Go.optional().describe("Host accepts context updates (ui/update-model-context) to be included in the model's context for future turns."),message:Go.optional().describe("Host supports receiving content messages (ui/message) from the view.")}),_I=c.object({experimental:c.object({}).optional().describe("Experimental features (structure TBD)."),tools:c.object({listChanged:c.boolean().optional().describe("App supports tools/list_changed notifications.")}).optional().describe("App exposes MCP-style tools that the host can call."),availableDisplayModes:c.array($t).optional().describe("Display modes the app supports.")});c.object({method:c.literal("ui/notifications/initialized"),params:c.object({}).optional()});c.object({csp:oo.optional().describe("Content Security Policy configuration."),permissions:so.optional().describe("Sandbox permissions requested by the UI."),domain:c.string().optional().describe("Dedicated origin for view sandbox."),prefersBorder:c.boolean().optional().describe("Visual boundary preference - true if UI prefers a visible border.")});c.object({method:c.literal("ui/request-display-mode"),params:c.object({mode:$t.describe("The display mode being requested.")})});var $I=c.object({mode:$t.describe("The display mode that was actually set. May differ from requested if not supported.")}).passthrough(),bI=c.union([c.literal("model"),c.literal("app")]).describe("Tool visibility scope - who can access the tool.");c.object({resourceUri:c.string().optional(),visibility:c.array(bI).optional().describe(`Who can access this tool. Default: ["model", "app"]
306
306
  - "model": Tool visible to and callable by the agent
307
307
  - "app": Tool callable by the app from this server only`)});c.object({mimeTypes:c.array(c.string()).optional().describe('Array of supported MIME types for UI resources.\nMust include `"text/html;profile=mcp-app"` for MCP Apps support.')});c.object({method:c.literal("ui/message"),params:c.object({role:c.literal("user").describe('Message role, currently only "user" is supported.'),content:c.array(Ot).describe("Message content blocks (text, image, etc.).")})});c.object({method:c.literal("ui/notifications/sandbox-resource-ready"),params:c.object({html:c.string().describe("HTML content to load into the inner iframe."),sandbox:c.string().optional().describe("Optional override for the inner iframe's sandbox attribute."),csp:oo.optional().describe("CSP configuration from resource metadata."),permissions:so.optional().describe("Sandbox permissions from resource metadata.")})});var yI=c.object({method:c.literal("ui/notifications/tool-result"),params:ji.describe("Standard MCP tool execution result.")}),Of=c.object({toolInfo:c.object({id:wt.optional().describe("JSON-RPC id of the tools/call request."),tool:cr.describe("Tool definition including name, inputSchema, etc.")}).optional().describe("Metadata of the tool call that instantiated this App."),theme:oI.optional().describe("Current color theme preference."),styles:vI.optional().describe("Style configuration for theming the app."),displayMode:$t.optional().describe("How the UI is currently displayed."),availableDisplayModes:c.array($t).optional().describe("Display modes the host supports."),containerDimensions:c.union([c.object({height:c.number().describe("Fixed container height in pixels.")}),c.object({maxHeight:c.union([c.number(),c.undefined()]).optional().describe("Maximum container height in pixels.")})]).and(c.union([c.object({width:c.number().describe("Fixed container width in pixels.")}),c.object({maxWidth:c.union([c.number(),c.undefined()]).optional().describe("Maximum container width in pixels.")})])).optional().describe(`Container dimensions. Represents the dimensions of the iframe or other
308
- container holding the app. Specify either width or maxWidth, and either height or maxHeight.`),locale:c.string().optional().describe("User's language and region preference in BCP 47 format."),timeZone:c.string().optional().describe("User's timezone in IANA format."),userAgent:c.string().optional().describe("Host application identifier."),platform:c.union([c.literal("web"),c.literal("desktop"),c.literal("mobile")]).optional().describe("Platform type for responsive design decisions."),deviceCapabilities:c.object({touch:c.boolean().optional().describe("Whether the device supports touch input."),hover:c.boolean().optional().describe("Whether the device supports hover interactions.")}).optional().describe("Device input capabilities."),safeAreaInsets:c.object({top:c.number().describe("Top safe area inset in pixels."),right:c.number().describe("Right safe area inset in pixels."),bottom:c.number().describe("Bottom safe area inset in pixels."),left:c.number().describe("Left safe area inset in pixels.")}).optional().describe("Mobile safe area boundaries in pixels.")}).passthrough(),kI=c.object({method:c.literal("ui/notifications/host-context-changed"),params:Of.describe("Partial context update containing only changed fields.")});c.object({method:c.literal("ui/update-model-context"),params:c.object({content:c.array(Ot).optional().describe("Context content blocks (text, image, etc.)."),structuredContent:c.record(c.string(),c.unknown().describe("Structured content for machine-readable context data.")).optional().describe("Structured content for machine-readable context data.")})});c.object({method:c.literal("ui/initialize"),params:c.object({appInfo:Ti.describe("App identification (name and version)."),appCapabilities:_I.describe("Features and capabilities this app provides."),protocolVersion:c.string().describe("Protocol version this app supports.")})});var II=c.object({protocolVersion:c.string().describe('Negotiated protocol version string (e.g., "2025-11-21").'),hostInfo:Ti.describe("Host application identification and version."),hostCapabilities:gI.describe("Features and capabilities provided by the host."),hostContext:Of.describe("Rich context about the host environment.")}).passthrough();class wI extends S${_appInfo;_capabilities;options;_hostCapabilities;_hostInfo;_hostContext;constructor(t,n={},r={autoResize:!0}){super(r),this._appInfo=t,this._capabilities=n,this.options=r,this.setRequestHandler(Ni,i=>(console.log("Received ping:",i.params),{})),this.onhostcontextchanged=()=>{}}getHostCapabilities(){return this._hostCapabilities}getHostVersion(){return this._hostInfo}getHostContext(){return this._hostContext}set ontoolinput(t){this.setNotificationHandler(dI,n=>t(n.params))}set ontoolinputpartial(t){this.setNotificationHandler(mI,n=>t(n.params))}set ontoolresult(t){this.setNotificationHandler(yI,n=>t(n.params))}set ontoolcancelled(t){this.setNotificationHandler(fI,n=>t(n.params))}set onhostcontextchanged(t){this.setNotificationHandler(kI,n=>{this._hostContext={...this._hostContext,...n.params},t(n.params)})}set onteardown(t){this.setRequestHandler(hI,(n,r)=>t(n.params,r))}set oncalltool(t){this.setRequestHandler(Ls,(n,r)=>t(n.params,r))}set onlisttools(t){this.setRequestHandler(Cs,(n,r)=>t(n.params,r))}assertCapabilityForMethod(t){}assertRequestHandlerCapability(t){switch(t){case"tools/call":case"tools/list":if(!this._capabilities.tools)throw Error(`Client does not support tool capability (required for ${t})`);return;case"ping":case"ui/resource-teardown":return;default:throw Error(`No handler for method ${t} registered`)}}assertNotificationCapability(t){}assertTaskCapability(t){throw Error("Tasks are not supported in MCP Apps")}assertTaskHandlerCapability(t){throw Error("Task handlers are not supported in MCP Apps")}async callServerTool(t,n){return await this.request({method:"tools/call",params:t},ji,n)}sendMessage(t,n){return this.request({method:"ui/message",params:t},cI,n)}sendLog(t){return this.notification({method:"notifications/message",params:t})}updateModelContext(t,n){return this.request({method:"ui/update-model-context",params:t},Gn,n)}openLink(t,n){return this.request({method:"ui/open-link",params:t},lI,n)}sendOpenLink=this.openLink;requestDisplayMode(t,n){return this.request({method:"ui/request-display-mode",params:t},$I,n)}sendSizeChanged(t){return this.notification({method:"ui/notifications/size-changed",params:t})}setupSizeChangedNotifications(){let t=!1,n=0,r=0,i=()=>{t||(t=!0,requestAnimationFrame(()=>{t=!1;let o=document.documentElement,s=o.style.width,u=o.style.height;o.style.width="fit-content",o.style.height="fit-content";let l=o.getBoundingClientRect();o.style.width=s,o.style.height=u;let d=window.innerWidth-o.clientWidth,f=Math.ceil(l.width+d),p=Math.ceil(l.height);(f!==n||p!==r)&&(n=f,r=p,this.sendSizeChanged({width:f,height:p}))}))};i();let a=new ResizeObserver(i);return a.observe(document.documentElement),a.observe(document.body),()=>a.disconnect()}async connect(t=new z$(window.parent,window.parent),n){await super.connect(t);try{let r=await this.request({method:"ui/initialize",params:{appCapabilities:this._capabilities,appInfo:this._appInfo,protocolVersion:Z$}},II,n);if(r===void 0)throw Error(`Server sent invalid initialize result: ${r}`);this._hostCapabilities=r.hostCapabilities,this._hostInfo=r.hostInfo,this._hostContext=r.hostContext,await this.notification({method:"ui/notifications/initialized"}),this.options?.autoResize&&this.setupSizeChangedNotifications()}catch(r){throw this.close(),r}}}let Ze=null,rt=null,Ie=null,An=null;const Ko=new Map,mi=document.getElementById("loading"),SI=document.getElementById("app"),Tf=document.getElementById("title"),Nf=document.getElementById("subtitle"),xI=document.getElementById("metrics"),zI=document.getElementById("highlights"),bt=document.getElementById("figure-select"),ZI=document.getElementById("figure-title"),UI=document.getElementById("figure-file"),OI=document.getElementById("figure-caption"),$e=document.getElementById("main-image"),ci=document.getElementById("open-standalone"),Zn=document.getElementById("open-monitor-btn"),Ge=document.getElementById("copy-url"),Xo=document.getElementById("related-monitor"),TI=document.getElementById("download-current");function NI(e){const t=String(e.job_type??"train_som"),n=e.grid??[],r=e.quantization_error!=null?Number(e.quantization_error).toFixed(4):"N/A",i=e.topographic_error!=null?Number(e.topographic_error).toFixed(4):"N/A",a=String(e.n_samples??"N/A"),o=String(e.n_features??"N/A"),s=e.coverage??{},u=l=>l!=null?`${(Number(l)*100).toFixed(1)}%`:"N/A";return t==="train_floop_siom"?[{label:"Topology",value:String(e.topology??"free")},{label:"Nodes",value:String(e.occupied_nodes??e.final_length??"N/A")},{label:"QE",value:r},{label:"TE",value:i},{label:"Utilization",value:u(s.utilization)},{label:"Dead",value:u(s.dead_fraction)},{label:"Coverage",value:String(s.status??"N/A")},{label:"Samples",value:a},{label:"Features",value:o}]:t==="train_siom"?[{label:"Grid",value:n.length>=2?`${n[0]}×${n[1]}`:"N/A"},{label:"Model",value:"SIOM"},{label:"QE",value:r},{label:"TE",value:i},{label:"Utilization",value:u(s.utilization)},{label:"Dead",value:u(s.dead_fraction)},{label:"Coverage",value:String(s.status??"N/A")},{label:"Samples",value:a},{label:"Features",value:o}]:[{label:"Grid",value:n.length>=2?`${n[0]}×${n[1]}`:"N/A"},{label:"Model",value:String(e.model??"SOM")},{label:"QE",value:r},{label:"TE",value:i},{label:"Samples",value:a},{label:"Features",value:o}]}function jI(e,t){const n=e.summary??{},r=(n.files??[]).filter(s=>/\.(png|pdf|svg)$/i.test(s)),i=String(n.output_format??"png"),o=(r.length>0?r:[`combined.${i}`]).map(s=>{const u=s.replace(/\.(png|pdf|svg)$/i,""),l=u.startsWith("component_")?"component":u==="combined"||u==="coverage_overview"?"summary":"diagnostic";return{key:u,label:u.replace(/^component_\d+_/,"Component: ").replace(/_/g," "),filename:s,kind:l,caption:/\.(png|jpe?g|gif|webp)$/i.test(s)?void 0:"This figure is available for download, but it is not inline-displayable in this first iteration.",url:/\.(png|jpe?g|gif|webp)$/i.test(s)?`/api/results/${t}/image/${s}`:void 0}});return{type:"results-explorer",jobId:t,title:"Results Explorer",subtitle:String(e.label??""),metrics:NI(n),highlights:[n.training_duration_seconds!=null?`Training duration: ${n.training_duration_seconds}s`:"",n.selected_columns?`Variables: ${n.selected_columns.join(", ")}`:""].filter(Boolean),availableFigures:o,defaultFigureKey:o.find(s=>s.key==="combined"&&s.url)?.key??o.find(s=>!!s.url)?.key??o.find(s=>s.key==="combined")?.key??o[0]?.key,trainingMonitorUrl:`/viz/training-monitor?mode=standalone&job_id=${encodeURIComponent(t)}`,standaloneUrl:`${window.location.origin}/viz/results-explorer?mode=standalone&job_id=${encodeURIComponent(t)}`,relatedUrls:{trainingMonitor:`${window.location.origin}/viz/training-monitor?mode=standalone&job_id=${encodeURIComponent(t)}`,resultsExplorer:`${window.location.origin}/viz/results-explorer?mode=standalone&job_id=${encodeURIComponent(t)}`}}}function PI(e){xI.innerHTML=e.map(t=>`<div class="metric"><span class="metric-label">${t.label}</span><span class="metric-value">${t.value}</span></div>`).join("")}function EI(e){zI.innerHTML=e.length>0?e.map(t=>`<div class="highlight">${t}</div>`).join(""):'<div class="highlight">No extra highlights for this result yet.</div>'}const DI={summary:"Summary",diagnostic:"Diagnostics",component:"Components",artifact:"Other"},RI=["summary","diagnostic","component","artifact"];function AI(e){const t=new Map;for(const n of e){const r=n.kind??"diagnostic",i=t.get(r)??[];i.push(n),t.set(r,i)}bt.innerHTML="";for(const n of RI){const r=t.get(n);if(!r||r.length===0)continue;const i=document.createElement("optgroup");i.label=DI[n]??n;for(const a of r){const o=document.createElement("option");o.value=a.key,o.textContent=`${a.label} (${a.filename})`,a.key===rt?.key&&(o.selected=!0),i.appendChild(o)}bt.appendChild(i)}}bt.addEventListener("change",()=>{const e=Ze?.availableFigures.find(t=>t.key===bt.value);e&&jf(e)});async function CI(e,t){const n=`${e}/${t}`,r=Ko.get(n);if(r)return r;if(!Ie)return null;try{const a=(await Ie.callServerTool({name:"_fetch_figure",arguments:{job_id:e,filename:t}})).content?.find(o=>o.type==="image");if(a){const o=`data:${a.mimeType};base64,${a.data}`;return Ko.set(n,o),o}}catch{}return null}function LI(e){!Ie||!Ze||Ie.updateModelContext({content:[{type:"text",text:`User is viewing the "${e.label}" figure (${e.filename}) of results job ${Ze.jobId} in the Results Explorer.`}]}).catch(()=>{})}async function jf(e){if(rt=e,ZI.textContent=e.label,UI.textContent=e.filename,OI.textContent=e.caption??"",bt.value=e.key,LI(e),e.url)$e.src=e.url;else if(Ie&&Ze){const t=Ze.defaultFigureKey;if(e.key===t&&An)$e.src=An;else{$e.removeAttribute("src");const n=await CI(Ze.jobId,e.filename);n&&rt?.key===e.key&&($e.src=n)}}else $e.removeAttribute("src")}function MI(e){return e.standaloneUrl??window.location.href}function FI(e){const t=MI(e);e.standaloneUrl?(ci.href=e.standaloneUrl,ci.style.display="inline-flex"):Ef&&(ci.href=window.location.href,ci.style.display="inline-flex"),Ge.style.display="inline-flex",Ge.dataset.url=t;const n=e.relatedUrls?.trainingMonitor;n&&(Xo.href=n,Xo.style.display="inline-flex")}function Yo(e){Ze=e,Tf.textContent=e.title,Nf.textContent=e.subtitle??`Job ${e.jobId}`,PI(e.metrics),EI(e.highlights),FI(e);const t=e.trainingMonitorUrl??e.relatedUrls?.trainingMonitor??`/viz/training-monitor?mode=standalone&job_id=${encodeURIComponent(e.jobId)}`;Zn.href=t,Zn.style.display="inline-flex",Zn.title="View live or completed training curves for this job";const n=e.availableFigures.find(r=>r.key===e.defaultFigureKey)??e.availableFigures[0]??null;rt=n,AI(e.availableFigures),n&&jf(n),mi.style.display="none",SI.style.display="block"}TI.addEventListener("click",()=>{const e=$e.src;if(!e||!rt)return;const t=document.createElement("a");t.href=e,t.download=rt.filename,t.click()});Ge.addEventListener("click",async()=>{const e=Ge.dataset.url||Ze?.standaloneUrl||window.location.href;try{await navigator.clipboard.writeText(e),Ge.textContent="Copied!",setTimeout(()=>{Ge.textContent="Copy explorer URL"},1500)}catch{window.prompt("Copy explorer URL:",e)}});$e.style.cursor="zoom-in";$e.addEventListener("click",()=>{if(!Ie||!$e.src)return;const e=Ie.getHostContext?.(),t=e?.availableDisplayModes;if(!Array.isArray(t)||!t.includes("fullscreen"))return;const n=e?.displayMode==="fullscreen"?"inline":"fullscreen";Ie.requestDisplayMode({mode:n}).then(r=>{$e.style.cursor=r.mode==="fullscreen"?"zoom-out":"zoom-in"}).catch(()=>{})});const Pf=new URLSearchParams(window.location.search),Ef=Pf.get("mode")==="standalone",di=Pf.get("job_id");if(Ef&&di){const e="The local viz port is assigned per MCP session and changes when the proxy restarts. Re-run results_explorer for a fresh URL, or set BARIVIA_VIZ_PORT in your MCP env for a persistent port.";(async()=>{for(let n=1;n<=3;n++)try{const r=await fetch(`/api/results/${di}`);if(r.status===404){mi.textContent=`Results for job ${di} were not found (HTTP 404). The job may still be finalizing or was deleted, or this link points at a stale session. ${e}`;return}if(!r.ok)throw new Error(`HTTP ${r.status}`);const i=await r.json();Yo(jI(i,di));return}catch(r){if(n<3){await new Promise(i=>setTimeout(i,1e3*n));continue}mi.textContent=`Could not load results from the local viz server (${r instanceof Error?r.message:"error"}). ${e}`}})()}else{const e=new wI({name:"Results Explorer",version:"1.0.0"},{},{autoResize:!0});Ie=e,e.ontoolinput=t=>{const n=t?.arguments??{},r=n.job_id!=null?String(n.job_id):"";r&&(Tf.textContent="Results Explorer",Nf.textContent=`Loading job ${r}...`)},e.ontoolresult=t=>{const n=t.content?.find(i=>i.type==="image");n&&(An=`data:${n.mimeType};base64,${n.data}`);const r=t.structuredContent;if(!r||typeof r!="object"){mi.textContent="The results explorer did not receive structured data.";return}Yo(r)},e.connect()}</script>
308
+ container holding the app. Specify either width or maxWidth, and either height or maxHeight.`),locale:c.string().optional().describe("User's language and region preference in BCP 47 format."),timeZone:c.string().optional().describe("User's timezone in IANA format."),userAgent:c.string().optional().describe("Host application identifier."),platform:c.union([c.literal("web"),c.literal("desktop"),c.literal("mobile")]).optional().describe("Platform type for responsive design decisions."),deviceCapabilities:c.object({touch:c.boolean().optional().describe("Whether the device supports touch input."),hover:c.boolean().optional().describe("Whether the device supports hover interactions.")}).optional().describe("Device input capabilities."),safeAreaInsets:c.object({top:c.number().describe("Top safe area inset in pixels."),right:c.number().describe("Right safe area inset in pixels."),bottom:c.number().describe("Bottom safe area inset in pixels."),left:c.number().describe("Left safe area inset in pixels.")}).optional().describe("Mobile safe area boundaries in pixels.")}).passthrough(),kI=c.object({method:c.literal("ui/notifications/host-context-changed"),params:Of.describe("Partial context update containing only changed fields.")});c.object({method:c.literal("ui/update-model-context"),params:c.object({content:c.array(Ot).optional().describe("Context content blocks (text, image, etc.)."),structuredContent:c.record(c.string(),c.unknown().describe("Structured content for machine-readable context data.")).optional().describe("Structured content for machine-readable context data.")})});c.object({method:c.literal("ui/initialize"),params:c.object({appInfo:Ti.describe("App identification (name and version)."),appCapabilities:_I.describe("Features and capabilities this app provides."),protocolVersion:c.string().describe("Protocol version this app supports.")})});var II=c.object({protocolVersion:c.string().describe('Negotiated protocol version string (e.g., "2025-11-21").'),hostInfo:Ti.describe("Host application identification and version."),hostCapabilities:gI.describe("Features and capabilities provided by the host."),hostContext:Of.describe("Rich context about the host environment.")}).passthrough();class wI extends S${_appInfo;_capabilities;options;_hostCapabilities;_hostInfo;_hostContext;constructor(t,n={},r={autoResize:!0}){super(r),this._appInfo=t,this._capabilities=n,this.options=r,this.setRequestHandler(Ni,i=>(console.log("Received ping:",i.params),{})),this.onhostcontextchanged=()=>{}}getHostCapabilities(){return this._hostCapabilities}getHostVersion(){return this._hostInfo}getHostContext(){return this._hostContext}set ontoolinput(t){this.setNotificationHandler(dI,n=>t(n.params))}set ontoolinputpartial(t){this.setNotificationHandler(mI,n=>t(n.params))}set ontoolresult(t){this.setNotificationHandler(yI,n=>t(n.params))}set ontoolcancelled(t){this.setNotificationHandler(fI,n=>t(n.params))}set onhostcontextchanged(t){this.setNotificationHandler(kI,n=>{this._hostContext={...this._hostContext,...n.params},t(n.params)})}set onteardown(t){this.setRequestHandler(hI,(n,r)=>t(n.params,r))}set oncalltool(t){this.setRequestHandler(Ls,(n,r)=>t(n.params,r))}set onlisttools(t){this.setRequestHandler(Cs,(n,r)=>t(n.params,r))}assertCapabilityForMethod(t){}assertRequestHandlerCapability(t){switch(t){case"tools/call":case"tools/list":if(!this._capabilities.tools)throw Error(`Client does not support tool capability (required for ${t})`);return;case"ping":case"ui/resource-teardown":return;default:throw Error(`No handler for method ${t} registered`)}}assertNotificationCapability(t){}assertTaskCapability(t){throw Error("Tasks are not supported in MCP Apps")}assertTaskHandlerCapability(t){throw Error("Task handlers are not supported in MCP Apps")}async callServerTool(t,n){return await this.request({method:"tools/call",params:t},ji,n)}sendMessage(t,n){return this.request({method:"ui/message",params:t},cI,n)}sendLog(t){return this.notification({method:"notifications/message",params:t})}updateModelContext(t,n){return this.request({method:"ui/update-model-context",params:t},Gn,n)}openLink(t,n){return this.request({method:"ui/open-link",params:t},lI,n)}sendOpenLink=this.openLink;requestDisplayMode(t,n){return this.request({method:"ui/request-display-mode",params:t},$I,n)}sendSizeChanged(t){return this.notification({method:"ui/notifications/size-changed",params:t})}setupSizeChangedNotifications(){let t=!1,n=0,r=0,i=()=>{t||(t=!0,requestAnimationFrame(()=>{t=!1;let o=document.documentElement,s=o.style.width,u=o.style.height;o.style.width="fit-content",o.style.height="fit-content";let l=o.getBoundingClientRect();o.style.width=s,o.style.height=u;let d=window.innerWidth-o.clientWidth,f=Math.ceil(l.width+d),p=Math.ceil(l.height);(f!==n||p!==r)&&(n=f,r=p,this.sendSizeChanged({width:f,height:p}))}))};i();let a=new ResizeObserver(i);return a.observe(document.documentElement),a.observe(document.body),()=>a.disconnect()}async connect(t=new z$(window.parent,window.parent),n){await super.connect(t);try{let r=await this.request({method:"ui/initialize",params:{appCapabilities:this._capabilities,appInfo:this._appInfo,protocolVersion:Z$}},II,n);if(r===void 0)throw Error(`Server sent invalid initialize result: ${r}`);this._hostCapabilities=r.hostCapabilities,this._hostInfo=r.hostInfo,this._hostContext=r.hostContext,await this.notification({method:"ui/notifications/initialized"}),this.options?.autoResize&&this.setupSizeChangedNotifications()}catch(r){throw this.close(),r}}}let Ze=null,rt=null,Ie=null,An=null;const Ko=new Map,mi=document.getElementById("loading"),SI=document.getElementById("app"),Tf=document.getElementById("title"),Nf=document.getElementById("subtitle"),xI=document.getElementById("metrics"),zI=document.getElementById("highlights"),bt=document.getElementById("figure-select"),ZI=document.getElementById("figure-title"),UI=document.getElementById("figure-file"),OI=document.getElementById("figure-caption"),$e=document.getElementById("main-image"),ci=document.getElementById("open-standalone"),Zn=document.getElementById("open-monitor-btn"),Ge=document.getElementById("copy-url"),Xo=document.getElementById("related-monitor"),TI=document.getElementById("download-current");function NI(e){const t=String(e.job_type??"train_som"),n=e.grid??[],r=e.quantization_error!=null?Number(e.quantization_error).toFixed(4):"N/A",i=e.topographic_error!=null?Number(e.topographic_error).toFixed(4):"N/A",a=String(e.n_samples??"N/A"),o=String(e.n_features??"N/A"),s=e.coverage??{},u=l=>l!=null?`${(Number(l)*100).toFixed(1)}%`:"N/A";return t==="train_floop_siom"?[{label:"Topology",value:String(e.topology??"free")},{label:"Nodes",value:String(e.occupied_nodes??e.final_length??"N/A")},{label:"QE",value:r},{label:"TE",value:i},{label:"Utilization",value:u(s.utilization)},{label:"Dead",value:u(s.dead_fraction)},{label:"Coverage",value:String(s.status??"N/A")},{label:"Samples",value:a},{label:"Features",value:o}]:t==="train_siom"?[{label:"Grid",value:n.length>=2?`${n[0]}×${n[1]}`:"N/A"},{label:"Model",value:"SIOM"},{label:"QE",value:r},{label:"TE",value:i},{label:"Utilization",value:u(s.utilization)},{label:"Dead",value:u(s.dead_fraction)},{label:"Coverage",value:String(s.status??"N/A")},{label:"Samples",value:a},{label:"Features",value:o}]:[{label:"Grid",value:n.length>=2?`${n[0]}×${n[1]}`:"N/A"},{label:"Model",value:String(e.model??"SOM")},{label:"QE",value:r},{label:"TE",value:i},{label:"Samples",value:a},{label:"Features",value:o}]}function jI(e,t){const n=e.summary??{},r=(n.files??[]).filter(s=>/\.(png|pdf|svg)$/i.test(s)),i=String(n.output_format??"png"),o=(r.length>0?r:[`combined.${i}`]).map(s=>{const u=s.replace(/\.(png|pdf|svg)$/i,""),l=u.startsWith("component_")?"component":u==="combined"||u==="coverage_overview"?"summary":"diagnostic";return{key:u,label:u.replace(/^component_\d+_/,"Component: ").replace(/_/g," "),filename:s,kind:l,caption:/\.(png|jpe?g|gif|webp)$/i.test(s)?void 0:"This figure is available for download, but it is not inline-displayable in this first iteration.",url:/\.(png|jpe?g|gif|webp)$/i.test(s)?`/api/results/${t}/image/${s}`:void 0}});return{type:"results-explorer",jobId:t,title:"Results Explorer",subtitle:String(e.label??""),metrics:NI(n),highlights:[n.training_duration_seconds!=null?`Training duration: ${n.training_duration_seconds}s`:"",n.selected_columns?`Variables: ${n.selected_columns.join(", ")}`:""].filter(Boolean),availableFigures:o,defaultFigureKey:o.find(s=>s.key==="combined"&&s.url)?.key??o.find(s=>!!s.url)?.key??o.find(s=>s.key==="combined")?.key??o[0]?.key,trainingMonitorUrl:`/viz/training-monitor?mode=standalone&job_id=${encodeURIComponent(t)}`,standaloneUrl:`${window.location.origin}/viz/results-explorer?mode=standalone&job_id=${encodeURIComponent(t)}`,relatedUrls:{trainingMonitor:`${window.location.origin}/viz/training-monitor?mode=standalone&job_id=${encodeURIComponent(t)}`,resultsExplorer:`${window.location.origin}/viz/results-explorer?mode=standalone&job_id=${encodeURIComponent(t)}`}}}function PI(e){xI.innerHTML=e.map(t=>`<div class="metric"><span class="metric-label">${t.label}</span><span class="metric-value">${t.value}</span></div>`).join("")}function EI(e){zI.innerHTML=e.length>0?e.map(t=>`<div class="highlight">${t}</div>`).join(""):'<div class="highlight">No extra highlights for this result yet.</div>'}const DI={summary:"Summary",diagnostic:"Diagnostics",component:"Components",artifact:"Other"},RI=["summary","diagnostic","component","artifact"];function AI(e){const t=new Map;for(const n of e){const r=n.kind??"diagnostic",i=t.get(r)??[];i.push(n),t.set(r,i)}bt.innerHTML="";for(const n of RI){const r=t.get(n);if(!r||r.length===0)continue;const i=document.createElement("optgroup");i.label=DI[n]??n;for(const a of r){const o=document.createElement("option");o.value=a.key,o.textContent=`${a.label} (${a.filename})`,a.key===rt?.key&&(o.selected=!0),i.appendChild(o)}bt.appendChild(i)}}bt.addEventListener("change",()=>{const e=Ze?.availableFigures.find(t=>t.key===bt.value);e&&jf(e)});async function CI(e,t){const n=`${e}/${t}`,r=Ko.get(n);if(r)return r;if(!Ie)return null;try{const a=(await Ie.callServerTool({name:"_fetch_figure",arguments:{job_id:e,filename:t}})).content?.find(o=>o.type==="image");if(a){const o=`data:${a.mimeType};base64,${a.data}`;return Ko.set(n,o),o}}catch{}return null}function LI(e){!Ie||!Ze||Ie.updateModelContext({content:[{type:"text",text:`User is viewing the "${e.label}" figure (${e.filename}) of results job ${Ze.jobId} in the Results Explorer.`}]}).catch(()=>{})}async function jf(e){if(rt=e,ZI.textContent=e.label,UI.textContent=e.filename,OI.textContent=e.caption??"",bt.value=e.key,LI(e),e.url)$e.src=e.url;else if(Ie&&Ze){const t=Ze.defaultFigureKey;if(e.key===t&&An)$e.src=An;else{$e.removeAttribute("src");const n=await CI(Ze.jobId,e.filename);n&&rt?.key===e.key&&($e.src=n)}}else $e.removeAttribute("src")}function MI(e){return e.standaloneUrl??window.location.href}function FI(e){const t=MI(e);e.standaloneUrl?(ci.href=e.standaloneUrl,ci.style.display="inline-flex"):Ef&&(ci.href=window.location.href,ci.style.display="inline-flex"),Ge.style.display="inline-flex",Ge.dataset.url=t;const n=e.relatedUrls?.trainingMonitor;n&&(Xo.href=n,Xo.style.display="inline-flex")}function Yo(e){Ze=e,Tf.textContent=e.title,Nf.textContent=e.subtitle??`Job ${e.jobId}`,PI(e.metrics),EI(e.highlights),FI(e);const t=e.trainingMonitorUrl??e.relatedUrls?.trainingMonitor??`/viz/training-monitor?mode=standalone&job_id=${encodeURIComponent(e.jobId)}`;Zn.href=t,Zn.style.display="inline-flex",Zn.title="View live or completed training curves for this job";const n=e.availableFigures.find(r=>r.key===e.defaultFigureKey)??e.availableFigures[0]??null;rt=n,AI(e.availableFigures),n&&jf(n),mi.style.display="none",SI.style.display="block"}TI.addEventListener("click",()=>{const e=$e.src;if(!e||!rt)return;const t=document.createElement("a");t.href=e,t.download=rt.filename,t.click()});Ge.addEventListener("click",async()=>{const e=Ge.dataset.url||Ze?.standaloneUrl||window.location.href;try{await navigator.clipboard.writeText(e),Ge.textContent="Copied!",setTimeout(()=>{Ge.textContent="Copy explorer URL"},1500)}catch{window.prompt("Copy explorer URL:",e)}});$e.style.cursor="zoom-in";$e.addEventListener("click",()=>{if(!Ie||!$e.src)return;const e=Ie.getHostContext?.(),t=e?.availableDisplayModes;if(!Array.isArray(t)||!t.includes("fullscreen"))return;const n=e?.displayMode==="fullscreen"?"inline":"fullscreen";Ie.requestDisplayMode({mode:n}).then(r=>{$e.style.cursor=r.mode==="fullscreen"?"zoom-out":"zoom-in"}).catch(()=>{})});const Pf=new URLSearchParams(window.location.search),Ef=Pf.get("mode")==="standalone",di=Pf.get("job_id");if(Ef&&di){const e="The local viz port is assigned per MCP session and changes when the proxy restarts. Re-run results_explorer for a fresh URL, or set BARIVIA_VIZ_PORT in your MCP env for a persistent port.";(async()=>{for(let n=1;n<=3;n++)try{const r=await fetch(`/api/results/${di}`);if(r.status===404){mi.innerHTML=`Results for job ${di} were not found (HTTP 404). The job may still be finalizing or was deleted, or this link points at a stale session. ${e}<br><button type="button" id="viz-retry-load" style="margin-top:0.75rem">Retry</button>`,document.getElementById("viz-retry-load")?.addEventListener("click",()=>location.reload());return}if(!r.ok)throw new Error(`HTTP ${r.status}`);const i=await r.json();Yo(jI(i,di));return}catch(r){if(n<3){await new Promise(i=>setTimeout(i,1e3*n));continue}mi.innerHTML=`Could not load results from the local viz server (${r instanceof Error?r.message:"error"}). ${e}<br><button type="button" id="viz-retry-load" style="margin-top:0.75rem">Retry</button>`,document.getElementById("viz-retry-load")?.addEventListener("click",()=>location.reload())}})()}else{const e=new wI({name:"Results Explorer",version:"1.0.0"},{},{autoResize:!0});Ie=e,e.ontoolinput=t=>{const n=t?.arguments??{},r=n.job_id!=null?String(n.job_id):"";r&&(Tf.textContent="Results Explorer",Nf.textContent=`Loading job ${r}...`)},e.ontoolresult=t=>{const n=t.content?.find(i=>i.type==="image");n&&(An=`data:${n.mimeType};base64,${n.data}`);const r=t.structuredContent;if(!r||typeof r!="object"){mi.textContent="The results explorer did not receive structured data.";return}Yo(r)},e.connect()}</script>
309
309
  </head>
310
310
  <body>
311
311
  <div id="loading">Loading results explorer...</div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barivia/barsom-mcp",
3
- "version": "0.23.10",
3
+ "version": "0.23.11",
4
4
  "description": "barSOM MCP proxy — connect any MCP client to the barSOM cloud API for Self-Organizing Map analytics",
5
5
  "keywords": [
6
6
  "mcp",