@barivia/barmesh-mcp 0.7.1 → 0.8.2

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/shared.js CHANGED
@@ -20,18 +20,22 @@ import { logInfo, logWarn } from "./logger.js";
20
20
  export const API_URL = process.env.BARIVIA_API_URL ?? "https://api.barivia.se";
21
21
  export const API_KEY = process.env.BARIVIA_API_KEY ?? "";
22
22
  export const FETCH_TIMEOUT_MS = parseInt(process.env.BARIVIA_FETCH_TIMEOUT_MS ?? "60000", 10);
23
- export const MAX_RETRIES = 2;
24
- export const RETRY_BASE_MS = parseInt(process.env.BARIVIA_RETRY_BASE_MS ?? "1000", 10);
23
+ /** Retries after the first attempt; default 2 → 3 total tries. Override with BARIVIA_MAX_RETRIES. */
24
+ export const MAX_RETRIES = parseInt(process.env.BARIVIA_MAX_RETRIES ?? "2", 10);
25
+ /** Delay between burst retries (ms). Default 2000 → ~2s between each of the 3 attempts. */
26
+ export const RETRY_BASE_MS = parseInt(process.env.BARIVIA_RETRY_BASE_MS ?? "2000", 10);
25
27
  export const RETRYABLE_STATUS = new Set([502, 503, 504]);
26
- /** Exponential backoff for transient API retries (attempt 0 base, 1 2×, …). */
27
- export function retryDelayMs(attempt) {
28
- return RETRY_BASE_MS * 2 ** attempt;
28
+ /** Cool-down hint after the burst is exhausted agents should wait before another round. */
29
+ export const UNAVAILABLE_RETRY_AFTER_SEC = 30;
30
+ /** Fixed delay between burst retries (not exponential). */
31
+ export function retryDelayMs(_attempt) {
32
+ return RETRY_BASE_MS;
29
33
  }
30
34
  function newRequestId() {
31
35
  return randomUUID();
32
36
  }
33
37
  /** Single source of truth for the proxy version. Keep in sync with package.json on bump. */
34
- export const CLIENT_VERSION = "0.7.1";
38
+ export const CLIENT_VERSION = "0.8.2";
35
39
  export const PUBLIC_SITE_ORIGIN = "https://barivia.se";
36
40
  /** Large per-cell CSV uploads may exceed the default fetch timeout. */
37
41
  export const UPLOAD_DATASET_TIMEOUT_MS = 180_000;
@@ -139,6 +143,51 @@ export function getClientSupportsMcpApps() {
139
143
  export function setClientSupportsMcpApps(value) {
140
144
  _clientSupportsMcpApps = value;
141
145
  }
146
+ export const BARMESH_VIZ_VIEWS = {
147
+ trainingMonitor: "barmesh-training-monitor",
148
+ resultsExplorer: "barmesh-results-explorer",
149
+ };
150
+ export function buildStandaloneVizPageUrl(port, viewPath, jobId) {
151
+ if (!port)
152
+ return undefined;
153
+ return `http://localhost:${port}/viz/${viewPath}?mode=standalone&job_id=${encodeURIComponent(jobId)}`;
154
+ }
155
+ export function buildVizRelatedUrls(port, jobId, views) {
156
+ if (!port)
157
+ return undefined;
158
+ return {
159
+ trainingMonitor: buildStandaloneVizPageUrl(port, views.trainingMonitor, jobId),
160
+ resultsExplorer: buildStandaloneVizPageUrl(port, views.resultsExplorer, jobId),
161
+ };
162
+ }
163
+ /** Prominent standalone URL blocks for tool results — always emitted when a viz port exists. */
164
+ export function buildVizUrlContentBlocks(options) {
165
+ const { label, toolName, standaloneUrl, port, jobId, clientSupportsMcpApps } = options;
166
+ if (!standaloneUrl)
167
+ return [];
168
+ const embedNote = clientSupportsMcpApps
169
+ ? "Embedded panel if your host supports MCP Apps — otherwise open the standalone URL below (expected on some Cursor builds)."
170
+ : "Open the standalone URL below when no inline panel appears.";
171
+ const health = port > 0
172
+ ? ` Health check: http://localhost:${port}/api/health?job_id=${encodeURIComponent(jobId)}`
173
+ : "";
174
+ const blocks = [
175
+ {
176
+ type: "text",
177
+ text: `${embedNote}\n` +
178
+ `Standalone ${label} URL (copy if Open is blocked):\n${standaloneUrl}\n` +
179
+ `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` +
180
+ `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}`,
181
+ },
182
+ ];
183
+ if (!clientSupportsMcpApps) {
184
+ blocks.push({
185
+ type: "text",
186
+ text: `[Open ${label}](${standaloneUrl})`,
187
+ });
188
+ }
189
+ return blocks;
190
+ }
142
191
  /** Tool result carrying both a structured payload (for the MCP App view) and text/image content. */
143
192
  export function structuredTextResult(structuredContent, text, content) {
144
193
  return {
@@ -247,47 +296,227 @@ export async function resolveFilePathForUpload(filePath, mcpServer) {
247
296
  const resolved = path.resolve(root, trimmed);
248
297
  return checkUnder(resolved);
249
298
  }
250
- export function formatApiErrorMessage(status, bodyText, requestId) {
299
+ function looksLikeHtml(bodyText) {
300
+ const t = bodyText.trim().slice(0, 200).toLowerCase();
301
+ return t.startsWith("<!doctype") || t.startsWith("<html") || /<\s*html[\s>]/.test(t);
302
+ }
303
+ function isStorageErrorCode(code) {
304
+ if (!code)
305
+ return false;
306
+ const c = code.toLowerCase();
307
+ return c === "storage_upload_failed" || (c.includes("storage") && !c.includes("unavailable"));
308
+ }
309
+ /**
310
+ * Classify an HTTP API failure into a compact structured error.
311
+ * Gateway HTML 502/503 bodies are never echoed — they become api_unreachable.
312
+ */
313
+ export function classifyApiError(status, bodyText, requestId) {
251
314
  let parsed = null;
252
- try {
253
- const j = JSON.parse(bodyText);
254
- if (j && typeof j === "object")
255
- parsed = j;
256
- }
257
- catch {
258
- /* ignore */
315
+ const html = looksLikeHtml(bodyText);
316
+ if (!html && bodyText.trim()) {
317
+ try {
318
+ const j = JSON.parse(bodyText);
319
+ if (j && typeof j === "object")
320
+ parsed = j;
321
+ }
322
+ catch {
323
+ /* non-JSON */
324
+ }
259
325
  }
260
- const detail = (parsed?.error != null && String(parsed.error)) ||
261
- (bodyText.trim() ? bodyText.trim() : `HTTP ${status}`);
262
- const code = parsed?.error_code != null ? ` (error_code: ${parsed.error_code})` : "";
326
+ const apiCode = parsed?.error_code != null ? String(parsed.error_code) : "";
327
+ const apiMsg = parsed?.error != null && String(parsed.error).trim()
328
+ ? String(parsed.error).trim()
329
+ : "";
330
+ let cause = "server";
331
+ let error_code = apiCode || `http_${status}`;
332
+ let message = apiMsg || `HTTP ${status}`;
333
+ let hint = "";
334
+ let retryable = status === 502 || status === 503 || status === 504;
263
335
  const accountHint = ` Regenerate or verify your key via ${PUBLIC_SITE_ORIGIN} if needed.`;
264
- const hint = status === 400
265
- ? " Check parameter types and required fields."
266
- : status === 401
267
- ? ` Check BARIVIA_API_KEY in your MCP config.${accountHint}`
268
- : status === 403
269
- ? ` Access denied your plan does not include the CFD capability. Add the CFD capability at ${PUBLIC_SITE_ORIGIN}/dashboard to enable the barmesh tools.`
270
- : status === 404
271
- ? " The resource may not exist or may have been deleted."
272
- : status === 409
273
- ? " The job may not be in the expected state."
274
- : status === 429
275
- ? " Plan limit or rate limit — read the error above; delete unused datasets or wait and retry."
276
- : status === 502
277
- ? " Object storage error from API — retry later."
278
- : status === 503
279
- ? " API or database temporarily unavailable — retry later."
280
- : status >= 500
281
- ? " Server error retry later."
282
- : "";
283
- const rid = ` (request id: ${requestId} include if contacting support)`;
284
- return `${detail}${code}${hint}${rid}`;
336
+ if (status === 400) {
337
+ cause = "client";
338
+ hint = "Check parameter types and required fields.";
339
+ retryable = false;
340
+ }
341
+ else if (status === 401) {
342
+ cause = "auth";
343
+ error_code = apiCode || "unauthorized";
344
+ hint = `Check BARIVIA_API_KEY in your MCP config.${accountHint}`;
345
+ retryable = false;
346
+ }
347
+ else if (status === 403) {
348
+ cause = "auth";
349
+ error_code = apiCode || "forbidden";
350
+ hint = `Access denied — your plan does not include the CFD capability. Add the CFD capability at ${PUBLIC_SITE_ORIGIN}/dashboard to enable the barmesh tools.`;
351
+ retryable = false;
352
+ }
353
+ else if (status === 404) {
354
+ cause = "client";
355
+ hint = "The resource may not exist or may have been deleted.";
356
+ retryable = false;
357
+ }
358
+ else if (status === 409) {
359
+ cause = "client";
360
+ hint = "The job may not be in the expected state.";
361
+ retryable = false;
362
+ }
363
+ else if (status === 429) {
364
+ cause = "rate_limit";
365
+ hint = "Plan limit or rate limit — delete unused datasets or wait and retry.";
366
+ retryable = true;
367
+ }
368
+ else if (status === 502 || status === 503 || status === 504) {
369
+ if (isStorageErrorCode(apiCode) || /object storage|r2\/s3|storage upload/i.test(apiMsg)) {
370
+ cause = "storage";
371
+ error_code = apiCode || "storage_upload_failed";
372
+ message = apiMsg || "Object storage (R2/S3) error from API.";
373
+ hint = "Retry later; feedback may still succeed via DB when storage is degraded.";
374
+ }
375
+ else if (html || !parsed) {
376
+ cause = "api_unreachable";
377
+ error_code = apiCode || "api_unreachable";
378
+ message = "Mesh analysis API temporarily unavailable (gateway/tunnel returned a non-JSON error).";
379
+ hint =
380
+ `Proxy already tried 3 times (~2s apart). Wait ~${UNAVAILABLE_RETRY_AFTER_SEC}s, then retry. ` +
381
+ "Use barmesh_api_health — /health does not depend on R2. Keep reviewing last good distances/figures; do not paste HTML.";
382
+ }
383
+ else if (apiCode === "feedback_unavailable" || apiCode === "database_error") {
384
+ cause = "server";
385
+ error_code = apiCode;
386
+ message = apiMsg || "API or database temporarily unavailable.";
387
+ hint =
388
+ `Wait ~${UNAVAILABLE_RETRY_AFTER_SEC}s; barmesh_send_feedback queues locally if the API stays down.`;
389
+ }
390
+ else {
391
+ cause = "server";
392
+ message = apiMsg || `HTTP ${status}`;
393
+ hint = `API or database temporarily unavailable — wait ~${UNAVAILABLE_RETRY_AFTER_SEC}s, then retry.`;
394
+ }
395
+ }
396
+ else if (status >= 500) {
397
+ cause = "server";
398
+ hint = `Server error — wait ~${UNAVAILABLE_RETRY_AFTER_SEC}s, then retry.`;
399
+ retryable = true;
400
+ }
401
+ else if (!apiMsg && html) {
402
+ cause = "api_unreachable";
403
+ error_code = "api_unreachable";
404
+ message = `HTTP ${status} (non-JSON gateway body)`;
405
+ hint = `Wait ~${UNAVAILABLE_RETRY_AFTER_SEC}s, then retry. Do not paste HTML.`;
406
+ retryable = status >= 500;
407
+ }
408
+ const retry_after_sec = retryable ? UNAVAILABLE_RETRY_AFTER_SEC : undefined;
409
+ return {
410
+ status,
411
+ error_code,
412
+ message,
413
+ hint,
414
+ request_id: requestId,
415
+ retryable,
416
+ cause,
417
+ ...(retry_after_sec !== undefined ? { retry_after_sec } : {}),
418
+ };
419
+ }
420
+ /** User-visible API error line (slim; includes request id). Exported for tests. */
421
+ export function formatApiErrorMessage(status, bodyText, requestId) {
422
+ const c = classifyApiError(status, bodyText, requestId);
423
+ const parts = [
424
+ c.message,
425
+ `error_code=${c.error_code}`,
426
+ `cause=${c.cause}`,
427
+ c.retry_after_sec != null ? `retry_after_sec=${c.retry_after_sec}` : "",
428
+ c.hint,
429
+ `request_id=${c.request_id}`,
430
+ ].filter((p) => p && String(p).trim());
431
+ return parts.join(" | ");
285
432
  }
286
433
  function throwApiError(status, bodyText, requestId) {
434
+ const classified = classifyApiError(status, bodyText, requestId);
287
435
  const err = new Error(formatApiErrorMessage(status, bodyText, requestId));
288
436
  err.httpStatus = status;
437
+ err.errorCode = classified.error_code;
438
+ err.requestId = requestId;
439
+ err.retryable = classified.retryable;
440
+ err.cause = classified.cause;
441
+ err.retryAfterSec = classified.retry_after_sec;
442
+ err.classified = classified;
289
443
  throw err;
290
444
  }
445
+ /** Final network failure after the burst — same calm shape as gateway HTML 502. */
446
+ function throwNetworkUnreachable(requestId, err) {
447
+ const detail = err instanceof Error ? err.message : String(err);
448
+ const classified = {
449
+ status: 0,
450
+ error_code: "api_unreachable",
451
+ message: "Mesh analysis API temporarily unavailable (network error).",
452
+ hint: `Proxy already tried 3 times (~2s apart). Wait ~${UNAVAILABLE_RETRY_AFTER_SEC}s, then retry. ` +
453
+ `Detail: ${detail.slice(0, 120)}`,
454
+ request_id: requestId,
455
+ retryable: true,
456
+ cause: "api_unreachable",
457
+ retry_after_sec: UNAVAILABLE_RETRY_AFTER_SEC,
458
+ };
459
+ const msg = [
460
+ classified.message,
461
+ `error_code=${classified.error_code}`,
462
+ `cause=${classified.cause}`,
463
+ `retry_after_sec=${classified.retry_after_sec}`,
464
+ classified.hint,
465
+ `request_id=${classified.request_id}`,
466
+ ].join(" | ");
467
+ const out = new Error(msg);
468
+ out.httpStatus = 0;
469
+ out.errorCode = classified.error_code;
470
+ out.requestId = requestId;
471
+ out.retryable = true;
472
+ out.cause = "api_unreachable";
473
+ out.retryAfterSec = UNAVAILABLE_RETRY_AFTER_SEC;
474
+ out.classified = classified;
475
+ throw out;
476
+ }
477
+ /**
478
+ * Liveness (+ optional readiness) probe against the API origin.
479
+ * Uses GET /health which never touches R2 — safe during storage outages.
480
+ */
481
+ export async function probeApiHealth() {
482
+ const base = { storage: "not_probed" };
483
+ try {
484
+ const live = await fetchWithTimeout(`${API_URL}/health`, { method: "GET" }, 8_000);
485
+ const liveText = await live.text();
486
+ if (!live.ok) {
487
+ return { ...base, liveness: "down", readiness: "unknown", detail: `health HTTP ${live.status}` };
488
+ }
489
+ let readiness = "unknown";
490
+ let db;
491
+ let redis;
492
+ try {
493
+ const ready = await fetchWithTimeout(`${API_URL}/ready`, { method: "GET" }, 8_000);
494
+ const readyText = await ready.text();
495
+ try {
496
+ const j = JSON.parse(readyText);
497
+ readiness = ready.ok && j.status === "ready" ? "ready" : "not_ready";
498
+ db = j.db;
499
+ redis = j.redis;
500
+ }
501
+ catch {
502
+ readiness = ready.ok ? "ready" : "not_ready";
503
+ }
504
+ }
505
+ catch {
506
+ readiness = "unknown";
507
+ }
508
+ void liveText;
509
+ return { ...base, liveness: "ok", readiness, db, redis };
510
+ }
511
+ catch (err) {
512
+ return {
513
+ ...base,
514
+ liveness: "down",
515
+ readiness: "unknown",
516
+ detail: err instanceof Error ? err.message : String(err),
517
+ };
518
+ }
519
+ }
291
520
  // ---- Distributed-trace context (W3C traceparent) ----
292
521
  // One trace per logical tool action (scoped via AsyncLocalStorage in registerAuditedTool),
293
522
  // so the API + job chain reconstruct end-to-end. Fresh span per API call; falls back to a
@@ -375,9 +604,13 @@ export async function apiCall(method, pathPart, body, extraHeaders, requestTimeo
375
604
  if (err instanceof DOMException && err.name === "AbortError" && !err.httpStatus) {
376
605
  throw new Error(`Request timed out after ${effectiveTimeout}ms. Increase BARIVIA_FETCH_TIMEOUT_MS (e.g. 120000) for slow or large requests. (request id: ${requestId})`);
377
606
  }
607
+ if (err instanceof TypeError)
608
+ throwNetworkUnreachable(requestId, err);
378
609
  throw err;
379
610
  }
380
611
  }
612
+ if (lastError instanceof TypeError)
613
+ throwNetworkUnreachable(requestId, lastError);
381
614
  throw lastError;
382
615
  }
383
616
  /** Fetch raw bytes from the API (for image downloads). */
@@ -430,9 +663,13 @@ export async function apiRawCall(pathPart, requestTimeoutMs) {
430
663
  if (err instanceof DOMException && err.name === "AbortError" && !err.httpStatus) {
431
664
  throw new Error(`Image request timed out after ${effectiveTimeout}ms. Increase BARIVIA_FETCH_TIMEOUT_MS (e.g. 120000) for large images. (request id: ${requestId})`);
432
665
  }
666
+ if (err instanceof TypeError)
667
+ throwNetworkUnreachable(requestId, err);
433
668
  throw err;
434
669
  }
435
670
  }
671
+ if (lastError instanceof TypeError)
672
+ throwNetworkUnreachable(requestId, lastError);
436
673
  throw lastError;
437
674
  }
438
675
  // ---------------------------------------------------------------------------
@@ -485,6 +722,12 @@ export function getCaptionForImage(filename) {
485
722
  return "Wasserstein-1 (EMD) distance from each mesh to the reference. A monotone decrease toward the finest mesh indicates field-level convergence.";
486
723
  if (base === "EMD_stepwise")
487
724
  return "Stepwise EMD between consecutive meshes. Small, plateauing values on the finest pairs indicate the fingerprint has stopped changing.";
725
+ if (base === "KL_asymmetric")
726
+ return "Asymmetric pairwise mesh-fingerprint KL D_KL(P‖Q): above-diagonal cells are coarse→fine, below-diagonal are fine→coarse (meshes ordered coarse→fine). Use when SKL line plots hide directional disagreement.";
727
+ if (base === "divergence_kl")
728
+ return "Feature-plane symmetric KL heatmap (min-shift sum-normalized physical component planes). Measures association between SOM features — not mesh-vs-mesh fingerprint KL (see KL_ref / KL_asymmetric).";
729
+ if (base === "divergence_w1")
730
+ return "Feature-plane 1-Wasserstein heatmap on the same physical component-plane masses. Opt-in via barmesh_results(action=feature_divergences, metrics=[\"wasserstein\"]).";
488
731
  if (base === "plot_vol_all_meshes")
489
732
  return "Volume-weighted SOM fingerprints P_vol for every mesh in mesh_order (shared color scale).";
490
733
  if (base === "plot_vol_coarse_fine")
@@ -1,9 +1,11 @@
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, apiRawCall, getClientSupportsMcpApps, getVizPort, getCaptionForImage, mimeForFilename, structuredTextResult, tryAttachImage, } from "../shared.js";
4
+ import { apiCall, apiRawCall, BARMESH_VIZ_VIEWS, buildStandaloneVizPageUrl, buildVizRelatedUrls, getVizPort, getCaptionForImage, mimeForFilename, structuredTextResult, tryAttachImage, } from "../shared.js";
5
5
  import { FIGURE_SECTION_LABELS, FIGURE_SECTION_ORDER, figureSection, sortRank, } from "../figure_sections.js";
6
6
  import { buildMetadataStrip } from "../results_metadata.js";
7
+ import { appendUiDeliveryContent, buildStandaloneVizUrl, resolveUiDelivery, } from "../ui-delivery.js";
8
+ import { buildBarmeshVizStandaloneUrl } from "../viz_links.js";
7
9
  export const RESULTS_EXPLORER_URI = "ui://barmesh/results-explorer";
8
10
  export function sortFigures(figures) {
9
11
  return [...figures].sort((a, b) => FIGURE_SECTION_ORDER.indexOf(a.section ?? "diagnostics") -
@@ -38,6 +40,12 @@ function labelForFigure(filename) {
38
40
  return "Wasserstein (EMD) vs reference";
39
41
  if (base === "EMD_stepwise")
40
42
  return "Wasserstein (EMD) stepwise";
43
+ if (base === "KL_asymmetric")
44
+ return "Asymmetric mesh KL matrix (coarse↔fine)";
45
+ if (base === "divergence_kl")
46
+ return "Feature-plane symmetric KL (not mesh KL)";
47
+ if (base === "divergence_w1")
48
+ return "Feature-plane Wasserstein-1";
41
49
  if (base === "plot_vol_all_meshes")
42
50
  return "Volume fingerprint: all meshes";
43
51
  if (base === "plot_vol_coarse_fine")
@@ -111,6 +119,10 @@ export function buildPayload(jobId, data, jobMeta) {
111
119
  const files = (summary.files ?? []).filter((f) => /\.(png|pdf|svg)$/i.test(f));
112
120
  const figures = sortFigures(groupFigures(files));
113
121
  const port = getVizPort();
122
+ const standaloneUrl = buildStandaloneVizPageUrl(port, BARMESH_VIZ_VIEWS.resultsExplorer, jobId) ??
123
+ buildBarmeshVizStandaloneUrl("barmesh-results-explorer", jobId);
124
+ const trainingMonitorUrl = buildStandaloneVizPageUrl(port, BARMESH_VIZ_VIEWS.trainingMonitor, jobId) ??
125
+ buildBarmeshVizStandaloneUrl("barmesh-training-monitor", jobId);
114
126
  return {
115
127
  type: "barmesh-results-explorer",
116
128
  jobId,
@@ -120,9 +132,9 @@ export function buildPayload(jobId, data, jobMeta) {
120
132
  metrics: buildMetrics(summary),
121
133
  availableFigures: figures,
122
134
  defaultFigureKey: resolveDefaultFigureKey(figures),
123
- standaloneUrl: port
124
- ? `http://localhost:${port}/viz/barmesh-results-explorer?mode=standalone&job_id=${jobId}`
125
- : undefined,
135
+ standaloneUrl,
136
+ trainingMonitorUrl,
137
+ relatedUrls: buildVizRelatedUrls(port, jobId, BARMESH_VIZ_VIEWS),
126
138
  };
127
139
  }
128
140
  async function handleResultsExplorer(job_id) {
@@ -131,40 +143,40 @@ async function handleResultsExplorer(job_id) {
131
143
  apiCall("GET", `/v1/jobs/${job_id}`).catch(() => ({})),
132
144
  ]);
133
145
  const payload = buildPayload(job_id, data, jobMeta);
134
- const content = [
135
- {
136
- type: "text",
137
- text: [
138
- `Mesh convergence results explorer ready for job ${job_id}.`,
139
- payload.subtitle ? `Label: ${payload.subtitle}` : "",
140
- payload.metadataStrip?.[0] ?? "",
141
- ]
142
- .filter(Boolean)
143
- .join("\n"),
144
- },
145
- ];
146
+ const summaryText = [
147
+ `Mesh convergence results explorer ready for job ${job_id}.`,
148
+ payload.subtitle ? `Label: ${payload.subtitle}` : "",
149
+ payload.metadataStrip?.[0] ?? "",
150
+ ]
151
+ .filter(Boolean)
152
+ .join("\n");
153
+ const content = [{ type: "text", text: summaryText }];
154
+ let inlineImageAttached = false;
146
155
  const defaultFigure = payload.availableFigures.find((f) => f.key === payload.defaultFigureKey);
147
156
  if (defaultFigure && /\.(png|svg|jpe?g|webp)$/i.test(defaultFigure.filename)) {
157
+ const before = content.length;
148
158
  await tryAttachImage(content, job_id, defaultFigure.filename);
159
+ inlineImageAttached = content.length > before && content.some((c) => c.type === "image");
149
160
  }
150
- if (payload.standaloneUrl) {
151
- content.push({
152
- type: "text",
153
- text: `Standalone results explorer URL (copy if Open is blocked):\n${payload.standaloneUrl}\n` +
154
- `AGENT: surface this URL to the user — markdown link or copy/paste into a browser when the inline MCP panel cannot open localhost. ` +
155
- `This localhost port is per MCP session and goes stale if the proxy restarts — re-run barmesh_results_explorer for a fresh URL, or set BARIVIA_VIZ_PORT for a persistent port.`,
156
- });
157
- }
158
- if (!getClientSupportsMcpApps() && payload.standaloneUrl) {
161
+ const uiDelivery = resolveUiDelivery({
162
+ tool: "results_explorer",
163
+ jobId: job_id,
164
+ jobStatus: "completed",
165
+ inlineImageAttached,
166
+ });
167
+ appendUiDeliveryContent(content, uiDelivery, {
168
+ linkLabel: "Open results explorer",
169
+ primaryUrl: payload.standaloneUrl ?? buildStandaloneVizUrl("results_explorer", job_id),
170
+ });
171
+ const monitorUrl = payload.trainingMonitorUrl ?? buildStandaloneVizUrl("training_monitor", job_id);
172
+ if (monitorUrl) {
159
173
  content.push({
160
174
  type: "text",
161
- text: `Interactive results explorer: [Open results explorer](${payload.standaloneUrl})\n` +
162
- `AGENT: surface this to the user as a clickable markdown link in your reply. ` +
163
- `This localhost port is assigned per MCP session and changes if the proxy restarts — re-run barmesh_results_explorer for a fresh URL, or set BARIVIA_VIZ_PORT for a persistent port.`,
175
+ text: `Review training curves: [Open training monitor](${monitorUrl})`,
164
176
  });
165
177
  }
166
178
  return {
167
- ...structuredTextResult(payload, undefined, content),
179
+ ...structuredTextResult({ ...payload, ui_delivery: uiDelivery }, undefined, content),
168
180
  _meta: { ui: { resourceUri: RESULTS_EXPLORER_URI } },
169
181
  };
170
182
  }
package/dist/tools/cfd.js CHANGED
@@ -10,14 +10,14 @@ What it does (one call): joint-normalizes all meshes, trains one SOM on a strati
10
10
  BEST FOR: A mesh-refinement study where you want a field-level (not just scalar) view of whether the mesh has converged.
11
11
  NOT FOR: Single scalar quantities of interest — use barmesh_richardson for classical GCI.
12
12
  ASYNC: This is a queued job. It returns a job id immediately. Then poll barmesh_jobs(action=status) every 10-20s; datacenter-scale grids plus EMD can take a few minutes — keep polling. On completion call barmesh_results(action=get).
13
- COMMON MISTAKES: omitting feature_columns (required); choosing a reference_mesh label that is not present; forgetting the cell-volume column at upload time.`, {
13
+ COMMON MISTAKES: omitting feature_columns (required); choosing a reference_mesh label that is not present; forgetting the cell-volume column at upload time; multi-topology studies (uniform U + graded G meshes) without explicit mesh_order — equal cell counts sort U-then-G and invent bogus stepwise pairs (U160|G20). Pass mesh_order coarse→fine interleaved by refinement (U20,G20,U40,G40,…) or use preset=cavity which defaults interleaved when U/G labels are detected.`, {
14
14
  dataset_id: z.string().describe("Dataset ID from barmesh_datasets(upload)"),
15
15
  feature_columns: z.array(z.string()).describe("Per-cell numeric feature columns to train the SOM on (e.g. [\"p\",\"U_mag\",\"k\",\"log_epsilon\"])"),
16
16
  preset: z.enum(["generic", "cavity", "pitz", "datacenter"]).optional().describe("Hyperparameter preset; defaults to generic. cavity/pitz/datacenter reproduce the published settings."),
17
17
  mesh_column: z.string().optional().describe("Column with the mesh label (default mesh_id)"),
18
18
  volume_column: z.string().optional().describe("Cell-volume column for fingerprint weights (default V; equal weights if absent)"),
19
19
  reference_mesh: z.string().optional().describe("Mesh label used as reference (default: the mesh with the most cells = finest)"),
20
- mesh_order: z.array(z.string()).optional().describe("Explicit coarse-to-fine mesh order (default: ascending cell count)"),
20
+ mesh_order: z.array(z.string()).optional().describe("Explicit coarse-to-fine mesh order (default: ascending cell count; cavity preset and tied U/G cell counts auto-interleave U20→G20→U40→G40→…)"),
21
21
  grid: z.array(z.number().int()).optional().describe("SOM grid [rows, cols] (overrides preset)"),
22
22
  epochs: z.array(z.number().int()).optional().describe("[ordering, convergence] epochs (overrides preset)"),
23
23
  batch_size: z.number().int().optional().describe("SOM batch size (overrides preset)"),
@@ -34,8 +34,10 @@ COMMON MISTAKES: omitting feature_columns (required); choosing a reference_mesh
34
34
  normalization_methods: z.record(z.enum(["zscore", "mad", "sigmoidal", "sepd", "none"])).optional().describe("Per-feature normalization override; keys must be in feature_columns."),
35
35
  row_range: z.tuple([z.number().int().min(1), z.number().int().min(1)]).optional().describe("1-based inclusive [start, end] row slice applied during preprocessing (and to mesh labels / cell volumes)."),
36
36
  label: z.string().optional().describe("Optional job label"),
37
+ description: z.string().optional().describe("Optional free-text description for inventory"),
38
+ tags: z.array(z.string()).optional().describe("Optional tags for inventory/navigation"),
37
39
  }, async (args) => {
38
- const { dataset_id, label, ...rest } = args;
40
+ const { dataset_id, label, description, tags, ...rest } = args;
39
41
  const params = {};
40
42
  for (const [k, v] of Object.entries(rest)) {
41
43
  if (v !== undefined && v !== null)
@@ -47,6 +49,10 @@ COMMON MISTAKES: omitting feature_columns (required); choosing a reference_mesh
47
49
  const body = { dataset_id, params };
48
50
  if (typeof label === "string" && label.length > 0)
49
51
  body.label = label;
52
+ if (description !== undefined)
53
+ body.description = description;
54
+ if (tags !== undefined)
55
+ body.tags = tags;
50
56
  const data = (await apiCall("POST", "/v1/cfd/mesh-convergence", body));
51
57
  await pollCfdPrepareIfPresent(data, "barmesh_mesh_convergence");
52
58
  const id = data.id;
@@ -63,7 +69,7 @@ What it does: from a small CSV with one row per mesh (a mesh label, a representa
63
69
  BEST FOR: Scalar benchmarks (reattachment length, centerline values, probe readings) where a classical asymptotic-convergence check is expected.
64
70
  NOT FOR: High-dimensional field comparison — use barmesh_mesh_convergence (complementary).
65
71
  ASYNC: queued job; poll barmesh_jobs(action=status), then barmesh_results(action=get).
66
- COMMON MISTAKES: not providing h_column or n_cells_column; mixing QoIs with different mesh sets in one CSV.`, {
72
+ COMMON MISTAKES: not providing h_column or n_cells_column; mixing QoIs with different mesh sets in one CSV; mixing uniform (U…) and graded (G…) mesh families in one CSV — classical GCI assumes one topology per job; run separate Richardson jobs per family (e.g. G160|G80|G40 graded, U160|U80|U40 uniform).`, {
67
73
  dataset_id: z.string().describe("Dataset ID (one row per mesh)"),
68
74
  qoi_columns: z.array(z.string()).describe("Scalar QoI column names to extrapolate"),
69
75
  mesh_column: z.string().optional().describe("Mesh label column (default mesh_id)"),
@@ -72,8 +78,10 @@ COMMON MISTAKES: not providing h_column or n_cells_column; mixing QoIs with diff
72
78
  dimension: z.number().int().optional().describe("Spatial dimension d for deriving h from cell count (default 3)"),
73
79
  safety_factor: z.number().optional().describe("GCI safety factor Fs (default 1.25, Roache)"),
74
80
  label: z.string().optional().describe("Optional job label"),
81
+ description: z.string().optional().describe("Optional free-text description for inventory"),
82
+ tags: z.array(z.string()).optional().describe("Optional tags for inventory/navigation"),
75
83
  }, async (args) => {
76
- const { dataset_id, label, ...rest } = args;
84
+ const { dataset_id, label, description, tags, ...rest } = args;
77
85
  const params = {};
78
86
  for (const [k, v] of Object.entries(rest)) {
79
87
  if (v !== undefined && v !== null)
@@ -82,6 +90,10 @@ COMMON MISTAKES: not providing h_column or n_cells_column; mixing QoIs with diff
82
90
  const body = { dataset_id, params };
83
91
  if (typeof label === "string" && label.length > 0)
84
92
  body.label = label;
93
+ if (description !== undefined)
94
+ body.description = description;
95
+ if (tags !== undefined)
96
+ body.tags = tags;
85
97
  const data = (await apiCall("POST", "/v1/cfd/richardson", body));
86
98
  const id = data.id;
87
99
  if (id != null)