@barivia/barmesh-mcp 0.7.1 → 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/audit.js CHANGED
@@ -47,10 +47,12 @@ function extractIds(result) {
47
47
  function errorCodeFrom(err) {
48
48
  if (err && typeof err === "object") {
49
49
  const e = err;
50
+ if (e.errorCode)
51
+ return e.errorCode;
50
52
  if (e.httpStatus !== undefined)
51
53
  return `http_${e.httpStatus}`;
52
54
  const m = e.message ?? "";
53
- const codeMatch = m.match(/error_code:\s*(\w+)/);
55
+ const codeMatch = m.match(/error_code[=:]\s*(\w+)/);
54
56
  if (codeMatch)
55
57
  return codeMatch[1];
56
58
  }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Local offline queue for barmesh_send_feedback when the API or object storage is down.
3
+ * Persists under ~/.barivia/deferred-feedback (or BARIVIA_FEEDBACK_QUEUE_DIR).
4
+ * Same layout as @barivia/barsom-mcp so both proxies share one queue directory.
5
+ */
6
+ import fs from "node:fs/promises";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ import { randomUUID } from "node:crypto";
10
+ export function deferredFeedbackQueueDir() {
11
+ const override = process.env.BARIVIA_FEEDBACK_QUEUE_DIR?.trim();
12
+ if (override)
13
+ return path.resolve(override);
14
+ return path.join(os.homedir(), ".barivia", "deferred-feedback");
15
+ }
16
+ async function ensureQueueDir() {
17
+ const dir = deferredFeedbackQueueDir();
18
+ await fs.mkdir(dir, { recursive: true });
19
+ return dir;
20
+ }
21
+ function entryPath(dir, id) {
22
+ return path.join(dir, `${id}.json`);
23
+ }
24
+ export async function enqueueDeferredFeedback(product, body, lastError) {
25
+ const dir = await ensureQueueDir();
26
+ const id = randomUUID();
27
+ const entry = {
28
+ id,
29
+ product,
30
+ created_at: new Date().toISOString(),
31
+ body,
32
+ ...(lastError ? { last_error: lastError } : {}),
33
+ };
34
+ const filePath = entryPath(dir, id);
35
+ await fs.writeFile(filePath, JSON.stringify(entry, null, 2), "utf8");
36
+ return { id, path: filePath };
37
+ }
38
+ export async function listDeferredFeedback(product) {
39
+ const dir = deferredFeedbackQueueDir();
40
+ let names;
41
+ try {
42
+ names = await fs.readdir(dir);
43
+ }
44
+ catch (err) {
45
+ if (err.code === "ENOENT")
46
+ return [];
47
+ throw err;
48
+ }
49
+ const out = [];
50
+ for (const name of names) {
51
+ if (!name.endsWith(".json"))
52
+ continue;
53
+ try {
54
+ const raw = await fs.readFile(path.join(dir, name), "utf8");
55
+ const entry = JSON.parse(raw);
56
+ if (product && entry.product !== product)
57
+ continue;
58
+ out.push(entry);
59
+ }
60
+ catch {
61
+ /* skip corrupt */
62
+ }
63
+ }
64
+ out.sort((a, b) => a.created_at.localeCompare(b.created_at));
65
+ return out;
66
+ }
67
+ /**
68
+ * Attempt to submit each queued entry via `submit`. Successful entries are deleted.
69
+ * Failures leave the file in place for a later retry.
70
+ */
71
+ export async function flushDeferredFeedback(product, submit) {
72
+ const entries = await listDeferredFeedback(product);
73
+ let flushed = 0;
74
+ const errors = [];
75
+ const dir = deferredFeedbackQueueDir();
76
+ for (const entry of entries) {
77
+ try {
78
+ await submit(entry.body);
79
+ await fs.unlink(entryPath(dir, entry.id)).catch(() => undefined);
80
+ flushed += 1;
81
+ }
82
+ catch (err) {
83
+ errors.push(err instanceof Error ? err.message : String(err));
84
+ }
85
+ }
86
+ const remaining = (await listDeferredFeedback(product)).length;
87
+ return { flushed, remaining, errors };
88
+ }
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 r}from"@modelcontextprotocol/sdk/server/stdio.js";import{getUiCapability as s,registerAppResource as o,RESOURCE_MIME_TYPE as n}from"@modelcontextprotocol/ext-apps/server";import{startVizServer as t}from"./viz-server.js";import{API_KEY as i,CLIENT_VERSION as a,apiCall as c,apiRawCall as m,loadViewHtml as l,setVizPort as d,setClientSupportsMcpApps as p}from"./shared.js";import{registerGuideTool as h}from"./tools/guide.js";import{registerDatasetsTool as u}from"./tools/datasets.js";import{registerCfdTools as b}from"./tools/cfd.js";import{registerJobsTool as f}from"./tools/jobs.js";import{registerResultsTool as v}from"./tools/results.js";import{registerResultsExplorerTool as y,RESULTS_EXPLORER_URI as _}from"./tools/barmesh_results_explorer.js";import{registerTrainingMonitorTool as g,TRAINING_MONITOR_URI as w}from"./tools/training_monitor.js";import{registerFeedbackTool as j}from"./tools/feedback.js";i||(console.error("Error: BARIVIA_API_KEY not set. Set it in your MCP client config."),process.exit(1));(async function(){const i=new e({name:"barmesh",version:a},{instructions:"# Barivia barmesh — CFD mesh-convergence analytics\n\nSOM-based mesh-convergence verification: compare CFD meshes of a refinement study by the\nvolume-weighted distribution their cells form on a shared self-organizing map, plus\nclassical Richardson/GCI on scalar quantities.\n\n## Two tracks\n- barmesh_mesh_convergence: high-dimensional field comparison. Symmetric KL and\n Wasserstein-1 (EMD) distances between each mesh's SOM fingerprint and a reference,\n and between consecutive meshes. Decreasing, plateauing distances toward the finest\n mesh indicate sufficiency. Complements (does not replace) numerical uncertainty analysis.\n- barmesh_richardson: classical grid-convergence index on scalar QoIs.\n\n## Workflow (read-only first)\n1. barmesh_guide_workflow — orient and confirm your plan includes CFD tools.\n2. barmesh_prepare_mesh_data — recipe for the combined per-cell CSV (mesh_id + features + cell volume V).\n3. barmesh_datasets(action=upload) then preview.\n4. barmesh_mesh_convergence (and/or barmesh_richardson) — returns a job id.\n5. barmesh_training_monitor(job_id) — visual MCP App with live curves (default); barmesh_jobs(action=monitor) — headless blocking snapshots; or barmesh_jobs(status) for one-shot polls every 10-20s.\n6. barmesh_results(action=get) — distances, convergence reading, and figures; then\n barmesh_results_explorer(job_id) to browse every figure interactively.\n\nThese tools are gated by the 'cfd' entitlement; analysis calls return 403 if your plan\ndoes not include it."});o(i,_,_,{mimeType:n},async()=>{const e=await l("barmesh-results-explorer");return{contents:[{uri:_,mimeType:n,text:e??"<html><body>Results Explorer view not built yet. Run: npm run build:views</body></html>"}]}}),o(i,w,w,{mimeType:n},async()=>{const e=await l("barmesh-training-monitor");return{contents:[{uri:w,mimeType:n,text:e??"<html><body>Training Monitor view not built yet. Run: npm run build:views</body></html>"}]}}),h(i),y(i),u(i),b(i),f(i),g(i),v(i),j(i);try{const e=await t(c,m,l);d(e)}catch(e){process.env.BARIVIA_VIZ_PORT&&console.error("barmesh viz server failed to start:",e)}const x=i.server;x.oninitialized=()=>{const e=x.getClientCapabilities(),r=s(e);p(!!r?.mimeTypes?.includes(n))};const I=new r;await i.connect(I),console.error(`barmesh-mcp ${a} ready (API: ${process.env.BARIVIA_API_URL??"https://api.barivia.se"})`)})().catch(e=>{console.error("Fatal error starting barmesh-mcp:",e),process.exit(1)});
2
+ import{McpServer as e}from"@modelcontextprotocol/sdk/server/mcp.js";import{StdioServerTransport as r}from"@modelcontextprotocol/sdk/server/stdio.js";import{getUiCapability as s,registerAppResource as t,RESOURCE_MIME_TYPE as o}from"@modelcontextprotocol/ext-apps/server";import{startVizServer as n}from"./viz-server.js";import{API_KEY as a,CLIENT_VERSION as i,apiCall as l,apiRawCall as c,loadViewHtml as m,setVizPort as d,setClientSupportsMcpApps as h}from"./shared.js";import{registerGuideTool as u}from"./tools/guide.js";import{registerDatasetsTool as p}from"./tools/datasets.js";import{registerCfdTools as b}from"./tools/cfd.js";import{registerJobsTool as f}from"./tools/jobs.js";import{registerResultsTool as _}from"./tools/results.js";import{registerResultsExplorerTool as y,RESULTS_EXPLORER_URI as v}from"./tools/barmesh_results_explorer.js";import{registerTrainingMonitorTool as g,TRAINING_MONITOR_URI as w}from"./tools/training_monitor.js";import{registerFeedbackTool as I}from"./tools/feedback.js";a||(console.error("Error: BARIVIA_API_KEY not set. Set it in your MCP client config."),process.exit(1));(async function(){const a=new e({name:"barmesh",version:i},{instructions:"# Barivia barmesh — CFD mesh-convergence analytics\n\nSOM-based mesh-convergence verification: compare CFD meshes of a refinement study by the\nvolume-weighted distribution their cells form on a shared self-organizing map, plus\nclassical Richardson/GCI on scalar quantities.\n\n## Two tracks\n- barmesh_mesh_convergence: high-dimensional field comparison. Symmetric KL and\n Wasserstein-1 (EMD) distances between each mesh's SOM fingerprint and a reference,\n and between consecutive meshes. Decreasing, plateauing distances toward the finest\n mesh indicate sufficiency. Complements (does not replace) numerical uncertainty analysis.\n- barmesh_richardson: classical grid-convergence index on scalar QoIs.\n\n## Workflow (read-only first)\n1. barmesh_guide_workflow — orient and confirm your plan includes CFD tools.\n2. barmesh_prepare_mesh_data — recipe for the combined per-cell CSV (mesh_id + features + cell volume V).\n3. barmesh_datasets(action=upload) then preview (optional description/tags for inventory).\n4. barmesh_mesh_convergence (and/or barmesh_richardson) — returns a job id.\n5. barmesh_training_monitor(job_id) — visual MCP App with live curves (default); barmesh_jobs(action=monitor) — headless blocking snapshots; or barmesh_jobs(status) for one-shot polls every 10-20s.\n6. barmesh_results(action=get) — distances, convergence reading, and figures; then\n barmesh_results_explorer(job_id) to browse every figure interactively.\n\n## Org inventory recipe\n- Prefer `barmesh_jobs(action=inventory)` for a markdown datasets+jobs overview.\n- Or compose `barmesh_datasets(list)` + `barmesh_jobs(action=list, limit=50)` (page with `cursor`; filter with `status`/`job_type`/`has_results=true` for a results catalog).\n- Set `description`+`tags` on upload/submit/update so duplicates stay navigable. List is slim by default (no fat params).\n\n## UI delivery (barmesh_training_monitor, barmesh_results_explorer)\n\nHosts vary: some embed MCP Apps; others list ui:// but never mount them (expected on some Cursor builds). Tool results always lead with a standalone localhost URL and a structured ui_delivery block (delivery, urls, hint_for_agent) — follow hint_for_agent without user prompting.\n\n- Surface the standalone URL as a clickable markdown link. Do not bury it after metrics.\n- Env: BARIVIA_UI_DELIVERY=auto|apps|localhost|inline; BARIVIA_VIZ_PORT pins the viz port across restarts.\n- Standalone pages cross-link training monitor ↔ results explorer for the same job_id.\n\nThese tools are gated by the 'cfd' entitlement; analysis calls return 403 if your plan\ndoes not include it.\n\n## API briefly unavailable (demos)\nIf barmesh_jobs / barmesh_results / barmesh_send_feedback (or any tool) reports the mesh analysis\nAPI temporarily unavailable: say so in one calm line, do not paste HTML/502 pages, and pause\nafter the proxy's burst retries (3 tries ~2s apart; then retry_after_sec≈30). Figures already\ndownloaded stay usable. Keep talking about the last good distances, GCI CSV, and figures —\ndegrade to \"review what we have\", not \"everything failed\". Suggest a later retry for new jobs\nor feedback.\n\n## Outages / feedback\n- barmesh_api_health probes GET /health + /ready (no R2). Use when tools fail with api_unreachable.\n- barmesh_send_feedback queues drafts under ~/.barivia/deferred-feedback when the API/gateway\n is down and flushes them on the next successful call (shared directory with barsom send_feedback).\n- API errors are slim structured lines (error_code, cause, request_id, retry_after_sec) — never raw Cloudflare HTML."});t(a,v,v,{mimeType:o},async()=>{const e=await m("barmesh-results-explorer");return{contents:[{uri:v,mimeType:o,text:e??"<html><body>Results Explorer view not built yet. Run: npm run build:views</body></html>"}]}}),t(a,w,w,{mimeType:o},async()=>{const e=await m("barmesh-training-monitor");return{contents:[{uri:w,mimeType:o,text:e??"<html><body>Training Monitor view not built yet. Run: npm run build:views</body></html>"}]}}),u(a),y(a),p(a),b(a),f(a),g(a),_(a),I(a);try{const e=await n(l,c,m);d(e)}catch(e){process.env.BARIVIA_VIZ_PORT&&console.error("barmesh viz server failed to start:",e)}const j=a.server;j.oninitialized=()=>{const e=j.getClientCapabilities(),r=s(e);h(!!r?.mimeTypes?.includes(o))};const k=new r;await a.connect(k),console.error(`barmesh-mcp ${i} ready (API: ${process.env.BARIVIA_API_URL??"https://api.barivia.se"})`)})().catch(e=>{console.error("Fatal error starting barmesh-mcp:",e),process.exit(1)});
@@ -0,0 +1,69 @@
1
+ /** Shared markdown inventory formatting for barmesh list/inventory. */
2
+ export function formatTags(tags) {
3
+ if (!Array.isArray(tags) || tags.length === 0)
4
+ return "";
5
+ return tags.map((t) => String(t)).filter(Boolean).join(", ");
6
+ }
7
+ export function formatDatasetInventoryLine(ds) {
8
+ const id = String(ds.id ?? "");
9
+ const name = String(ds.name ?? "");
10
+ const rows = ds.rows != null ? Number(ds.rows) : "?";
11
+ const cols = ds.cols != null ? Number(ds.cols) : "?";
12
+ const st = ds.status != null ? String(ds.status) : "ready";
13
+ const statusBit = st !== "ready" ? ` | status=${st}` : "";
14
+ const desc = ds.description != null && String(ds.description).trim() !== ""
15
+ ? ` — ${String(ds.description).trim()}`
16
+ : "";
17
+ const tags = formatTags(ds.tags);
18
+ const tagsBit = tags ? ` [${tags}]` : "";
19
+ return `${name} (${id}) — ${rows}×${cols}${statusBit}${tagsBit}${desc}`;
20
+ }
21
+ export function formatJobInventoryLine(job) {
22
+ const id = String(job.id ?? "");
23
+ const st = String(job.status ?? "");
24
+ const label = job.label != null && job.label !== "" ? String(job.label) : null;
25
+ const jt = job.job_type != null ? String(job.job_type) : "?";
26
+ const ds = job.dataset_id != null ? String(job.dataset_id) : "";
27
+ const created = job.created_at != null ? String(job.created_at) : "";
28
+ const result = job.result_ref != null && String(job.result_ref) !== "" ? "yes" : "no";
29
+ const tags = formatTags(job.tags);
30
+ const tagsBit = tags ? ` [${tags}]` : "";
31
+ const desc = job.description != null && String(job.description).trim() !== ""
32
+ ? ` — ${String(job.description).trim()}`
33
+ : "";
34
+ const head = label ? `${label} (id: ${id})` : `id: ${id}`;
35
+ return `${head} — ${st} | type=${jt} | dataset=${ds} | created=${created} | result=${result}${tagsBit}${desc}`;
36
+ }
37
+ export function extractJobsList(data) {
38
+ if (Array.isArray(data)) {
39
+ return { jobs: data, next_cursor: null };
40
+ }
41
+ if (data && typeof data === "object") {
42
+ const obj = data;
43
+ const jobs = Array.isArray(obj.jobs) ? obj.jobs : [];
44
+ const next = obj.next_cursor != null && String(obj.next_cursor) !== ""
45
+ ? String(obj.next_cursor)
46
+ : null;
47
+ return { jobs, next_cursor: next };
48
+ }
49
+ return { jobs: [], next_cursor: null };
50
+ }
51
+ export function buildJobsListQuery(opts) {
52
+ const qs = new URLSearchParams();
53
+ if (opts.dataset_id)
54
+ qs.set("dataset_id", opts.dataset_id);
55
+ if (opts.status)
56
+ qs.set("status", opts.status);
57
+ if (opts.job_type)
58
+ qs.set("job_type", opts.job_type);
59
+ if (opts.has_results)
60
+ qs.set("has_results", "true");
61
+ if (opts.limit != null)
62
+ qs.set("limit", String(opts.limit));
63
+ if (opts.cursor)
64
+ qs.set("cursor", opts.cursor);
65
+ if (opts.fields)
66
+ qs.set("fields", opts.fields);
67
+ const s = qs.toString();
68
+ return s ? `/v1/jobs?${s}` : "/v1/jobs";
69
+ }
@@ -0,0 +1,35 @@
1
+ /** Format barmesh_results(get) text for cfd_richardson_gci jobs. */
2
+ export function formatRichardsonResultsSummary(jobId, data, summary) {
3
+ const label = data.label != null && data.label !== "" ? String(data.label) : null;
4
+ const header = label
5
+ ? `Richardson/GCI results for ${label} (job_id: ${jobId})`
6
+ : `Richardson/GCI results for job_id: ${jobId}`;
7
+ const qois = summary.qoi_columns ?? [];
8
+ const meshOrder = summary.mesh_order_fine_to_coarse ?? [];
9
+ const fmt = (v) => (typeof v === "number" && Number.isFinite(v) ? v.toFixed(4) : String(v ?? "N/A"));
10
+ const results = summary.results ?? {};
11
+ const lines = [
12
+ header,
13
+ `Meshes (fine→coarse): ${meshOrder.length > 0 ? meshOrder.join(" → ") : "N/A"}`,
14
+ `Safety factor Fs: ${fmt(summary.safety_factor ?? 1.25)} | h source: ${String(summary.h_source ?? "n_cells_column")}`,
15
+ ];
16
+ const warning = summary.topology_warning;
17
+ if (typeof warning === "string" && warning.length > 0) {
18
+ lines.push(`\n⚠ Topology: ${warning}`);
19
+ }
20
+ for (const q of qois) {
21
+ const triplets = results[q] ?? [];
22
+ if (triplets.length === 0)
23
+ continue;
24
+ lines.push(`\nQoI ${q} (${triplets.length} triplet${triplets.length === 1 ? "" : "s"}):`);
25
+ for (const t of triplets) {
26
+ const mono = t.monotonic === true ? "monotonic" : t.monotonic === false ? "non-monotonic" : "monotonicity n/a";
27
+ lines.push(` ${String(t.triplet ?? "?")}: p=${fmt(t.p)} f_ext=${fmt(t.f_ext)} GCI_fine=${fmt(t.gci_fine_pct)}% (${mono})`);
28
+ }
29
+ }
30
+ lines.push("\nFull table: richardson_gci.csv (one row per triplet per QoI).", "Advisory: classical GCI complements SOM fingerprint distances (barmesh_mesh_convergence).", "Use barmesh_results(action=download, figures=none, include_json=true) to save richardson_gci.csv locally.");
31
+ return lines.join("\n");
32
+ }
33
+ export function isRichardsonJob(summary) {
34
+ return String(summary.job_type ?? "") === "cfd_richardson_gci";
35
+ }
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.0";
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
  // ---------------------------------------------------------------------------