@barivia/barmesh-mcp 0.4.1 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -2
- package/dist/index.js +1 -1
- package/dist/job_status_format.js +89 -0
- package/dist/shared.js +45 -1
- package/dist/tools/barmesh_results_explorer.js +186 -0
- package/dist/tools/jobs.js +4 -9
- package/dist/tools/results.js +32 -7
- package/dist/views/src/views/barmesh-results-explorer/index.html +180 -0
- package/dist/viz-server.js +124 -0
- package/package.json +9 -4
package/README.md
CHANGED
|
@@ -55,10 +55,28 @@ API key; otherwise the analysis calls return HTTP 403. Contact Barivia to enable
|
|
|
55
55
|
| `barmesh_datasets` | Upload / preview / list / get / subset / delete the mesh CSV. |
|
|
56
56
|
| `barmesh_mesh_convergence` | SOM fingerprint distances (async job). |
|
|
57
57
|
| `barmesh_richardson` | Richardson/GCI on scalar QoIs (async job). |
|
|
58
|
-
| `barmesh_jobs` | Poll job status / list jobs (auto-polls CFD prepare + finalize when applicable). |
|
|
59
|
-
| `barmesh_results` | Distances, convergence reading, and figures. |
|
|
58
|
+
| `barmesh_jobs` | Poll job status / list jobs (auto-polls CFD prepare + finalize when applicable). Reports phase, epoch/total, elapsed, ETA, and QE live during the SOM training. |
|
|
59
|
+
| `barmesh_results` | Distances, convergence reading, and figures. `action=get` inlines a single `combined.png` overview by default; `action=render` produces publication PDFs on demand. |
|
|
60
|
+
| `barmesh_results_explorer` | Interactive MCP App (or standalone localhost page) to browse the distances, convergence reading, and every figure after a job completes. |
|
|
60
61
|
| `barmesh_send_feedback` | Send a short note or bug report to the Barivia team. |
|
|
61
62
|
|
|
63
|
+
### Figures and progress (0.5.0)
|
|
64
|
+
|
|
65
|
+
- **Combined overview:** every completed `mesh_convergence` job produces a single
|
|
66
|
+
`combined.png` (coarse vs reference volume fingerprint, KL-vs-reference, EMD-vs-reference)
|
|
67
|
+
as the headline artifact, alongside per-panel PNG previews.
|
|
68
|
+
- **PDFs on demand:** publication vector PDFs are NOT generated by default. Render them
|
|
69
|
+
after completion with `barmesh_results(action=render, format=pdf)`, then download each
|
|
70
|
+
with `barmesh_results(action=image, filename="KL_ref.pdf")`. This avoids generating
|
|
71
|
+
~2× the artifacts (and storage/finalize cost) on every run.
|
|
72
|
+
- **Adaptive layout:** the stepwise volume panels use a single row for small studies and
|
|
73
|
+
auto-wrap into a near-square grid for many meshes; figures are not cavity-specific.
|
|
74
|
+
- **Live progress:** `barmesh_jobs(action=status)` exposes phase, epoch/total, elapsed,
|
|
75
|
+
ETA, and quantization error during the SOM training (barsom parity).
|
|
76
|
+
- **Results explorer:** `barmesh_results_explorer(job_id)` opens an interactive figure
|
|
77
|
+
browser. It embeds as an MCP App in supporting hosts (Claude, VS Code, etc.) and falls
|
|
78
|
+
back to a standalone `localhost` page otherwise (set `BARIVIA_VIZ_PORT` for a stable port).
|
|
79
|
+
|
|
62
80
|
### Migration notes
|
|
63
81
|
|
|
64
82
|
- **`send_feedback` → `barmesh_send_feedback` (0.3.0):** the feedback tool was renamed so it no longer collides with the `@barivia/barsom-mcp` tool of the same name when both servers are enabled in one client. Update any direct call sites; the behavior is unchanged.
|
|
@@ -84,3 +102,4 @@ Parquet staging is supported by the API but not yet exposed as an MCP upload for
|
|
|
84
102
|
| `BARIVIA_FETCH_TIMEOUT_MS` | `60000` | Per-request timeout (raise for large uploads). |
|
|
85
103
|
| `BARIVIA_WORKSPACE_ROOT` | workspace/cwd | Root for resolving relative `file_path` uploads. |
|
|
86
104
|
| `BARIVIA_ENFORCE_WORKSPACE_SANDBOX` | `1` | Restrict uploads to the workspace; set `0` to allow absolute paths. |
|
|
105
|
+
| `BARIVIA_VIZ_PORT` | ephemeral | Fixed localhost port for the `barmesh_results_explorer` standalone page (otherwise OS-assigned per session). |
|
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
|
|
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 f}from"./tools/cfd.js";import{registerJobsTool as b}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{registerFeedbackTool as g}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_jobs(action=status) — poll every 10-20s, for minutes if needed.\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>"}]}}),h(i),y(i),u(i),f(i),b(i),v(i),g(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 w=i.server;w.oninitialized=()=>{const e=w.getClientCapabilities(),r=s(e);p(!!r?.mimeTypes?.includes(n))};const j=new r;await i.connect(j),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)});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format barmesh_jobs(status) text from GET /v1/jobs/:id (barsom parity).
|
|
3
|
+
* Surfaces phase, elapsed, ETA, and epoch N/M during the SOM training of a
|
|
4
|
+
* cfd_mesh_convergence job, plus CFD finalize (figure render) sub-status.
|
|
5
|
+
*/
|
|
6
|
+
function formatElapsedPart(data, status) {
|
|
7
|
+
const wall = data.wall_elapsed_sec != null && !Number.isNaN(Number(data.wall_elapsed_sec))
|
|
8
|
+
? Number(data.wall_elapsed_sec)
|
|
9
|
+
: null;
|
|
10
|
+
const kernel = data.kernel_elapsed_sec != null && !Number.isNaN(Number(data.kernel_elapsed_sec))
|
|
11
|
+
? Number(data.kernel_elapsed_sec)
|
|
12
|
+
: data.training_elapsed_sec != null && !Number.isNaN(Number(data.training_elapsed_sec))
|
|
13
|
+
? Number(data.training_elapsed_sec)
|
|
14
|
+
: null;
|
|
15
|
+
if (wall != null && wall >= 0) {
|
|
16
|
+
const wallR = Math.round(wall);
|
|
17
|
+
if (status === "completed" && kernel != null && kernel >= 0 && Math.abs(wallR - Math.round(kernel)) >= 2) {
|
|
18
|
+
return `elapsed: ${wallR}s (kernel training: ${Math.round(kernel)}s)`;
|
|
19
|
+
}
|
|
20
|
+
return `elapsed: ${wallR}s`;
|
|
21
|
+
}
|
|
22
|
+
if (kernel != null && kernel >= 0) {
|
|
23
|
+
return `elapsed: ${Math.round(kernel)}s`;
|
|
24
|
+
}
|
|
25
|
+
const startedAt = data.started_at != null ? String(data.started_at) : null;
|
|
26
|
+
if (startedAt) {
|
|
27
|
+
const startedMs = Date.parse(startedAt);
|
|
28
|
+
if (!Number.isNaN(startedMs)) {
|
|
29
|
+
const elapsedSec = Math.max(0, Math.round((Date.now() - startedMs) / 1000));
|
|
30
|
+
return `elapsed: ${elapsedSec}s`;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
export function formatJobStatusText(job_id, data) {
|
|
36
|
+
const status = String(data.status ?? "unknown");
|
|
37
|
+
const progress = (data.progress ?? 0) * 100;
|
|
38
|
+
const label = data.label != null && data.label !== "" ? String(data.label) : null;
|
|
39
|
+
const jobDesc = label ? `Job ${label} (id: ${job_id})` : `Job ${job_id}`;
|
|
40
|
+
const parts = [`${jobDesc}: ${status} (${progress.toFixed(1)}%)`];
|
|
41
|
+
const attempt = data.attempt != null ? Number(data.attempt) : null;
|
|
42
|
+
if (attempt != null && attempt > 1) {
|
|
43
|
+
parts.push(`attempt ${attempt} (job was requeued after worker timeout — not stuck from scratch)`);
|
|
44
|
+
}
|
|
45
|
+
const phase = data.progress_phase != null && String(data.progress_phase) !== ""
|
|
46
|
+
? String(data.progress_phase)
|
|
47
|
+
: null;
|
|
48
|
+
if (status === "running") {
|
|
49
|
+
parts.push(phase ? `phase: ${phase}` : `phase: preprocessing (download/parse before first tick)`);
|
|
50
|
+
}
|
|
51
|
+
const startedAt = data.started_at != null ? String(data.started_at) : null;
|
|
52
|
+
if (startedAt)
|
|
53
|
+
parts.push(`started_at: ${startedAt}`);
|
|
54
|
+
const elapsedPart = formatElapsedPart(data, status);
|
|
55
|
+
if (elapsedPart)
|
|
56
|
+
parts.push(elapsedPart);
|
|
57
|
+
if (status === "running") {
|
|
58
|
+
const etaSec = data.training_eta_sec != null ? Number(data.training_eta_sec) : null;
|
|
59
|
+
if (etaSec != null && etaSec > 0)
|
|
60
|
+
parts.push(`ETA ~${Math.round(etaSec)}s`);
|
|
61
|
+
const epoch = data.epoch != null ? Number(data.epoch) : null;
|
|
62
|
+
const totalEpochs = data.total_epochs != null ? Number(data.total_epochs) : null;
|
|
63
|
+
if (epoch != null && totalEpochs != null && totalEpochs > 0)
|
|
64
|
+
parts.push(`epoch ${epoch}/${totalEpochs}`);
|
|
65
|
+
const qe = data.quantization_error != null ? Number(data.quantization_error) : null;
|
|
66
|
+
if (qe != null && !Number.isNaN(qe))
|
|
67
|
+
parts.push(`QE ${qe.toFixed(4)}`);
|
|
68
|
+
}
|
|
69
|
+
if (status === "running" && progress < 5 && !phase) {
|
|
70
|
+
parts.push("Advisory: still in early preprocessing — epoch counter starts after ingest completes.");
|
|
71
|
+
}
|
|
72
|
+
if (status === "completed") {
|
|
73
|
+
const finalizeId = data.finalize_job_id != null && String(data.finalize_job_id) !== "" ? String(data.finalize_job_id) : null;
|
|
74
|
+
if (finalizeId) {
|
|
75
|
+
parts.push(`Compute done; cfd_finalize still rendering figures (finalize_job_id=${finalizeId}). Poll barmesh_jobs(action=status) again to auto-wait before barmesh_results(action=get).`);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
parts.push(`Results ready. Use barmesh_results(action=get, job_id="${job_id}") for distances and figures, or barmesh_results_explorer(job_id="${job_id}") to browse interactively.`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
else if (status === "failed") {
|
|
82
|
+
const failureStage = data.failure_stage != null && String(data.failure_stage) !== "" ? ` [${data.failure_stage}]` : "";
|
|
83
|
+
parts.push(`Error${failureStage}: ${data.error ?? "unknown"}`);
|
|
84
|
+
}
|
|
85
|
+
else if (status === "cancelled") {
|
|
86
|
+
parts.push(`Cancelled. Poll again to confirm; dataset and partial worker state are unchanged.`);
|
|
87
|
+
}
|
|
88
|
+
return parts.join(" | ");
|
|
89
|
+
}
|
package/dist/shared.js
CHANGED
|
@@ -22,7 +22,7 @@ export const FETCH_TIMEOUT_MS = parseInt(process.env.BARIVIA_FETCH_TIMEOUT_MS ??
|
|
|
22
22
|
export const MAX_RETRIES = 2;
|
|
23
23
|
export const RETRYABLE_STATUS = new Set([502, 503, 504]);
|
|
24
24
|
/** Single source of truth for the proxy version. Keep in sync with package.json on bump. */
|
|
25
|
-
export const CLIENT_VERSION = "0.
|
|
25
|
+
export const CLIENT_VERSION = "0.5.1";
|
|
26
26
|
export const PUBLIC_SITE_ORIGIN = "https://barivia.se";
|
|
27
27
|
/** Large per-cell CSV uploads may exceed the default fetch timeout. */
|
|
28
28
|
export const UPLOAD_DATASET_TIMEOUT_MS = 180_000;
|
|
@@ -95,6 +95,30 @@ export async function putPresignedStream(url, srcPath, contentType, timeoutMs =
|
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
// ---------------------------------------------------------------------------
|
|
98
|
+
// MCP App view state (results explorer)
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
let _vizPort = 0;
|
|
101
|
+
let _clientSupportsMcpApps = false;
|
|
102
|
+
export function getVizPort() {
|
|
103
|
+
return _vizPort;
|
|
104
|
+
}
|
|
105
|
+
export function setVizPort(port) {
|
|
106
|
+
_vizPort = port;
|
|
107
|
+
}
|
|
108
|
+
export function getClientSupportsMcpApps() {
|
|
109
|
+
return _clientSupportsMcpApps;
|
|
110
|
+
}
|
|
111
|
+
export function setClientSupportsMcpApps(value) {
|
|
112
|
+
_clientSupportsMcpApps = value;
|
|
113
|
+
}
|
|
114
|
+
/** Tool result carrying both a structured payload (for the MCP App view) and text/image content. */
|
|
115
|
+
export function structuredTextResult(structuredContent, text, content) {
|
|
116
|
+
return {
|
|
117
|
+
structuredContent,
|
|
118
|
+
content: content ?? [{ type: "text", text: text ?? JSON.stringify(structuredContent, null, 2) }],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
98
122
|
// Fetch helpers
|
|
99
123
|
// ---------------------------------------------------------------------------
|
|
100
124
|
export function isTransientError(err, status) {
|
|
@@ -370,6 +394,26 @@ export function getCaptionForImage(filename) {
|
|
|
370
394
|
return `Component plane for ${base.replace(/^plot_/, "")} on the trained SOM.`;
|
|
371
395
|
return "";
|
|
372
396
|
}
|
|
397
|
+
// ---------------------------------------------------------------------------
|
|
398
|
+
// View loading (bundled single-file HTML for the results explorer MCP App)
|
|
399
|
+
// ---------------------------------------------------------------------------
|
|
400
|
+
const BASE_DIR = import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url));
|
|
401
|
+
export async function loadViewHtml(viewName) {
|
|
402
|
+
const candidates = [
|
|
403
|
+
path.join(BASE_DIR, "views", "src", "views", viewName, "index.html"),
|
|
404
|
+
path.join(BASE_DIR, "views", viewName, "index.html"),
|
|
405
|
+
path.join(BASE_DIR, "..", "dist", "views", "src", "views", viewName, "index.html"),
|
|
406
|
+
];
|
|
407
|
+
for (const p of candidates) {
|
|
408
|
+
try {
|
|
409
|
+
return await fs.readFile(p, "utf-8");
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
373
417
|
export async function tryAttachImage(content, jobId, filename) {
|
|
374
418
|
if (filename.endsWith(".pdf") || filename.endsWith(".svg")) {
|
|
375
419
|
content.push({
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
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";
|
|
5
|
+
export const RESULTS_EXPLORER_URI = "ui://barmesh/results-explorer";
|
|
6
|
+
function labelForFigure(filename) {
|
|
7
|
+
const base = filename.replace(/\.(png|pdf|svg)$/i, "");
|
|
8
|
+
if (base === "combined")
|
|
9
|
+
return "Combined overview";
|
|
10
|
+
if (base === "KL_ref")
|
|
11
|
+
return "KL divergence vs reference";
|
|
12
|
+
if (base === "KL_stepwise")
|
|
13
|
+
return "KL divergence stepwise";
|
|
14
|
+
if (base === "EMD_ref")
|
|
15
|
+
return "Wasserstein (EMD) vs reference";
|
|
16
|
+
if (base === "EMD_stepwise")
|
|
17
|
+
return "Wasserstein (EMD) stepwise";
|
|
18
|
+
if (base === "plot_vol_coarse_fine")
|
|
19
|
+
return "Volume fingerprint: coarse vs reference";
|
|
20
|
+
if (base.startsWith("plot_vol_"))
|
|
21
|
+
return "Volume fingerprint: stepwise";
|
|
22
|
+
if (base.startsWith("plot_"))
|
|
23
|
+
return `Component plane: ${base.replace(/^plot_/, "").replace(/_/g, " ")}`;
|
|
24
|
+
return base.replace(/_/g, " ");
|
|
25
|
+
}
|
|
26
|
+
function figureKind(base) {
|
|
27
|
+
if (base === "combined")
|
|
28
|
+
return "summary";
|
|
29
|
+
if (base.startsWith("plot_") && !base.startsWith("plot_vol_"))
|
|
30
|
+
return "component";
|
|
31
|
+
return "diagnostic";
|
|
32
|
+
}
|
|
33
|
+
function buildMetrics(summary) {
|
|
34
|
+
const grid = summary.grid ?? [];
|
|
35
|
+
const qe = summary.quantization_error != null ? Number(summary.quantization_error).toFixed(4) : "N/A";
|
|
36
|
+
const te = summary.topographic_error != null ? Number(summary.topographic_error).toFixed(4) : "N/A";
|
|
37
|
+
const meshes = summary.meshes ?? [];
|
|
38
|
+
return [
|
|
39
|
+
{ label: "Grid", value: grid.length >= 2 ? `${grid[0]}×${grid[1]}` : "N/A" },
|
|
40
|
+
{ label: "Preset", value: String(summary.preset ?? "generic") },
|
|
41
|
+
{ label: "Reference", value: String(summary.reference_mesh ?? "N/A") },
|
|
42
|
+
{ label: "Meshes", value: String(meshes.length || "N/A") },
|
|
43
|
+
{ label: "QE", value: qe },
|
|
44
|
+
{ label: "TE", value: te },
|
|
45
|
+
{ label: "EMD method", value: String(summary.emd_method ?? "exact") },
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
function buildHighlights(summary) {
|
|
49
|
+
const out = [];
|
|
50
|
+
const convergence = summary.convergence;
|
|
51
|
+
const emd = convergence?.emd;
|
|
52
|
+
const skl = convergence?.skl;
|
|
53
|
+
if (emd) {
|
|
54
|
+
const plateau = emd.plateau === true ? "plateauing" : "not yet plateauing";
|
|
55
|
+
const mono = emd.monotonic_decrease === true ? "monotonic decrease" : "non-monotonic";
|
|
56
|
+
out.push(`EMD vs reference: ${mono}, ${plateau}.`);
|
|
57
|
+
}
|
|
58
|
+
if (skl) {
|
|
59
|
+
const plateau = skl.plateau === true ? "plateauing" : "not yet plateauing";
|
|
60
|
+
out.push(`Symmetric KL vs reference: ${plateau}.`);
|
|
61
|
+
}
|
|
62
|
+
out.push("Advisory: SOM distances complement, not replace, numerical uncertainty analysis (Richardson/GCI).");
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Group `<base>.<ext>` figure files into one logical figure per base, collecting the
|
|
67
|
+
* available formats. Preview is the inline-displayable raster (PNG, else SVG); the
|
|
68
|
+
* download target is the best quality (SVG > PDF > PNG). Order follows first appearance
|
|
69
|
+
* in `files` (combined first, then diagnostics, then component planes).
|
|
70
|
+
*/
|
|
71
|
+
function groupFigures(files) {
|
|
72
|
+
const byBase = new Map();
|
|
73
|
+
for (const f of files) {
|
|
74
|
+
const m = f.match(/^(.*)\.(png|pdf|svg)$/i);
|
|
75
|
+
if (!m)
|
|
76
|
+
continue;
|
|
77
|
+
const base = m[1];
|
|
78
|
+
const ext = m[2].toLowerCase();
|
|
79
|
+
const set = byBase.get(base) ?? new Set();
|
|
80
|
+
set.add(ext);
|
|
81
|
+
byBase.set(base, set);
|
|
82
|
+
}
|
|
83
|
+
return [...byBase.entries()].map(([base, extSet]) => {
|
|
84
|
+
const formats = [...extSet];
|
|
85
|
+
const preview = extSet.has("png") ? `${base}.png` : extSet.has("svg") ? `${base}.svg` : `${base}.${formats[0]}`;
|
|
86
|
+
const downloadFilename = extSet.has("svg") ? `${base}.svg` : extSet.has("pdf") ? `${base}.pdf` : `${base}.png`;
|
|
87
|
+
const vector = formats.filter((e) => e === "pdf" || e === "svg");
|
|
88
|
+
const baseCaption = getCaptionForImage(preview) || "";
|
|
89
|
+
const vectorNote = vector.length > 0
|
|
90
|
+
? `High-quality ${vector.join("/").toUpperCase()} available — download it (standalone page) or fetch with barmesh_results(action=image, filename="${downloadFilename}").`
|
|
91
|
+
: "";
|
|
92
|
+
return {
|
|
93
|
+
key: base,
|
|
94
|
+
label: labelForFigure(preview),
|
|
95
|
+
filename: preview,
|
|
96
|
+
downloadFilename,
|
|
97
|
+
formats,
|
|
98
|
+
kind: figureKind(base),
|
|
99
|
+
caption: [baseCaption, vectorNote].filter(Boolean).join(" ") || undefined,
|
|
100
|
+
};
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
export function buildPayload(jobId, data) {
|
|
104
|
+
const summary = (data.summary ?? {});
|
|
105
|
+
const files = (summary.files ?? []).filter((f) => /\.(png|pdf|svg)$/i.test(f));
|
|
106
|
+
// Embedded view: never set `url` (sandboxed iframe cannot load localhost) — figures
|
|
107
|
+
// lazy-load as base64 through the _barmesh_fetch_figure bridge tool. One entry per
|
|
108
|
+
// logical figure; PNG previews inline, vector (PDF/SVG) is offered for download.
|
|
109
|
+
const figures = groupFigures(files);
|
|
110
|
+
const port = getVizPort();
|
|
111
|
+
return {
|
|
112
|
+
type: "barmesh-results-explorer",
|
|
113
|
+
jobId,
|
|
114
|
+
title: "Mesh Convergence Results",
|
|
115
|
+
subtitle: String(data.label ?? ""),
|
|
116
|
+
metrics: buildMetrics(summary),
|
|
117
|
+
highlights: buildHighlights(summary),
|
|
118
|
+
availableFigures: figures,
|
|
119
|
+
defaultFigureKey: figures.find((f) => f.key === "combined")?.key ??
|
|
120
|
+
figures.find((f) => f.key === "KL_ref")?.key ??
|
|
121
|
+
figures[0]?.key,
|
|
122
|
+
standaloneUrl: port
|
|
123
|
+
? `http://localhost:${port}/viz/barmesh-results-explorer?mode=standalone&job_id=${jobId}`
|
|
124
|
+
: undefined,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
async function handleResultsExplorer(job_id) {
|
|
128
|
+
const data = (await apiCall("GET", `/v1/results/${job_id}`));
|
|
129
|
+
const payload = buildPayload(job_id, data);
|
|
130
|
+
const content = [
|
|
131
|
+
{
|
|
132
|
+
type: "text",
|
|
133
|
+
text: [
|
|
134
|
+
`Mesh convergence results explorer ready for job ${job_id}.`,
|
|
135
|
+
payload.subtitle ? `Label: ${payload.subtitle}` : "",
|
|
136
|
+
payload.highlights[0] ?? "",
|
|
137
|
+
]
|
|
138
|
+
.filter(Boolean)
|
|
139
|
+
.join("\n"),
|
|
140
|
+
},
|
|
141
|
+
];
|
|
142
|
+
const defaultFigure = payload.availableFigures.find((f) => f.key === payload.defaultFigureKey);
|
|
143
|
+
if (defaultFigure) {
|
|
144
|
+
await tryAttachImage(content, job_id, defaultFigure.filename);
|
|
145
|
+
}
|
|
146
|
+
if (!getClientSupportsMcpApps() && payload.standaloneUrl) {
|
|
147
|
+
content.push({
|
|
148
|
+
type: "text",
|
|
149
|
+
text: `Interactive results explorer: [Open results explorer](${payload.standaloneUrl})\n` +
|
|
150
|
+
`AGENT: surface this to the user as a clickable markdown link in your reply. ` +
|
|
151
|
+
`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.`,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return structuredTextResult(payload, undefined, content);
|
|
155
|
+
}
|
|
156
|
+
export function registerResultsExplorerTool(server) {
|
|
157
|
+
const toolConfig = {
|
|
158
|
+
title: "Mesh Convergence Results Explorer",
|
|
159
|
+
description: "PREFERRED way to browse a completed mesh_convergence job after a first barmesh_results(get) glance. Interactive explorer of the distances, convergence reading, and every figure (combined overview, KL/EMD vs reference and stepwise, volume fingerprints, component planes). Each figure shows its PNG preview inline (PDFs cannot render in the sandboxed panel) and offers the best-quality vector (PDF/SVG) for download when it has been rendered (barmesh_results(action=render, format=pdf)). Embeds as an MCP App or falls back to a standalone localhost page. barmesh_results(action=get) remains the headless/metrics path.",
|
|
160
|
+
inputSchema: {
|
|
161
|
+
job_id: z.string().describe("Job ID of a completed cfd_mesh_convergence job"),
|
|
162
|
+
},
|
|
163
|
+
_meta: { ui: { resourceUri: RESULTS_EXPLORER_URI } },
|
|
164
|
+
};
|
|
165
|
+
registerAppTool(server, "barmesh_results_explorer", toolConfig, async (args) => runMcpToolAudit("barmesh_results_explorer", "default", args, () => handleResultsExplorer(String(args.job_id))));
|
|
166
|
+
// MUST stay a registered tool: the explorer App view lazy-loads non-default raster
|
|
167
|
+
// figures through the App bridge (mcpApp.callServerTool), which can only reach
|
|
168
|
+
// registered server tools. Agents must not call it directly from chat.
|
|
169
|
+
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.", {
|
|
170
|
+
job_id: z.string(),
|
|
171
|
+
filename: z.string(),
|
|
172
|
+
}, async ({ job_id, filename }) => {
|
|
173
|
+
const safeFilename = filename.replace(/[^a-zA-Z0-9._-]/g, "");
|
|
174
|
+
const mime = mimeForFilename(safeFilename);
|
|
175
|
+
if (safeFilename.endsWith(".pdf") || safeFilename.endsWith(".svg")) {
|
|
176
|
+
return { content: [{ type: "text", text: `${safeFilename} is a vector format and cannot be displayed inline.` }] };
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
const { data: imgBuf } = await apiRawCall(`/v1/results/${job_id}/image/${safeFilename}`);
|
|
180
|
+
return { content: [{ type: "image", data: imgBuf.toString("base64"), mimeType: mime }] };
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return { content: [{ type: "text", text: `Failed to fetch ${safeFilename}.` }] };
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
}
|
package/dist/tools/jobs.js
CHANGED
|
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import { registerAuditedTool } from "../audit.js";
|
|
3
3
|
import { apiCall, textResult } from "../shared.js";
|
|
4
4
|
import { pollCfdFinalizeIfPresent, refreshJobAfterFinalize } from "../cfd_finalize.js";
|
|
5
|
+
import { formatJobStatusText } from "../job_status_format.js";
|
|
5
6
|
export function registerJobsTool(server) {
|
|
6
7
|
registerAuditedTool(server, "barmesh_jobs", `Check job status or list jobs.
|
|
7
8
|
|
|
@@ -20,16 +21,10 @@ ESCALATION: status=failed returns an error message and (when available) a failur
|
|
|
20
21
|
if (status === "completed" && data.finalize_job_id) {
|
|
21
22
|
const { note } = await pollCfdFinalizeIfPresent(job_id, data);
|
|
22
23
|
data = await refreshJobAfterFinalize(job_id);
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
data.label != null ? `Label: ${String(data.label)}` : null,
|
|
26
|
-
data.progress != null ? `Progress: ${String(data.progress)}` : null,
|
|
27
|
-
data.result_ref != null ? `Results: ${String(data.result_ref)}` : null,
|
|
28
|
-
note,
|
|
29
|
-
].filter(Boolean);
|
|
30
|
-
return textResult({ ...data, status_text: lines.join("\n") });
|
|
24
|
+
const statusText = note ? `${formatJobStatusText(job_id, data)}\n${note}` : formatJobStatusText(job_id, data);
|
|
25
|
+
return textResult({ ...data, status_text: statusText });
|
|
31
26
|
}
|
|
32
|
-
return textResult(data);
|
|
27
|
+
return textResult({ ...data, status_text: formatJobStatusText(job_id, data) });
|
|
33
28
|
}
|
|
34
29
|
const data = await apiCall("GET", "/v1/jobs");
|
|
35
30
|
return textResult(data);
|
package/dist/tools/results.js
CHANGED
|
@@ -1,27 +1,52 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { registerAuditedTool } from "../audit.js";
|
|
3
|
-
import { apiCall, apiRawCall, textResult, tryAttachImage } from "../shared.js";
|
|
4
|
-
|
|
3
|
+
import { apiCall, apiRawCall, textResult, tryAttachImage, pollUntilComplete, POLL_STAGE_MAX_MS } from "../shared.js";
|
|
4
|
+
// Headline figures when present, best first: the combined overview (barsom parity),
|
|
5
|
+
// then KL/EMD-vs-reference and the coarse/fine volume pair.
|
|
6
|
+
const MESH_DEFAULT_FIGURES = ["combined", "KL_ref", "EMD_ref", "plot_vol_coarse_fine"];
|
|
5
7
|
export function registerResultsTool(server) {
|
|
6
8
|
registerAuditedTool(server, "barmesh_results", `Fetch results of a completed CFD job: distances, convergence reading, and figures.
|
|
7
9
|
|
|
8
10
|
| Action | Use when |
|
|
9
11
|
|--------|----------|
|
|
10
12
|
| get | Read the summary (distances per mesh, stepwise, convergence reading) and inline key figures. |
|
|
11
|
-
| image | Download one figure by filename (e.g. KL_ref.
|
|
13
|
+
| image | Download one figure by filename (e.g. KL_ref.png, EMD_stepwise.pdf). |
|
|
14
|
+
| render | Re-render the figures as publication PDFs (or SVG) on demand — PDFs are NOT generated by default. |
|
|
12
15
|
|
|
13
16
|
BEST FOR: After barmesh_jobs(action=status) shows completed.
|
|
14
|
-
FIGURES:
|
|
17
|
+
FIGURES: Default job output is metrics + PNG previews, including a single combined.png overview. action=get inlines the combined headline plus KL_ref/EMD_ref/volume PNGs with captions; pass figures="all" for every PNG, "none" for metrics only, or a list of names.
|
|
18
|
+
PDFs ON DEMAND: vector PDFs are not produced by default. Use action=render (format=pdf) once, then action=image to download each <name>.pdf. For an interactive browser use barmesh_results_explorer.
|
|
15
19
|
NOT FOR: Submitting jobs.`, {
|
|
16
|
-
action: z.enum(["get", "image"]).describe("get: summary + figures; image: download one figure file"),
|
|
20
|
+
action: z.enum(["get", "image", "render"]).describe("get: summary + figures; image: download one figure file; render: re-render figures as PDF/SVG on demand"),
|
|
17
21
|
job_id: z.string().describe("Job ID"),
|
|
18
|
-
filename: z.string().optional().describe("For action=image: the figure filename (e.g. KL_ref.pdf)"),
|
|
22
|
+
filename: z.string().optional().describe("For action=image: the figure filename (e.g. KL_ref.png or, after render, KL_ref.pdf)"),
|
|
23
|
+
format: z.enum(["pdf", "png", "svg"]).optional().describe("For action=render: output format to generate (default pdf). PNG previews already exist by default."),
|
|
19
24
|
figures: z
|
|
20
25
|
.union([z.enum(["default", "all", "none"]), z.array(z.string())])
|
|
21
26
|
.optional()
|
|
22
27
|
.describe("For action=get: which figures to inline (default headline panels; 'all'; 'none'; or a list of names)"),
|
|
23
28
|
}, async (args) => {
|
|
24
|
-
const { action, job_id, filename, figures } = args;
|
|
29
|
+
const { action, job_id, filename, format, figures } = args;
|
|
30
|
+
if (action === "render") {
|
|
31
|
+
const fmt = format ?? "pdf";
|
|
32
|
+
const submit = (await apiCall("POST", "/v1/cfd/render", { job_id, format: fmt }));
|
|
33
|
+
const renderId = (submit.id ?? submit.job_id);
|
|
34
|
+
if (!renderId)
|
|
35
|
+
throw new Error("cfd render did not return a job id");
|
|
36
|
+
const poll = await pollUntilComplete(renderId, POLL_STAGE_MAX_MS);
|
|
37
|
+
if (poll.status === "failed") {
|
|
38
|
+
return textResult({ status: "failed", render_job_id: renderId, error: poll.error ?? "render failed" });
|
|
39
|
+
}
|
|
40
|
+
if (poll.status !== "completed") {
|
|
41
|
+
return textResult({ status: poll.status, render_job_id: renderId, note: `Still rendering; poll barmesh_jobs(action=status, job_id="${renderId}").` });
|
|
42
|
+
}
|
|
43
|
+
return textResult({
|
|
44
|
+
status: "completed",
|
|
45
|
+
render_job_id: renderId,
|
|
46
|
+
format: fmt,
|
|
47
|
+
suggested_next_step: `Figures rendered as ${fmt}. Download one with barmesh_results(action=image, job_id="${job_id}", filename="KL_ref.${fmt}"), or list filenames via barmesh_results(action=get, job_id="${job_id}").`,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
25
50
|
if (action === "image") {
|
|
26
51
|
if (!filename)
|
|
27
52
|
throw new Error("barmesh_results(image) requires filename.");
|