@barivia/barmesh-mcp 0.8.4 → 0.8.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
1
  #!/usr/bin/env node
2
- import{McpServer as e}from"@modelcontextprotocol/sdk/server/mcp.js";import{StdioServerTransport as 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)});
2
+ import{McpServer as e}from"@modelcontextprotocol/sdk/server/mcp.js";import{StdioServerTransport as s}from"@modelcontextprotocol/sdk/server/stdio.js";import{getUiCapability as r,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 j}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- Cleanup: `barmesh_jobs(action=delete, job_id=…)` + `barmesh_datasets(action=delete)` for known IDs — do not deep-page list to find them.\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),j(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 I=a.server;I.oninitialized=()=>{const e=I.getClientCapabilities(),s=r(e);h(!!s?.mimeTypes?.includes(o))};const k=new s;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)});
@@ -35,14 +35,25 @@ function tailOrderingErrors(data, n = 4) {
35
35
  export function snapshotFromJob(data, elapsedSec, note) {
36
36
  const status = String(data.status ?? "unknown");
37
37
  const progress_pct = (data.progress ?? 0) * 100;
38
+ const kernelComplete = data.kernel_complete === true;
39
+ const awaitingFinalize = kernelComplete &&
40
+ status !== "completed" &&
41
+ status !== "failed" &&
42
+ status !== "cancelled";
38
43
  const snap = {
39
44
  elapsed_sec: elapsedSec,
40
45
  status,
41
- progress_pct: Math.round(progress_pct * 10) / 10,
46
+ progress_pct: awaitingFinalize
47
+ ? Math.min(Math.round(progress_pct * 10) / 10, 99.0)
48
+ : Math.round(progress_pct * 10) / 10,
42
49
  };
43
50
  const phase = str(data, "progress_phase");
44
- if (phase)
51
+ if (awaitingFinalize) {
52
+ snap.phase = phase && phase !== "complete" ? `${phase}+awaiting_finalize` : "awaiting_finalize";
53
+ }
54
+ else if (phase) {
45
55
  snap.phase = phase;
56
+ }
46
57
  const epoch = num(data, "epoch");
47
58
  const total = num(data, "total_epochs");
48
59
  if (epoch != null)
@@ -58,7 +69,7 @@ export function snapshotFromJob(data, elapsedSec, note) {
58
69
  const panelTe = num(data, "panel_topographic_error");
59
70
  if (panelTe != null)
60
71
  snap.panel_te = Math.round(panelTe * 10_000) / 10_000;
61
- if (data.kernel_complete === true)
72
+ if (kernelComplete)
62
73
  snap.kernel_complete = true;
63
74
  const failureStage = str(data, "failure_stage");
64
75
  if (failureStage)
@@ -69,8 +80,12 @@ export function snapshotFromJob(data, elapsedSec, note) {
69
80
  const tail = tailOrderingErrors(data);
70
81
  if (tail && tail.length > 0)
71
82
  snap.ordering_errors_tail = tail;
72
- if (note)
83
+ if (note) {
73
84
  snap.note = note;
85
+ }
86
+ else if (awaitingFinalize) {
87
+ snap.note = "kernel complete — awaiting finalize (not fully done)";
88
+ }
74
89
  return snap;
75
90
  }
76
91
  /** True when a new snapshot is worth recording (phase/epoch/progress/status change). */
@@ -96,7 +111,10 @@ export function shouldRecordSnapshot(prev, next) {
96
111
  return false;
97
112
  }
98
113
  export function formatSnapshotLine(s) {
99
- const parts = [`[+${s.elapsed_sec}s] ${s.status} ${s.progress_pct.toFixed(1)}%`];
114
+ const progressLabel = s.kernel_complete && s.status !== "completed" && s.status !== "failed" && s.status !== "cancelled"
115
+ ? `kernel ${s.progress_pct.toFixed(1)}% (awaiting finalize)`
116
+ : `${s.progress_pct.toFixed(1)}%`;
117
+ const parts = [`[+${s.elapsed_sec}s] ${s.status} ${progressLabel}`];
100
118
  if (s.phase)
101
119
  parts.push(`phase ${s.phase}`);
102
120
  if (s.epoch != null && s.total_epochs != null)
@@ -107,8 +125,9 @@ export function formatSnapshotLine(s) {
107
125
  parts.push(`Epoch TE ${s.te.toFixed(4)}`);
108
126
  if (s.panel_te != null)
109
127
  parts.push(`Panel TE ${s.panel_te.toFixed(4)}`);
110
- if (s.kernel_complete)
128
+ if (s.kernel_complete && (s.status === "completed" || s.status === "failed" || s.status === "cancelled")) {
111
129
  parts.push("kernel complete");
130
+ }
112
131
  if (s.failure_stage)
113
132
  parts.push(`failure_stage ${s.failure_stage}`);
114
133
  if (s.eta_sec != null)
@@ -42,7 +42,12 @@ export async function formatJobStatusText(job_id, data) {
42
42
  const runConfig = formatRunConfigTable(data);
43
43
  if (runConfig)
44
44
  parts.push(runConfig);
45
- parts.push(`${jobDesc}: ${status} (${progress.toFixed(1)}%)`);
45
+ const kernelComplete = data.kernel_complete === true;
46
+ const awaitingFinalize = kernelComplete && status !== "completed" && status !== "failed" && status !== "cancelled";
47
+ const progressBit = awaitingFinalize
48
+ ? `kernel ${Math.min(progress, 99).toFixed(1)}% — awaiting finalize`
49
+ : `${progress.toFixed(1)}%`;
50
+ parts.push(`${jobDesc}: ${status} (${progressBit})`);
46
51
  const attempt = data.attempt != null ? Number(data.attempt) : null;
47
52
  if (attempt != null && attempt > 1) {
48
53
  parts.push(`attempt ${attempt} (job was requeued after worker timeout — not stuck from scratch)`);
package/dist/shared.js CHANGED
@@ -35,7 +35,7 @@ function newRequestId() {
35
35
  return randomUUID();
36
36
  }
37
37
  /** Single source of truth for the proxy version. Keep in sync with package.json on bump. */
38
- export const CLIENT_VERSION = "0.8.4";
38
+ export const CLIENT_VERSION = "0.8.8";
39
39
  export const PUBLIC_SITE_ORIGIN = "https://barivia.se";
40
40
  /** Large per-cell CSV uploads may exceed the default fetch timeout. */
41
41
  export const UPLOAD_DATASET_TIMEOUT_MS = 180_000;
@@ -45,6 +45,94 @@ export const LARGE_UPLOAD_BYTES = 64 * 1024 * 1024; // 64 MB
45
45
  export const PRESIGNED_PUT_TIMEOUT_MS = 30 * 60_000; // 30 min
46
46
  /** Poll window for the async stage_dataset job. */
47
47
  export const POLL_STAGE_MAX_MS = 30 * 60_000; // 30 min
48
+ /** Normalize upload JSON after staging completes — never leave staging:true with train_ready. */
49
+ function finalizeStagedUploadResponse(data, ready) {
50
+ const merged = ready
51
+ ? { ...data, ...ready }
52
+ : { ...data };
53
+ const trainReady = merged.train_ready === true || String(merged.status ?? "") === "ready";
54
+ if (trainReady) {
55
+ merged.status = ready?.status ?? merged.status ?? "ready";
56
+ merged.train_ready = true;
57
+ merged.staging = false;
58
+ if (ready?.stage_job_id == null)
59
+ delete merged.stage_job_id;
60
+ }
61
+ return merged;
62
+ }
63
+ /**
64
+ * After a small POST /v1/datasets upload, poll stage_job_id until the dataset is
65
+ * train-ready (mirrors the large-upload finalize path).
66
+ */
67
+ export async function awaitDatasetStageIfNeeded(data) {
68
+ const stageJobId = data.stage_job_id != null ? String(data.stage_job_id) : "";
69
+ if (!stageJobId) {
70
+ if (data.train_ready === true || data.status === "ready") {
71
+ return finalizeStagedUploadResponse(data);
72
+ }
73
+ return data;
74
+ }
75
+ if (data.train_ready === true || data.status === "ready") {
76
+ return finalizeStagedUploadResponse(data);
77
+ }
78
+ const poll = await pollUntilComplete(stageJobId, POLL_STAGE_MAX_MS);
79
+ if (poll.status === "failed") {
80
+ throw new Error(`Dataset staging failed (stage_job_id=${stageJobId}): ${poll.error ?? "unknown error"}`);
81
+ }
82
+ if (poll.status === "timeout") {
83
+ throw new Error(`Dataset staging timed out after ${POLL_STAGE_MAX_MS / 60000} min (stage_job_id=${stageJobId}). Poll barmesh_jobs(action=status, job_id="${stageJobId}").`);
84
+ }
85
+ const datasetId = String(data.id ?? data.dataset_id ?? "");
86
+ if (datasetId) {
87
+ try {
88
+ const ready = (await apiCall("GET", `/v1/datasets/${datasetId}`));
89
+ return finalizeStagedUploadResponse(data, ready);
90
+ }
91
+ catch {
92
+ /* fall through */
93
+ }
94
+ }
95
+ return finalizeStagedUploadResponse({ ...data, status: "ready", train_ready: true });
96
+ }
97
+ /**
98
+ * Before mesh_convergence submit: ensure the dataset has finished staging.
99
+ */
100
+ export async function ensureDatasetTrainReady(datasetId) {
101
+ const ds = (await apiCall("GET", `/v1/datasets/${datasetId}`));
102
+ if (ds.train_ready === true)
103
+ return;
104
+ if (String(ds.status) === "failed") {
105
+ const err = ds.ingest_error != null ? String(ds.ingest_error) : "staging failed";
106
+ throw new Error(`Dataset ${datasetId} staging failed: ${err}`);
107
+ }
108
+ const stageJobId = ds.stage_job_id != null ? String(ds.stage_job_id) : "";
109
+ if (stageJobId) {
110
+ const poll = await pollUntilComplete(stageJobId, POLL_STAGE_MAX_MS);
111
+ if (poll.status === "failed") {
112
+ throw new Error(`Dataset staging failed (stage_job_id=${stageJobId}): ${poll.error ?? "unknown error"}`);
113
+ }
114
+ if (poll.status === "timeout") {
115
+ throw new Error(`Dataset staging timed out (stage_job_id=${stageJobId}). Poll barmesh_jobs(action=status, job_id="${stageJobId}").`);
116
+ }
117
+ return;
118
+ }
119
+ const deadline = Date.now() + 60_000;
120
+ while (Date.now() < deadline) {
121
+ await new Promise((r) => setTimeout(r, 1000));
122
+ const again = (await apiCall("GET", `/v1/datasets/${datasetId}`));
123
+ if (again.train_ready === true)
124
+ return;
125
+ if (String(again.status) === "failed") {
126
+ const err = again.ingest_error != null ? String(again.ingest_error) : "staging failed";
127
+ throw new Error(`Dataset ${datasetId} staging failed: ${err}`);
128
+ }
129
+ if (again.stage_job_id != null) {
130
+ await ensureDatasetTrainReady(datasetId);
131
+ return;
132
+ }
133
+ }
134
+ throw new Error(`Dataset ${datasetId} is not train_ready (status=${ds.status ?? "?"}). Wait for staging or poll barmesh_datasets(action=get).`);
135
+ }
48
136
  /** Map a local upload path to the API Content-Type (csv vs tsv; ignores .gz suffix). */
49
137
  export function resolveUploadContentType(filePath) {
50
138
  const lower = filePath.toLowerCase();
@@ -261,9 +349,48 @@ function enforceWorkspaceSandboxUpload() {
261
349
  return false;
262
350
  return true;
263
351
  }
352
+ /** Extra realpath roots allowed for uploads (comma- or colon-separated absolute paths). */
353
+ export function getWorkspaceExtraRoots() {
354
+ const raw = process.env.BARIVIA_WORKSPACE_EXTRA_ROOTS ?? "";
355
+ if (!raw.trim())
356
+ return [];
357
+ return raw
358
+ .split(/[,:]/)
359
+ .map((s) => s.trim())
360
+ .filter(Boolean)
361
+ .map((s) => path.resolve(s));
362
+ }
363
+ async function realpathIfExists(p) {
364
+ try {
365
+ return await fs.realpath(p);
366
+ }
367
+ catch {
368
+ return null;
369
+ }
370
+ }
371
+ export async function isPathUnderSandboxRoots(realPath, workspaceRoot) {
372
+ const candidates = [workspaceRoot, ...getWorkspaceExtraRoots()];
373
+ for (const root of candidates) {
374
+ const realRoot = await realpathIfExists(root);
375
+ if (!realRoot)
376
+ continue;
377
+ if (realPath === realRoot || realPath.startsWith(realRoot + path.sep))
378
+ return true;
379
+ }
380
+ return false;
381
+ }
382
+ function sandboxEscapeError(realPath, workspaceRoot) {
383
+ const extras = getWorkspaceExtraRoots();
384
+ const extraHint = extras.length > 0
385
+ ? ` Also checked BARIVIA_WORKSPACE_EXTRA_ROOTS (${extras.join(", ")}).`
386
+ : " Set BARIVIA_WORKSPACE_EXTRA_ROOTS to allow shared fixture dirs, or copy the file into the workspace.";
387
+ return new Error(`Resolved path escapes workspace — symlink resolves outside BARIVIA_WORKSPACE_ROOT` +
388
+ ` (${workspaceRoot}). Target: ${realPath}.${extraHint}`);
389
+ }
264
390
  /**
265
391
  * Resolve file_path for dataset upload. Rejects "..", enforces workspace sandbox
266
392
  * by default (set BARIVIA_ENFORCE_WORKSPACE_SANDBOX=0 to allow absolute paths).
393
+ * Symlink targets under BARIVIA_WORKSPACE_EXTRA_ROOTS are allowed when sandbox is on.
267
394
  */
268
395
  export async function resolveFilePathForUpload(filePath, mcpServer) {
269
396
  const trimmed = filePath.trim();
@@ -272,9 +399,8 @@ export async function resolveFilePathForUpload(filePath, mcpServer) {
272
399
  const checkUnder = async (p) => {
273
400
  const root = await getWorkspaceRootAsync(mcpServer);
274
401
  const real = await fs.realpath(p);
275
- const realRoot = await fs.realpath(root);
276
- if (real !== realRoot && !real.startsWith(realRoot + path.sep)) {
277
- throw new Error(`Path outside the workspace is disabled (BARIVIA_ENFORCE_WORKSPACE_SANDBOX). Workspace root: ${realRoot}`);
402
+ if (!(await isPathUnderSandboxRoots(real, root))) {
403
+ throw sandboxEscapeError(real, root);
278
404
  }
279
405
  const stat = await fs.stat(real);
280
406
  if (!stat.isFile())
@@ -294,6 +420,9 @@ export async function resolveFilePathForUpload(filePath, mcpServer) {
294
420
  }
295
421
  const root = await getWorkspaceRootAsync(mcpServer);
296
422
  const resolved = path.resolve(root, trimmed);
423
+ if (resolved !== root && !resolved.startsWith(root + path.sep)) {
424
+ throw new Error("Path must be within the workspace directory.");
425
+ }
297
426
  return checkUnder(resolved);
298
427
  }
299
428
  function looksLikeHtml(bodyText) {
@@ -357,7 +486,15 @@ export function classifyApiError(status, bodyText, requestId) {
357
486
  }
358
487
  else if (status === 409) {
359
488
  cause = "client";
360
- hint = "The job may not be in the expected state.";
489
+ if (apiCode === "staging_not_ready") {
490
+ const stageId = parsed?.stage_job_id != null ? String(parsed.stage_job_id) : "";
491
+ hint = stageId
492
+ ? `Wait for stage_dataset to finish: barmesh_jobs(action=monitor, job_id="${stageId}"), then retry. Prefer barmesh_datasets(action=get) train_ready=true.`
493
+ : "Wait for dataset staging (status=ready with staged_prefix / train_ready=true), then retry.";
494
+ }
495
+ else {
496
+ hint = "The job may not be in the expected state.";
497
+ }
361
498
  retryable = false;
362
499
  }
363
500
  else if (status === 429) {
@@ -685,20 +822,65 @@ export function textResult(data) {
685
822
  }
686
823
  export async function pollUntilComplete(jobId, maxWaitMs = 30_000, intervalMs = 2000) {
687
824
  const start = Date.now();
825
+ /** Brief 404s happen when finalize_job_id is visible before the child row is readable. */
826
+ let notFoundStreak = 0;
827
+ const maxNotFoundRetries = 5;
688
828
  while (Date.now() - start < maxWaitMs) {
689
- const data = (await apiCall("GET", `/v1/jobs/${jobId}`));
690
- const status = data.status;
691
- if (status === "completed" || status === "failed" || status === "cancelled") {
692
- return {
693
- status,
694
- result_ref: data.result_ref,
695
- error: data.error,
696
- };
829
+ try {
830
+ const data = (await apiCall("GET", `/v1/jobs/${jobId}`));
831
+ notFoundStreak = 0;
832
+ const status = data.status;
833
+ if (status === "completed" || status === "failed" || status === "cancelled") {
834
+ return {
835
+ status,
836
+ result_ref: data.result_ref,
837
+ error: data.error,
838
+ };
839
+ }
840
+ }
841
+ catch (err) {
842
+ const status = err?.httpStatus;
843
+ if (status === 404 && notFoundStreak < maxNotFoundRetries) {
844
+ notFoundStreak += 1;
845
+ await new Promise((r) => setTimeout(r, intervalMs));
846
+ continue;
847
+ }
848
+ throw err;
697
849
  }
698
850
  await new Promise((r) => setTimeout(r, intervalMs));
699
851
  }
700
852
  return { status: "timeout" };
701
853
  }
854
+ const RESULTS_FIGURE_ENUMS = new Set(["default", "all", "none"]);
855
+ /**
856
+ * Coerce host-stringified figures args (e.g. `'["all"]'`) and singleton enum arrays
857
+ * (`["all"]` → `"all"`) before Zod validation.
858
+ */
859
+ export function coerceFiguresArg(v) {
860
+ if (v === undefined || v === null)
861
+ return v;
862
+ let val = v;
863
+ if (typeof val === "string") {
864
+ const trimmed = val.trim();
865
+ if ((trimmed.startsWith("[") && trimmed.endsWith("]")) ||
866
+ (trimmed.startsWith('"') && trimmed.endsWith('"'))) {
867
+ try {
868
+ val = JSON.parse(trimmed);
869
+ }
870
+ catch {
871
+ return v;
872
+ }
873
+ }
874
+ }
875
+ if (Array.isArray(val)) {
876
+ const asStrings = val.map((x) => String(x));
877
+ if (asStrings.length === 1 && RESULTS_FIGURE_ENUMS.has(asStrings[0])) {
878
+ return asStrings[0];
879
+ }
880
+ return asStrings;
881
+ }
882
+ return val;
883
+ }
702
884
  // ---------------------------------------------------------------------------
703
885
  // Image helpers
704
886
  // ---------------------------------------------------------------------------
package/dist/tools/cfd.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { registerAuditedTool } from "../audit.js";
3
- import { apiCall, textResult, suggestAfterCfdSubmit } from "../shared.js";
3
+ import { apiCall, ensureDatasetTrainReady, textResult, suggestAfterCfdSubmit } from "../shared.js";
4
4
  import { pollCfdPrepareIfPresent } from "../cfd_prepare.js";
5
5
  export function registerCfdTools(server) {
6
6
  registerAuditedTool(server, "barmesh_mesh_convergence", `Run SOM-based mesh-convergence analysis on an uploaded combined per-cell CSV.
@@ -53,6 +53,7 @@ COMMON MISTAKES: omitting feature_columns (required); choosing a reference_mesh
53
53
  body.description = description;
54
54
  if (tags !== undefined)
55
55
  body.tags = tags;
56
+ await ensureDatasetTrainReady(String(dataset_id));
56
57
  const data = (await apiCall("POST", "/v1/cfd/mesh-convergence", body));
57
58
  await pollCfdPrepareIfPresent(data, "barmesh_mesh_convergence");
58
59
  const id = data.id;
@@ -4,9 +4,13 @@ import { z } from "zod";
4
4
  import fs from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { registerAuditedTool } from "../audit.js";
7
- import { apiCall, getWorkspaceRootAsync, resolveFilePathForUpload, textResult, pollUntilComplete, UPLOAD_DATASET_TIMEOUT_MS, LARGE_UPLOAD_BYTES, PRESIGNED_PUT_TIMEOUT_MS, POLL_STAGE_MAX_MS, streamFileSha256, putPresignedStream, resolveUploadContentType, suggestMeshDatasetPreview, } from "../shared.js";
7
+ import { apiCall, getWorkspaceRootAsync, resolveFilePathForUpload, textResult, pollUntilComplete, UPLOAD_DATASET_TIMEOUT_MS, LARGE_UPLOAD_BYTES, PRESIGNED_PUT_TIMEOUT_MS, POLL_STAGE_MAX_MS, awaitDatasetStageIfNeeded, streamFileSha256, putPresignedStream, resolveUploadContentType, suggestMeshDatasetPreview, } from "../shared.js";
8
8
  import { GZIP_UPLOAD_HINT } from "../upload_hints.js";
9
9
  import { formatDatasetInventoryLine, formatTags } from "../inventory_format.js";
10
+ /** Percent-encode header values so Node fetch accepts Unicode (ByteString limit). */
11
+ function encodeHeaderValue(s) {
12
+ return encodeURIComponent(s);
13
+ }
10
14
  /**
11
15
  * Normalize a nullable string field from the API. Returns "" for absent values,
12
16
  * empty strings, and the literal SQL/serialization sentinels "missing"/"null".
@@ -169,16 +173,16 @@ ESCALATION: If preview shows a feature column as non-numeric, fix the extraction
169
173
  if (isGzipInput) {
170
174
  const gzBytes = await fs.readFile(resolved);
171
175
  const gzHeaders = {
172
- "X-Dataset-Name": name,
176
+ "X-Dataset-Name": encodeHeaderValue(name),
173
177
  "Content-Type": uploadContentType,
174
178
  "Content-Encoding": "gzip",
175
179
  "Idempotency-Key": createHash("sha256").update(`${name}\n`).update(gzBytes).digest("hex"),
176
180
  };
177
181
  if (description !== undefined)
178
- gzHeaders["X-Dataset-Description"] = description;
182
+ gzHeaders["X-Dataset-Description"] = encodeHeaderValue(description);
179
183
  if (tags !== undefined)
180
- gzHeaders["X-Dataset-Tags"] = JSON.stringify(tags);
181
- const data = (await apiCall("POST", "/v1/datasets", gzBytes, gzHeaders, UPLOAD_DATASET_TIMEOUT_MS));
184
+ gzHeaders["X-Dataset-Tags"] = encodeHeaderValue(JSON.stringify(tags));
185
+ const data = await awaitDatasetStageIfNeeded((await apiCall("POST", "/v1/datasets", gzBytes, gzHeaders, UPLOAD_DATASET_TIMEOUT_MS)));
182
186
  const gid = data.id ?? data.dataset_id;
183
187
  if (gid != null) {
184
188
  data.suggested_next_step = suggestMeshDatasetPreview(String(gid));
@@ -195,20 +199,20 @@ ESCALATION: If preview shows a feature column as non-numeric, fix the extraction
195
199
  }
196
200
  const GZIP_THRESHOLD = 1024 * 1024;
197
201
  const uploadHeaders = {
198
- "X-Dataset-Name": name,
202
+ "X-Dataset-Name": encodeHeaderValue(name),
199
203
  "Content-Type": uploadContentType,
200
204
  "Idempotency-Key": createHash("sha256").update(`${name}\n`).update(body).digest("hex"),
201
205
  };
202
206
  if (description !== undefined)
203
- uploadHeaders["X-Dataset-Description"] = description;
207
+ uploadHeaders["X-Dataset-Description"] = encodeHeaderValue(description);
204
208
  if (tags !== undefined)
205
- uploadHeaders["X-Dataset-Tags"] = JSON.stringify(tags);
209
+ uploadHeaders["X-Dataset-Tags"] = encodeHeaderValue(JSON.stringify(tags));
206
210
  let uploadBody = body;
207
211
  if (Buffer.byteLength(body, "utf-8") > GZIP_THRESHOLD) {
208
212
  uploadBody = gzipSync(Buffer.from(body, "utf-8"));
209
213
  uploadHeaders["Content-Encoding"] = "gzip";
210
214
  }
211
- const data = (await apiCall("POST", "/v1/datasets", uploadBody, uploadHeaders, UPLOAD_DATASET_TIMEOUT_MS));
215
+ const data = await awaitDatasetStageIfNeeded((await apiCall("POST", "/v1/datasets", uploadBody, uploadHeaders, UPLOAD_DATASET_TIMEOUT_MS)));
212
216
  const id = data.id ?? data.dataset_id;
213
217
  if (id != null) {
214
218
  data.suggested_next_step = suggestMeshDatasetPreview(String(id));
@@ -259,16 +263,20 @@ ESCALATION: If preview shows a feature column as non-numeric, fix the extraction
259
263
  throw new Error("barmesh_datasets(get) requires dataset_id.");
260
264
  const ds = (await apiCall("GET", `/v1/datasets/${dataset_id}`));
261
265
  const tagStr = formatTags(ds.tags);
266
+ const trainReady = ds.train_ready === true;
262
267
  const lines = [
263
268
  `Dataset: ${ds.name ?? "?"} (${ds.id ?? dataset_id})`,
264
- `Status: ${ds.status ?? "ready"}`,
269
+ `Status: ${ds.status ?? "unknown"}`,
270
+ `Train ready: ${trainReady ? "yes" : "no"}`,
265
271
  `Rows × cols: ${ds.rows ?? "?"} × ${ds.cols ?? "?"}`,
266
272
  ds.size_bytes != null ? `Size: ${Number(ds.size_bytes).toLocaleString()} bytes` : "",
267
273
  ds.description != null && String(ds.description).trim() !== "" ? `Description: ${String(ds.description)}` : "",
268
274
  tagStr ? `Tags: ${tagStr}` : "",
269
275
  ds.staged_prefix != null ? `Staged prefix: ${String(ds.staged_prefix)}` : "",
270
276
  ds.staged_version != null ? `Staged version: ${String(ds.staged_version)}` : "",
271
- ds.stage_job_id != null ? `Stage job: ${String(ds.stage_job_id)} (poll barmesh_jobs(action=status))` : "",
277
+ !trainReady && ds.stage_job_id != null
278
+ ? `Stage job: ${String(ds.stage_job_id)} (poll barmesh_jobs(action=status) until train_ready)`
279
+ : "",
272
280
  cleanNullable(ds.ingest_error) ? `Ingest error: ${cleanNullable(ds.ingest_error)}` : "",
273
281
  ds.created_at != null ? `Created: ${String(ds.created_at)}` : "",
274
282
  ].filter(Boolean);
@@ -15,18 +15,19 @@ export function registerJobsTool(server) {
15
15
  | status | One-shot progress check |
16
16
  | list | Slim recent jobs (filter/page with limit/cursor/status/job_type/has_results) |
17
17
  | update | Edit label / description / tags |
18
+ | delete | Permanently remove a job and its result files |
18
19
  | inventory | Markdown overview of datasets + recent jobs |
19
20
 
20
- BEST FOR: action=monitor after submit (one call, throttled snapshots — preferred for agents). action=status for a single one-shot check. action=inventory for a durable org catalog.
21
+ BEST FOR: action=monitor after submit (one call, throttled snapshots — preferred for agents). action=status for a single one-shot check. action=inventory for a durable org catalog. action=delete for cleanup by known job_id (do not deep-page list to find IDs).
21
22
  MONITOR MODES: barmesh_training_monitor — default visual MCP App (live curves, post-hoc review on completed jobs). barmesh_jobs(action=monitor) — headless blocking snapshots for agents (live or post-hoc review; attaches learning_curve.png when already completed).
22
23
  ASYNC PROTOCOL: monitor blocks server-side until completed/failed or block_until timeout (default ${DEFAULT_BLOCK_UNTIL_SEC}s, poll every ${DEFAULT_POLL_INTERVAL_SEC}s). status is one-shot; poll every 10-20s manually if not using monitor. When status=completed, call barmesh_results(action=get, job_id=...) then barmesh_results_explorer(job_id=...).
23
24
  LIST: slim by default (label,id,status,job_type,dataset_id,created_at,result_ref,description,tags — no fat params). has_results=true is the results catalog.
24
25
  ESCALATION: status=failed returns an error message and (when available) a failure_stage; read it before retrying.
25
26
  UNAVAILABLE: If the mesh analysis API is temporarily unavailable, say so plainly, pause (proxy already burst-retried; wait ~retry_after_sec), and keep reviewing the last good distances/figures — do not treat the demo as failed. Suggest retry later.`, {
26
27
  action: z
27
- .enum(["status", "monitor", "list", "update", "inventory"])
28
- .describe("status: one-shot check; monitor: block until terminal; list: slim paginated jobs; update: metadata; inventory: datasets+jobs overview"),
29
- job_id: z.string().optional().describe("Job ID (required for status, monitor, update)"),
28
+ .enum(["status", "monitor", "list", "update", "delete", "inventory"])
29
+ .describe("status: one-shot check; monitor: block until terminal; list: slim paginated jobs; update: metadata; delete: remove job+results; inventory: datasets+jobs overview"),
30
+ job_id: z.string().optional().describe("Job ID (required for status, monitor, update, delete)"),
30
31
  block_until_sec: z
31
32
  .number()
32
33
  .int()
@@ -123,6 +124,12 @@ UNAVAILABLE: If the mesh analysis API is temporarily unavailable, say so plainly
123
124
  const data = await apiCall("PATCH", `/v1/jobs/${job_id}`, body);
124
125
  return textResult(data);
125
126
  }
127
+ if (action === "delete") {
128
+ if (!job_id)
129
+ throw new Error("barmesh_jobs(delete) requires job_id.");
130
+ const data = await apiCall("DELETE", `/v1/jobs/${job_id}`);
131
+ return textResult(data);
132
+ }
126
133
  if (action === "inventory") {
127
134
  const jobLimit = limit && limit > 0 ? Math.min(Math.floor(limit), 100) : 50;
128
135
  const [datasetsRaw, jobsRaw] = await Promise.all([
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { z } from "zod";
4
4
  import { registerAuditedTool } from "../audit.js";
5
- import { apiCall, apiRawCall, getWorkspaceRootAsync, sandboxPath, textResult, tryAttachImage, resetInlineAttachBudget, pollUntilComplete, POLL_STAGE_MAX_MS, suggestAfterRender, } from "../shared.js";
5
+ import { apiCall, apiRawCall, getWorkspaceRootAsync, sandboxPath, textResult, tryAttachImage, resetInlineAttachBudget, pollUntilComplete, POLL_STAGE_MAX_MS, suggestAfterRender, coerceFiguresArg, } from "../shared.js";
6
6
  import { formatConvergenceReading } from "../convergence_reading.js";
7
7
  import { formatRichardsonResultsSummary, isRichardsonJob } from "../richardson_results.js";
8
8
  const MESH_DEFAULT_FIGURES = ["combined", "overview_distances", "plot_vol_all_meshes", "plot_vol_steps", "learning_curve"];
@@ -73,7 +73,7 @@ export function registerResultsTool(server) {
73
73
  | feature_divergences | Refresh feature-plane KL matrices / optional Wasserstein on SOM component planes (async; not mesh fingerprint KL). |
74
74
 
75
75
  BEST FOR: After barmesh_jobs(action=status) shows completed (and finalize finished if defer_figures was used).
76
- FIGURES: Default bundle is combined.png, overview_distances.png (all KL/EMD panels), plot_vol_all_meshes.png, plot_vol_steps.png, and learning_curve.png — not redundant per-feature PNGs or divergence_kl. action=get inlines the headline set; pass figures="all" for every artifact listed in summary.files, figures="none" for metrics only (recommended for agents).
76
+ FIGURES: Default bundle is combined.png, overview_distances.png (all KL/EMD panels), plot_vol_all_meshes.png, plot_vol_steps.png, and learning_curve.png — not redundant per-feature PNGs or divergence_kl. Prefer string figures="all" / "none" (not an array); arrays are for specific base names or filenames. Hosts that stringify arrays are coerced. action=get inlines the headline set; pass figures="all" for every artifact listed in summary.files, figures="none" for metrics only (recommended for agents).
77
77
  FEATURE-PLANE KL: mesh SKL/EMD (KL_ref, EMD_*) compare meshes; divergence_kl / feature_divergences.json compare SOM component planes (barsom-parallel). Finalize already writes symmetric KL; use action=feature_divergences for W1 or a refresh.
78
78
  PDFs ON DEMAND: vector PDFs are not produced by default. Use action=render (format=pdf) once, then action=image or action=download.
79
79
  If GET /v1/results returns 404 shortly after status=completed, cfd_finalize may still be rendering — poll barmesh_jobs(status) until finalize_job_id completes.
@@ -85,10 +85,10 @@ NOT FOR: Submitting jobs.`, {
85
85
  job_id: z.string().describe("Job ID"),
86
86
  filename: z.string().optional().describe("For action=image: the figure filename (e.g. KL_ref.png or, after render, KL_ref.pdf)"),
87
87
  format: z.enum(["pdf", "png", "svg"]).optional().describe("For action=render: output format to generate (default pdf). PNG previews already exist by default."),
88
- figures: z
88
+ figures: z.preprocess(coerceFiguresArg, z
89
89
  .union([z.enum(["default", "all", "none"]), z.array(z.string())])
90
90
  .optional()
91
- .describe("For action=get/download: which figures (default headline panels; 'all'; 'none'; or a list of base names)"),
91
+ .describe("Prefer string 'all'/'none'. get/download: omit=headline panels; 'all'; 'none'; or array of base names/filenames. Stringified JSON arrays from hosts are accepted.")),
92
92
  folder: z.string().optional().describe("For action=download: directory to save into (relative to workspace). Files land in a per-job subfolder."),
93
93
  include_json: z.boolean().optional().describe("For action=download: also save summary.json and text/CSV artifacts"),
94
94
  metrics: z
@@ -235,7 +235,8 @@ NOT FOR: Submitting jobs.`, {
235
235
  }
236
236
  toDownload = [...new Set(toDownload)];
237
237
  let resolvedDir = sandboxPath(folder, await getWorkspaceRootAsync(server));
238
- const jobSubfolder = `cfd_mesh_convergence_${jobLabel ?? job_id}`.replace(/[^a-zA-Z0-9_.-]/g, "_");
238
+ const prefix = isRichardsonJob(summary) ? "cfd_richardson" : "cfd_mesh_convergence";
239
+ const jobSubfolder = `${prefix}_${jobLabel ?? job_id}`.replace(/[^a-zA-Z0-9_.-]/g, "_");
239
240
  resolvedDir = path.join(resolvedDir, jobSubfolder);
240
241
  await fs.mkdir(resolvedDir, { recursive: true });
241
242
  const saved = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barivia/barmesh-mcp",
3
- "version": "0.8.4",
3
+ "version": "0.8.8",
4
4
  "description": "barmesh MCP proxy — SOM-based CFD mesh-convergence and Richardson/GCI analysis on the Barivia cloud API",
5
5
  "keywords": [
6
6
  "mcp",