@barivia/barmesh-mcp 0.7.0 → 0.8.0

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
@@ -13,17 +13,29 @@ import { pipeline } from "node:stream/promises";
13
13
  import os from "node:os";
14
14
  import path from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
- import { logInfo } from "./logger.js";
16
+ import { logInfo, logWarn } from "./logger.js";
17
17
  // ---------------------------------------------------------------------------
18
18
  // Config
19
19
  // ---------------------------------------------------------------------------
20
- export const API_URL = process.env.BARIVIA_API_URL ?? process.env.BARSOM_API_URL ?? "https://api.barivia.se";
21
- export const API_KEY = process.env.BARIVIA_API_KEY ?? process.env.BARSOM_API_KEY ?? "";
20
+ export const API_URL = process.env.BARIVIA_API_URL ?? "https://api.barivia.se";
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;
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);
24
27
  export const RETRYABLE_STATUS = new Set([502, 503, 504]);
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;
33
+ }
34
+ function newRequestId() {
35
+ return randomUUID();
36
+ }
25
37
  /** Single source of truth for the proxy version. Keep in sync with package.json on bump. */
26
- export const CLIENT_VERSION = "0.7.0";
38
+ export const CLIENT_VERSION = "0.8.0";
27
39
  export const PUBLIC_SITE_ORIGIN = "https://barivia.se";
28
40
  /** Large per-cell CSV uploads may exceed the default fetch timeout. */
29
41
  export const UPLOAD_DATASET_TIMEOUT_MS = 180_000;
@@ -33,6 +45,21 @@ export const LARGE_UPLOAD_BYTES = 64 * 1024 * 1024; // 64 MB
33
45
  export const PRESIGNED_PUT_TIMEOUT_MS = 30 * 60_000; // 30 min
34
46
  /** Poll window for the async stage_dataset job. */
35
47
  export const POLL_STAGE_MAX_MS = 30 * 60_000; // 30 min
48
+ /** Map a local upload path to the API Content-Type (csv vs tsv; ignores .gz suffix). */
49
+ export function resolveUploadContentType(filePath) {
50
+ const lower = filePath.toLowerCase();
51
+ const base = lower.endsWith(".gz") ? lower.slice(0, -3) : lower;
52
+ return path.extname(base) === ".tsv" ? "text/tab-separated-values" : "text/csv";
53
+ }
54
+ export function suggestMeshDatasetPreview(datasetId) {
55
+ return `barmesh_datasets(action=preview, dataset_id=${datasetId}) to verify mesh, feature, and volume columns.`;
56
+ }
57
+ export function suggestAfterCfdSubmit(jobId, prepSuffix = "") {
58
+ return `Run barmesh_training_monitor(job_id="${jobId}") or barmesh_jobs(action=monitor, job_id="${jobId}")${prepSuffix}; on completion call barmesh_results(action=get, job_id="${jobId}").`;
59
+ }
60
+ export function suggestAfterRender(parentJobId, fmt) {
61
+ return `Figures rendered as ${fmt}. Download with barmesh_results(action=download, job_id="${parentJobId}") or barmesh_results(action=image, job_id="${parentJobId}", filename=<figure>.${fmt}).`;
62
+ }
36
63
  /** Streaming SHA-256 of a file (idempotency key) without reading it into memory. */
37
64
  export async function streamFileSha256(srcPath) {
38
65
  return new Promise((resolve, reject) => {
@@ -80,7 +107,11 @@ export async function putPresignedStream(url, srcPath, contentType, timeoutMs =
80
107
  const resp = await fetch(url, {
81
108
  method: "PUT",
82
109
  body: webStream,
83
- headers: { "Content-Type": contentType, "Content-Length": String(contentLength) },
110
+ headers: {
111
+ "Content-Type": contentType,
112
+ "Content-Length": String(contentLength),
113
+ "Content-Encoding": "gzip",
114
+ },
84
115
  duplex: "half",
85
116
  signal: controller.signal,
86
117
  });
@@ -112,6 +143,51 @@ export function getClientSupportsMcpApps() {
112
143
  export function setClientSupportsMcpApps(value) {
113
144
  _clientSupportsMcpApps = value;
114
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
+ }
115
191
  /** Tool result carrying both a structured payload (for the MCP App view) and text/image content. */
116
192
  export function structuredTextResult(structuredContent, text, content) {
117
193
  return {
@@ -147,7 +223,7 @@ export async function fetchWithTimeout(url, init, timeoutMs = FETCH_TIMEOUT_MS)
147
223
  // Path / sandbox helpers
148
224
  // ---------------------------------------------------------------------------
149
225
  export function getSandboxRoot() {
150
- const raw = process.env.BARIVIA_WORKSPACE_ROOT ?? process.env.BARSOM_WORKSPACE_ROOT;
226
+ const raw = process.env.BARIVIA_WORKSPACE_ROOT;
151
227
  if (raw)
152
228
  return path.resolve(process.cwd(), raw);
153
229
  const workspaceFolder = process.env.CURSOR_WORKSPACE_FOLDER ?? process.env.WORKSPACE_FOLDER;
@@ -220,47 +296,227 @@ export async function resolveFilePathForUpload(filePath, mcpServer) {
220
296
  const resolved = path.resolve(root, trimmed);
221
297
  return checkUnder(resolved);
222
298
  }
223
- 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) {
224
314
  let parsed = null;
225
- try {
226
- const j = JSON.parse(bodyText);
227
- if (j && typeof j === "object")
228
- parsed = j;
229
- }
230
- catch {
231
- /* 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
+ }
232
325
  }
233
- const detail = (parsed?.error != null && String(parsed.error)) ||
234
- (bodyText.trim() ? bodyText.trim() : `HTTP ${status}`);
235
- 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;
236
335
  const accountHint = ` Regenerate or verify your key via ${PUBLIC_SITE_ORIGIN} if needed.`;
237
- const hint = status === 400
238
- ? " Check parameter types and required fields."
239
- : status === 401
240
- ? ` Check BARIVIA_API_KEY in your MCP config.${accountHint}`
241
- : status === 403
242
- ? ` Access denied your plan does not include the CFD capability. Add the CFD capability at ${PUBLIC_SITE_ORIGIN}/dashboard to enable the barmesh tools.`
243
- : status === 404
244
- ? " The resource may not exist or may have been deleted."
245
- : status === 409
246
- ? " The job may not be in the expected state."
247
- : status === 429
248
- ? " Plan limit or rate limit — read the error above; delete unused datasets or wait and retry."
249
- : status === 502
250
- ? " Object storage error from API — retry later."
251
- : status === 503
252
- ? " API or database temporarily unavailable — retry later."
253
- : status >= 500
254
- ? " Server error retry later."
255
- : "";
256
- const rid = ` (request id: ${requestId} include if contacting support)`;
257
- 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(" | ");
258
432
  }
259
433
  function throwApiError(status, bodyText, requestId) {
434
+ const classified = classifyApiError(status, bodyText, requestId);
260
435
  const err = new Error(formatApiErrorMessage(status, bodyText, requestId));
261
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;
262
443
  throw err;
263
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
+ }
264
520
  // ---- Distributed-trace context (W3C traceparent) ----
265
521
  // One trace per logical tool action (scoped via AsyncLocalStorage in registerAuditedTool),
266
522
  // so the API + job chain reconstruct end-to-end. Fresh span per API call; falls back to a
@@ -282,7 +538,7 @@ function _traceparentHeader() {
282
538
  export async function apiCall(method, pathPart, body, extraHeaders, requestTimeoutMs) {
283
539
  const url = `${API_URL}${pathPart}`;
284
540
  const contentType = extraHeaders?.["Content-Type"] ?? "application/json";
285
- const requestId = Math.random().toString(36).slice(2, 10);
541
+ const requestId = newRequestId();
286
542
  const headers = {
287
543
  Authorization: `Bearer ${API_KEY}`,
288
544
  "Content-Type": contentType,
@@ -310,7 +566,17 @@ export async function apiCall(method, pathPart, body, extraHeaders, requestTimeo
310
566
  const text = await resp.text();
311
567
  if (!resp.ok) {
312
568
  if (attempt < MAX_RETRIES && isTransientError(null, resp.status)) {
313
- await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
569
+ const delayMs = retryDelayMs(attempt);
570
+ logWarn("API retry", {
571
+ rid: requestId,
572
+ action: method,
573
+ path: pathPart,
574
+ attempt: attempt + 1,
575
+ max_retries: MAX_RETRIES,
576
+ delay_ms: delayMs,
577
+ error_code: `http_${resp.status}`,
578
+ });
579
+ await new Promise((r) => setTimeout(r, delayMs));
314
580
  continue;
315
581
  }
316
582
  logInfo("API response", { rid: requestId, outcome: "error", duration_ms: Date.now() - t0, error_code: `http_${resp.status}` });
@@ -322,21 +588,35 @@ export async function apiCall(method, pathPart, body, extraHeaders, requestTimeo
322
588
  catch (err) {
323
589
  lastError = err;
324
590
  if (attempt < MAX_RETRIES && isTransientError(err)) {
325
- await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
591
+ const delayMs = retryDelayMs(attempt);
592
+ logWarn("API retry", {
593
+ rid: requestId,
594
+ action: method,
595
+ path: pathPart,
596
+ attempt: attempt + 1,
597
+ max_retries: MAX_RETRIES,
598
+ delay_ms: delayMs,
599
+ error_code: err instanceof Error ? err.name : "fetch_error",
600
+ });
601
+ await new Promise((r) => setTimeout(r, delayMs));
326
602
  continue;
327
603
  }
328
604
  if (err instanceof DOMException && err.name === "AbortError" && !err.httpStatus) {
329
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})`);
330
606
  }
607
+ if (err instanceof TypeError)
608
+ throwNetworkUnreachable(requestId, err);
331
609
  throw err;
332
610
  }
333
611
  }
612
+ if (lastError instanceof TypeError)
613
+ throwNetworkUnreachable(requestId, lastError);
334
614
  throw lastError;
335
615
  }
336
616
  /** Fetch raw bytes from the API (for image downloads). */
337
617
  export async function apiRawCall(pathPart, requestTimeoutMs) {
338
618
  const url = `${API_URL}${pathPart}`;
339
- const requestId = Math.random().toString(36).slice(2, 10);
619
+ const requestId = newRequestId();
340
620
  const effectiveTimeout = requestTimeoutMs ?? FETCH_TIMEOUT_MS;
341
621
  let lastError;
342
622
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
@@ -344,7 +624,16 @@ export async function apiRawCall(pathPart, requestTimeoutMs) {
344
624
  const resp = await fetchWithTimeout(url, { method: "GET", headers: { Authorization: `Bearer ${API_KEY}`, "X-Request-ID": requestId, traceparent: _traceparentHeader() } }, effectiveTimeout);
345
625
  if (!resp.ok) {
346
626
  if (attempt < MAX_RETRIES && isTransientError(null, resp.status)) {
347
- await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
627
+ const delayMs = retryDelayMs(attempt);
628
+ logWarn("API retry", {
629
+ rid: requestId,
630
+ path: pathPart,
631
+ attempt: attempt + 1,
632
+ max_retries: MAX_RETRIES,
633
+ delay_ms: delayMs,
634
+ error_code: `http_${resp.status}`,
635
+ });
636
+ await new Promise((r) => setTimeout(r, delayMs));
348
637
  continue;
349
638
  }
350
639
  const text = await resp.text();
@@ -359,15 +648,28 @@ export async function apiRawCall(pathPart, requestTimeoutMs) {
359
648
  catch (err) {
360
649
  lastError = err;
361
650
  if (attempt < MAX_RETRIES && isTransientError(err)) {
362
- await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
651
+ const delayMs = retryDelayMs(attempt);
652
+ logWarn("API retry", {
653
+ rid: requestId,
654
+ path: pathPart,
655
+ attempt: attempt + 1,
656
+ max_retries: MAX_RETRIES,
657
+ delay_ms: delayMs,
658
+ error_code: err instanceof Error ? err.name : "fetch_error",
659
+ });
660
+ await new Promise((r) => setTimeout(r, delayMs));
363
661
  continue;
364
662
  }
365
663
  if (err instanceof DOMException && err.name === "AbortError" && !err.httpStatus) {
366
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})`);
367
665
  }
666
+ if (err instanceof TypeError)
667
+ throwNetworkUnreachable(requestId, err);
368
668
  throw err;
369
669
  }
370
670
  }
671
+ if (lastError instanceof TypeError)
672
+ throwNetworkUnreachable(requestId, lastError);
371
673
  throw lastError;
372
674
  }
373
675
  // ---------------------------------------------------------------------------
@@ -1,9 +1,11 @@
1
1
  import { z } from "zod";
2
2
  import { registerAppTool } from "@modelcontextprotocol/ext-apps/server";
3
- import { registerAuditedTool, runMcpToolAudit } from "../audit.js";
4
- import { apiCall, apiRawCall, getClientSupportsMcpApps, getVizPort, getCaptionForImage, mimeForFilename, structuredTextResult, tryAttachImage, } from "../shared.js";
3
+ import { runMcpToolAudit } from "../audit.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") -
@@ -111,6 +113,10 @@ export function buildPayload(jobId, data, jobMeta) {
111
113
  const files = (summary.files ?? []).filter((f) => /\.(png|pdf|svg)$/i.test(f));
112
114
  const figures = sortFigures(groupFigures(files));
113
115
  const port = getVizPort();
116
+ const standaloneUrl = buildStandaloneVizPageUrl(port, BARMESH_VIZ_VIEWS.resultsExplorer, jobId) ??
117
+ buildBarmeshVizStandaloneUrl("barmesh-results-explorer", jobId);
118
+ const trainingMonitorUrl = buildStandaloneVizPageUrl(port, BARMESH_VIZ_VIEWS.trainingMonitor, jobId) ??
119
+ buildBarmeshVizStandaloneUrl("barmesh-training-monitor", jobId);
114
120
  return {
115
121
  type: "barmesh-results-explorer",
116
122
  jobId,
@@ -120,9 +126,9 @@ export function buildPayload(jobId, data, jobMeta) {
120
126
  metrics: buildMetrics(summary),
121
127
  availableFigures: figures,
122
128
  defaultFigureKey: resolveDefaultFigureKey(figures),
123
- standaloneUrl: port
124
- ? `http://localhost:${port}/viz/barmesh-results-explorer?mode=standalone&job_id=${jobId}`
125
- : undefined,
129
+ standaloneUrl,
130
+ trainingMonitorUrl,
131
+ relatedUrls: buildVizRelatedUrls(port, jobId, BARMESH_VIZ_VIEWS),
126
132
  };
127
133
  }
128
134
  async function handleResultsExplorer(job_id) {
@@ -131,40 +137,40 @@ async function handleResultsExplorer(job_id) {
131
137
  apiCall("GET", `/v1/jobs/${job_id}`).catch(() => ({})),
132
138
  ]);
133
139
  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
- ];
140
+ const summaryText = [
141
+ `Mesh convergence results explorer ready for job ${job_id}.`,
142
+ payload.subtitle ? `Label: ${payload.subtitle}` : "",
143
+ payload.metadataStrip?.[0] ?? "",
144
+ ]
145
+ .filter(Boolean)
146
+ .join("\n");
147
+ const content = [{ type: "text", text: summaryText }];
148
+ let inlineImageAttached = false;
146
149
  const defaultFigure = payload.availableFigures.find((f) => f.key === payload.defaultFigureKey);
147
150
  if (defaultFigure && /\.(png|svg|jpe?g|webp)$/i.test(defaultFigure.filename)) {
151
+ const before = content.length;
148
152
  await tryAttachImage(content, job_id, defaultFigure.filename);
153
+ inlineImageAttached = content.length > before && content.some((c) => c.type === "image");
149
154
  }
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) {
155
+ const uiDelivery = resolveUiDelivery({
156
+ tool: "results_explorer",
157
+ jobId: job_id,
158
+ jobStatus: "completed",
159
+ inlineImageAttached,
160
+ });
161
+ appendUiDeliveryContent(content, uiDelivery, {
162
+ linkLabel: "Open results explorer",
163
+ primaryUrl: payload.standaloneUrl ?? buildStandaloneVizUrl("results_explorer", job_id),
164
+ });
165
+ const monitorUrl = payload.trainingMonitorUrl ?? buildStandaloneVizUrl("training_monitor", job_id);
166
+ if (monitorUrl) {
159
167
  content.push({
160
168
  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.`,
169
+ text: `Review training curves: [Open training monitor](${monitorUrl})`,
164
170
  });
165
171
  }
166
172
  return {
167
- ...structuredTextResult(payload, undefined, content),
173
+ ...structuredTextResult({ ...payload, ui_delivery: uiDelivery }, undefined, content),
168
174
  _meta: { ui: { resourceUri: RESULTS_EXPLORER_URI } },
169
175
  };
170
176
  }
@@ -178,9 +184,14 @@ export function registerResultsExplorerTool(server) {
178
184
  _meta: { ui: { resourceUri: RESULTS_EXPLORER_URI } },
179
185
  };
180
186
  registerAppTool(server, "barmesh_results_explorer", toolConfig, async (args) => runMcpToolAudit("barmesh_results_explorer", "default", args, () => handleResultsExplorer(String(args.job_id))));
181
- registerAuditedTool(server, "_barmesh_fetch_figure", "[INTERNAL — host / MCP App only; agents MUST NOT call this.] The Mesh Convergence Results Explorer view uses it to lazy-load one raster figure as base64. From chat, use barmesh_results(action=get) or barmesh_results_explorer instead.", {
182
- job_id: z.string(),
183
- filename: z.string(),
187
+ registerAppTool(server, "_barmesh_fetch_figure", {
188
+ title: "Fetch figure (internal)",
189
+ description: "[INTERNAL — MCP App only; agents MUST NOT call.] Lazy-loads one raster figure as base64 for the Mesh Convergence Results Explorer.",
190
+ inputSchema: {
191
+ job_id: z.string(),
192
+ filename: z.string(),
193
+ },
194
+ _meta: { ui: { resourceUri: RESULTS_EXPLORER_URI, visibility: ["app"] } },
184
195
  }, async ({ job_id, filename }) => {
185
196
  const safeFilename = filename.replace(/[^a-zA-Z0-9._-]/g, "");
186
197
  const mime = mimeForFilename(safeFilename);