@barivia/barmesh-mcp 0.4.0 → 0.5.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/README.md +29 -4
- package/dist/index.js +1 -1
- package/dist/job_status_format.js +89 -0
- package/dist/shared.js +84 -9
- package/dist/tools/barmesh_results_explorer.js +158 -0
- package/dist/tools/datasets.js +191 -28
- package/dist/tools/jobs.js +4 -9
- package/dist/tools/results.js +32 -7
- package/dist/upload_hints.js +1 -0
- 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
|
@@ -14,7 +14,7 @@ form on a shared self-organizing map (SOM)**:
|
|
|
14
14
|
each mesh to a volume-weighted fingerprint, and computes **symmetric KL** and
|
|
15
15
|
**Wasserstein-1 (EMD)** distances stepwise and against a reference mesh, with publication
|
|
16
16
|
figures and an advisory convergence reading. The SOM features are preprocessed by the
|
|
17
|
-
same staged pipeline as barsom training, so any dataset (small or large, CSV/gzip
|
|
17
|
+
same staged pipeline as barsom training, so any dataset (small or large, CSV/gzip)
|
|
18
18
|
is handled out-of-core by default; optional `transforms`, `normalize`,
|
|
19
19
|
`normalization_methods`, and `row_range` give the same preprocessing controls. Submit
|
|
20
20
|
enqueues **`prepare_training_matrix`** on worker-io when the dataset is staged; the proxy
|
|
@@ -52,13 +52,31 @@ API key; otherwise the analysis calls return HTTP 403. Contact Barivia to enable
|
|
|
52
52
|
|------|---------|
|
|
53
53
|
| `barmesh_guide_workflow` | Workflow + tool map (tier-scoped). Call first. |
|
|
54
54
|
| `barmesh_prepare_mesh_data` | Recipe for the combined per-cell CSV. |
|
|
55
|
-
| `barmesh_datasets` | Upload / preview / list the mesh CSV. |
|
|
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.
|
|
@@ -69,6 +87,12 @@ One combined CSV: one row per cell, a mesh-label column (`mesh_id`), the physica
|
|
|
69
87
|
you choose as `feature_columns` (e.g. `p`, `U_mag`, `k`, `log_epsilon`, `T`), and a
|
|
70
88
|
cell-volume column (`V`). Use `barmesh_prepare_mesh_data` for the full recipe.
|
|
71
89
|
|
|
90
|
+
**Upload formats:** `.csv`, `.tsv`, `.csv.gz`, or `.tsv.gz`. For large per-cell tables
|
|
91
|
+
(≥64 MB), prefer `.csv.gz` — uploads stream directly to object storage with presigned PUT.
|
|
92
|
+
Use `barmesh_datasets(action=get, dataset_id=...)` to check staging status after upload;
|
|
93
|
+
`barmesh_datasets(action=subset, sample_n=...)` to downsample huge tables server-side.
|
|
94
|
+
Parquet staging is supported by the API but not yet exposed as an MCP upload format.
|
|
95
|
+
|
|
72
96
|
## Environment variables
|
|
73
97
|
|
|
74
98
|
| Variable | Default | Purpose |
|
|
@@ -78,3 +102,4 @@ cell-volume column (`V`). Use `barmesh_prepare_mesh_data` for the full recipe.
|
|
|
78
102
|
| `BARIVIA_FETCH_TIMEOUT_MS` | `60000` | Per-request timeout (raise for large uploads). |
|
|
79
103
|
| `BARIVIA_WORKSPACE_ROOT` | workspace/cwd | Root for resolving relative `file_path` uploads. |
|
|
80
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
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
* remains a thin HTTPS client to the same Barivia API (no domain logic here).
|
|
5
5
|
*/
|
|
6
6
|
import fs from "node:fs/promises";
|
|
7
|
-
import { createReadStream } from "node:fs";
|
|
7
|
+
import { createReadStream, createWriteStream } from "node:fs";
|
|
8
8
|
import { createGzip } from "node:zlib";
|
|
9
|
-
import { createHash } from "node:crypto";
|
|
9
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
10
10
|
import { Readable } from "node:stream";
|
|
11
|
+
import { pipeline } from "node:stream/promises";
|
|
12
|
+
import os from "node:os";
|
|
11
13
|
import path from "node:path";
|
|
12
14
|
import { fileURLToPath } from "node:url";
|
|
13
15
|
import { logInfo } from "./logger.js";
|
|
@@ -20,7 +22,7 @@ export const FETCH_TIMEOUT_MS = parseInt(process.env.BARIVIA_FETCH_TIMEOUT_MS ??
|
|
|
20
22
|
export const MAX_RETRIES = 2;
|
|
21
23
|
export const RETRYABLE_STATUS = new Set([502, 503, 504]);
|
|
22
24
|
/** Single source of truth for the proxy version. Keep in sync with package.json on bump. */
|
|
23
|
-
export const CLIENT_VERSION = "0.
|
|
25
|
+
export const CLIENT_VERSION = "0.5.0";
|
|
24
26
|
export const PUBLIC_SITE_ORIGIN = "https://barivia.se";
|
|
25
27
|
/** Large per-cell CSV uploads may exceed the default fetch timeout. */
|
|
26
28
|
export const UPLOAD_DATASET_TIMEOUT_MS = 180_000;
|
|
@@ -40,30 +42,83 @@ export async function streamFileSha256(srcPath) {
|
|
|
40
42
|
s.on("error", reject);
|
|
41
43
|
});
|
|
42
44
|
}
|
|
43
|
-
/**
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
45
|
+
/** Turn a raw S3/R2 presigned-PUT error into a clean, actionable message. */
|
|
46
|
+
function presignedPutError(status, bodyText) {
|
|
47
|
+
const snippet = bodyText.slice(0, 200);
|
|
48
|
+
if (status === 411 || /MissingContentLength/i.test(bodyText)) {
|
|
49
|
+
return new Error("Upload rejected by storage: the Content-Length header was missing. " +
|
|
50
|
+
"This is a client bug — please update @barivia/barmesh-mcp to the latest version.");
|
|
51
|
+
}
|
|
52
|
+
if (status === 413 || /EntityTooLarge|entity is too large/i.test(bodyText)) {
|
|
53
|
+
return new Error("Upload rejected by storage: file exceeds the maximum upload size.");
|
|
54
|
+
}
|
|
55
|
+
if (status === 403) {
|
|
56
|
+
return new Error("Upload rejected by storage: the presigned URL expired or was invalid; retry barmesh_datasets(action=upload).");
|
|
57
|
+
}
|
|
58
|
+
return new Error(`Presigned upload failed: HTTP ${status} ${snippet}`);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Stream a local file directly to a presigned PUT URL (e.g. R2), gzip-compressing
|
|
62
|
+
* on the way unless the source is already gzipped. Never materializes the payload
|
|
63
|
+
* in process memory: when compression is needed we gzip to a temp file first, then
|
|
64
|
+
* PUT that file with an explicit Content-Length.
|
|
65
|
+
*/
|
|
66
|
+
export async function putPresignedStream(url, srcPath, contentType, timeoutMs = PRESIGNED_PUT_TIMEOUT_MS, alreadyGzipped = false) {
|
|
67
|
+
let putPath = srcPath;
|
|
68
|
+
let tempPath;
|
|
69
|
+
if (!alreadyGzipped) {
|
|
70
|
+
tempPath = path.join(os.tmpdir(), `barmesh-upload-${randomUUID()}.csv.gz`);
|
|
71
|
+
await pipeline(createReadStream(srcPath), createGzip(), createWriteStream(tempPath));
|
|
72
|
+
putPath = tempPath;
|
|
73
|
+
}
|
|
47
74
|
const controller = new AbortController();
|
|
48
75
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
49
76
|
try {
|
|
77
|
+
const contentLength = (await fs.stat(putPath)).size;
|
|
78
|
+
const webStream = Readable.toWeb(createReadStream(putPath));
|
|
50
79
|
const resp = await fetch(url, {
|
|
51
80
|
method: "PUT",
|
|
52
81
|
body: webStream,
|
|
53
|
-
headers: { "Content-Type": contentType },
|
|
82
|
+
headers: { "Content-Type": contentType, "Content-Length": String(contentLength) },
|
|
54
83
|
duplex: "half",
|
|
55
84
|
signal: controller.signal,
|
|
56
85
|
});
|
|
57
86
|
if (!resp.ok) {
|
|
58
87
|
const t = await resp.text().catch(() => "");
|
|
59
|
-
throw
|
|
88
|
+
throw presignedPutError(resp.status, t);
|
|
60
89
|
}
|
|
61
90
|
}
|
|
62
91
|
finally {
|
|
63
92
|
clearTimeout(timer);
|
|
93
|
+
if (tempPath)
|
|
94
|
+
await fs.rm(tempPath, { force: true }).catch(() => { });
|
|
64
95
|
}
|
|
65
96
|
}
|
|
66
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
|
+
// ---------------------------------------------------------------------------
|
|
67
122
|
// Fetch helpers
|
|
68
123
|
// ---------------------------------------------------------------------------
|
|
69
124
|
export function isTransientError(err, status) {
|
|
@@ -339,6 +394,26 @@ export function getCaptionForImage(filename) {
|
|
|
339
394
|
return `Component plane for ${base.replace(/^plot_/, "")} on the trained SOM.`;
|
|
340
395
|
return "";
|
|
341
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
|
+
}
|
|
342
417
|
export async function tryAttachImage(content, jobId, filename) {
|
|
343
418
|
if (filename.endsWith(".pdf") || filename.endsWith(".svg")) {
|
|
344
419
|
content.push({
|
|
@@ -0,0 +1,158 @@
|
|
|
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
|
+
export function buildPayload(jobId, data) {
|
|
66
|
+
const summary = (data.summary ?? {});
|
|
67
|
+
const files = (summary.files ?? []).filter((f) => /\.(png|pdf|svg)$/i.test(f));
|
|
68
|
+
// Embedded view: never set `url` (sandboxed iframe cannot load localhost) — figures
|
|
69
|
+
// lazy-load as base64 through the _barmesh_fetch_figure bridge tool.
|
|
70
|
+
const figures = files.map((filename) => {
|
|
71
|
+
const base = filename.replace(/\.(png|pdf|svg)$/i, "");
|
|
72
|
+
const isRaster = /\.(png|jpe?g|gif|webp)$/i.test(filename);
|
|
73
|
+
return {
|
|
74
|
+
key: base,
|
|
75
|
+
label: labelForFigure(filename),
|
|
76
|
+
filename,
|
|
77
|
+
kind: figureKind(base),
|
|
78
|
+
caption: getCaptionForImage(filename) ||
|
|
79
|
+
(!isRaster ? "Vector figure — available for download, not inline-displayable." : undefined),
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
const port = getVizPort();
|
|
83
|
+
return {
|
|
84
|
+
type: "barmesh-results-explorer",
|
|
85
|
+
jobId,
|
|
86
|
+
title: "Mesh Convergence Results",
|
|
87
|
+
subtitle: String(data.label ?? ""),
|
|
88
|
+
metrics: buildMetrics(summary),
|
|
89
|
+
highlights: buildHighlights(summary),
|
|
90
|
+
availableFigures: figures,
|
|
91
|
+
defaultFigureKey: figures.find((f) => f.key === "combined")?.key ??
|
|
92
|
+
figures.find((f) => f.key === "KL_ref")?.key ??
|
|
93
|
+
figures[0]?.key,
|
|
94
|
+
standaloneUrl: port
|
|
95
|
+
? `http://localhost:${port}/viz/barmesh-results-explorer?mode=standalone&job_id=${jobId}`
|
|
96
|
+
: undefined,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
async function handleResultsExplorer(job_id) {
|
|
100
|
+
const data = (await apiCall("GET", `/v1/results/${job_id}`));
|
|
101
|
+
const payload = buildPayload(job_id, data);
|
|
102
|
+
const content = [
|
|
103
|
+
{
|
|
104
|
+
type: "text",
|
|
105
|
+
text: [
|
|
106
|
+
`Mesh convergence results explorer ready for job ${job_id}.`,
|
|
107
|
+
payload.subtitle ? `Label: ${payload.subtitle}` : "",
|
|
108
|
+
payload.highlights[0] ?? "",
|
|
109
|
+
]
|
|
110
|
+
.filter(Boolean)
|
|
111
|
+
.join("\n"),
|
|
112
|
+
},
|
|
113
|
+
];
|
|
114
|
+
const defaultFigure = payload.availableFigures.find((f) => f.key === payload.defaultFigureKey);
|
|
115
|
+
if (defaultFigure) {
|
|
116
|
+
await tryAttachImage(content, job_id, defaultFigure.filename);
|
|
117
|
+
}
|
|
118
|
+
if (!getClientSupportsMcpApps() && payload.standaloneUrl) {
|
|
119
|
+
content.push({
|
|
120
|
+
type: "text",
|
|
121
|
+
text: `Interactive results explorer: [Open results explorer](${payload.standaloneUrl})\n` +
|
|
122
|
+
`AGENT: surface this to the user as a clickable markdown link in your reply. ` +
|
|
123
|
+
`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.`,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return structuredTextResult(payload, undefined, content);
|
|
127
|
+
}
|
|
128
|
+
export function registerResultsExplorerTool(server) {
|
|
129
|
+
const toolConfig = {
|
|
130
|
+
title: "Mesh Convergence Results Explorer",
|
|
131
|
+
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). Embeds as an MCP App or falls back to a standalone localhost page. barmesh_results(action=get) remains the headless/metrics path.",
|
|
132
|
+
inputSchema: {
|
|
133
|
+
job_id: z.string().describe("Job ID of a completed cfd_mesh_convergence job"),
|
|
134
|
+
},
|
|
135
|
+
_meta: { ui: { resourceUri: RESULTS_EXPLORER_URI } },
|
|
136
|
+
};
|
|
137
|
+
registerAppTool(server, "barmesh_results_explorer", toolConfig, async (args) => runMcpToolAudit("barmesh_results_explorer", "default", args, () => handleResultsExplorer(String(args.job_id))));
|
|
138
|
+
// MUST stay a registered tool: the explorer App view lazy-loads non-default raster
|
|
139
|
+
// figures through the App bridge (mcpApp.callServerTool), which can only reach
|
|
140
|
+
// registered server tools. Agents must not call it directly from chat.
|
|
141
|
+
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.", {
|
|
142
|
+
job_id: z.string(),
|
|
143
|
+
filename: z.string(),
|
|
144
|
+
}, async ({ job_id, filename }) => {
|
|
145
|
+
const safeFilename = filename.replace(/[^a-zA-Z0-9._-]/g, "");
|
|
146
|
+
const mime = mimeForFilename(safeFilename);
|
|
147
|
+
if (safeFilename.endsWith(".pdf") || safeFilename.endsWith(".svg")) {
|
|
148
|
+
return { content: [{ type: "text", text: `${safeFilename} is a vector format and cannot be displayed inline.` }] };
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
const { data: imgBuf } = await apiRawCall(`/v1/results/${job_id}/image/${safeFilename}`);
|
|
152
|
+
return { content: [{ type: "image", data: imgBuf.toString("base64"), mimeType: mime }] };
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return { content: [{ type: "text", text: `Failed to fetch ${safeFilename}.` }] };
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
}
|