@barivia/barmesh-mcp 0.1.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/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ Copyright (c) 2026 Barivia AB. All rights reserved.
2
+
3
+ This software and associated documentation files (the "Software") are
4
+ proprietary to Barivia AB. The Software is licensed, not sold.
5
+
6
+ Without prior written permission from Barivia AB, you may not copy, modify,
7
+ merge, publish, distribute, sublicense, sell, or create derivative works
8
+ from the Software, except to the extent necessary to install and execute it
9
+ for your own use in connection with Barivia services under a separate
10
+ agreement with Barivia AB.
11
+
12
+ For licensing inquiries, contact Barivia AB.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ IMPLIED.
package/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # @barivia/barmesh-mcp
2
+
3
+ MCP proxy for **SOM-based CFD mesh-convergence** and **Richardson/GCI** analysis on the
4
+ Barivia cloud API. It connects any MCP client (Cursor, Claude Desktop, etc.) to the same
5
+ Barivia backend as `@barivia/barsom-mcp`, using the same API key and licensing.
6
+
7
+ ## What it does
8
+
9
+ Given a mesh-refinement study (several meshes of the same CFD case at increasing
10
+ resolution), barmesh compares the meshes by the **volume-weighted distribution their cells
11
+ form on a shared self-organizing map (SOM)**:
12
+
13
+ - **`barmesh_mesh_convergence`** — trains one SOM on all meshes (joint-normalized), projects
14
+ each mesh to a volume-weighted fingerprint, and computes **symmetric KL** and
15
+ **Wasserstein-1 (EMD)** distances stepwise and against a reference mesh, with publication
16
+ figures and an advisory convergence reading.
17
+ - **`barmesh_richardson`** — classical three-level Richardson extrapolation / Grid
18
+ Convergence Index (GCI) on scalar quantities of interest.
19
+
20
+ These complement, and do not replace, conventional numerical uncertainty analysis.
21
+
22
+ ## Install / configure
23
+
24
+ ```json
25
+ {
26
+ "mcpServers": {
27
+ "barmesh": {
28
+ "command": "npx",
29
+ "args": ["-y", "@barivia/barmesh-mcp"],
30
+ "env": {
31
+ "BARIVIA_API_KEY": "bv_live_xxxx",
32
+ "BARIVIA_API_URL": "https://api.barivia.se"
33
+ }
34
+ }
35
+ }
36
+ }
37
+ ```
38
+
39
+ Access to the analysis tools requires the **`cfd`** entitlement on your API key; otherwise
40
+ the analysis calls return HTTP 403. Contact Barivia to enable it.
41
+
42
+ ## Tools
43
+
44
+ | Tool | Purpose |
45
+ |------|---------|
46
+ | `barmesh_guide_workflow` | Workflow + tool map (tier-scoped). Call first. |
47
+ | `barmesh_prepare_mesh_data` | Recipe for the combined per-cell CSV. |
48
+ | `barmesh_datasets` | Upload / preview / list the mesh CSV. |
49
+ | `barmesh_mesh_convergence` | SOM fingerprint distances (async job). |
50
+ | `barmesh_richardson` | Richardson/GCI on scalar QoIs (async job). |
51
+ | `barmesh_jobs` | Poll job status / list jobs. |
52
+ | `barmesh_results` | Distances, convergence reading, and figures. |
53
+
54
+ ## Data format (mesh_convergence)
55
+
56
+ One combined CSV: one row per cell, a mesh-label column (`mesh_id`), the physical channels
57
+ you choose as `feature_columns` (e.g. `p`, `U_mag`, `k`, `log_epsilon`, `T`), and a
58
+ cell-volume column (`V`). Use `barmesh_prepare_mesh_data` for the full recipe.
59
+
60
+ ## Environment variables
61
+
62
+ | Variable | Default | Purpose |
63
+ |----------|---------|---------|
64
+ | `BARIVIA_API_KEY` | (required) | Your Barivia API key. |
65
+ | `BARIVIA_API_URL` | `https://api.barivia.se` | API base URL. |
66
+ | `BARIVIA_FETCH_TIMEOUT_MS` | `30000` | Per-request timeout (raise for large uploads). |
67
+ | `BARIVIA_WORKSPACE_ROOT` | workspace/cwd | Root for resolving relative `file_path` uploads. |
68
+ | `BARIVIA_ENFORCE_WORKSPACE_SANDBOX` | `1` | Restrict uploads to the workspace; set `0` to allow absolute paths. |
package/dist/audit.js ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * MCP tool audit wrapper — tool name, action, latency, outcome; no secrets.
3
+ */
4
+ import { logAudit } from "./logger.js";
5
+ const AUDIT_PARAM_KEYS = new Set([
6
+ "action",
7
+ "preset",
8
+ "backend",
9
+ "emd_method",
10
+ "reference_mesh",
11
+ "job_id",
12
+ "dataset_id",
13
+ "figures",
14
+ ]);
15
+ function scrubParams(args) {
16
+ const out = {};
17
+ for (const [k, v] of Object.entries(args)) {
18
+ if (!AUDIT_PARAM_KEYS.has(k))
19
+ continue;
20
+ if (v === undefined || v === null)
21
+ continue;
22
+ if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
23
+ out[k] = v;
24
+ }
25
+ }
26
+ return out;
27
+ }
28
+ function extractIds(result) {
29
+ const ids = {};
30
+ if (result === null || typeof result !== "object")
31
+ return ids;
32
+ try {
33
+ const text = JSON.stringify(result);
34
+ const jobMatch = text.match(/"(?:job_id|id)"\s*:\s*"([a-f0-9-]{36})"/i);
35
+ if (jobMatch)
36
+ ids.job_id = jobMatch[1];
37
+ const dsMatch = text.match(/"dataset_id"\s*:\s*"([a-f0-9-]{36})"/i);
38
+ if (dsMatch)
39
+ ids.dataset_id = dsMatch[1];
40
+ }
41
+ catch {
42
+ /* ignore */
43
+ }
44
+ return ids;
45
+ }
46
+ function errorCodeFrom(err) {
47
+ if (err && typeof err === "object") {
48
+ const e = err;
49
+ if (e.httpStatus !== undefined)
50
+ return `http_${e.httpStatus}`;
51
+ const m = e.message ?? "";
52
+ const codeMatch = m.match(/error_code:\s*(\w+)/);
53
+ if (codeMatch)
54
+ return codeMatch[1];
55
+ }
56
+ return undefined;
57
+ }
58
+ export async function runMcpToolAudit(tool, action, args, handler) {
59
+ const t0 = Date.now();
60
+ const params = scrubParams(args);
61
+ try {
62
+ const result = await handler();
63
+ const ids = extractIds(result);
64
+ logAudit({
65
+ tool,
66
+ action,
67
+ duration_ms: Date.now() - t0,
68
+ outcome: "ok",
69
+ ...ids,
70
+ ...(Object.keys(params).length > 0 ? { params } : {}),
71
+ });
72
+ return result;
73
+ }
74
+ catch (err) {
75
+ logAudit({
76
+ tool,
77
+ action,
78
+ duration_ms: Date.now() - t0,
79
+ outcome: "error",
80
+ error_code: errorCodeFrom(err),
81
+ ...(Object.keys(params).length > 0 ? { params } : {}),
82
+ });
83
+ throw err;
84
+ }
85
+ }
86
+ export function registerAuditedTool(server, name, description, schema, handler) {
87
+ server.tool(name, description, schema, (async (args) => {
88
+ const rec = args;
89
+ const action = typeof rec.action === "string" && rec.action.length > 0 ? rec.action : "default";
90
+ return runMcpToolAudit(name, action, rec, () => handler(args));
91
+ }));
92
+ }
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import{McpServer as e}from"@modelcontextprotocol/sdk/server/mcp.js";import{StdioServerTransport as s}from"@modelcontextprotocol/sdk/server/stdio.js";import{API_KEY as n,CLIENT_VERSION as r}from"./shared.js";import{registerGuideTool as o}from"./tools/guide.js";import{registerDatasetsTool as t}from"./tools/datasets.js";import{registerCfdTools as a}from"./tools/cfd.js";import{registerJobsTool as i}from"./tools/jobs.js";import{registerResultsTool as c}from"./tools/results.js";n||(console.error("Error: BARIVIA_API_KEY not set. Set it in your MCP client config."),process.exit(1));(async function(){const n=new e({name:"barmesh",version:r},{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.\n\nThese tools are gated by the 'cfd' entitlement; analysis calls return 403 if your plan\ndoes not include it."});o(n),t(n),a(n),i(n),c(n);const m=new s;await n.connect(m),console.error(`barmesh-mcp ${r} ready (API: ${process.env.BARIVIA_API_URL??"https://api.barivia.se"})`)})().catch(e=>{console.error("Fatal error starting barmesh-mcp:",e),process.exit(1)});
package/dist/logger.js ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Structured stderr logging for the barmesh MCP proxy (Docker json-file friendly).
3
+ */
4
+ const SERVICE_NAME = process.env.BARIVIA_SERVICE_NAME ?? "barmesh-mcp";
5
+ const LOG_FORMAT = (process.env.BARIVIA_LOG_FORMAT ?? "text").toLowerCase();
6
+ function emit(fields) {
7
+ const level = fields.level ?? "info";
8
+ const payload = {
9
+ ts: new Date().toISOString(),
10
+ level,
11
+ service: SERVICE_NAME,
12
+ ...fields,
13
+ };
14
+ delete payload.level;
15
+ if (LOG_FORMAT === "json") {
16
+ console.error(JSON.stringify(payload));
17
+ return;
18
+ }
19
+ const parts = [`${payload.ts} ${level.toUpperCase().padEnd(5)} ${fields.msg}`];
20
+ for (const [k, v] of Object.entries(payload)) {
21
+ if (k === "ts" || k === "msg" || k === "service")
22
+ continue;
23
+ if (v !== undefined && v !== null && v !== "") {
24
+ parts.push(`${k}=${String(v)}`);
25
+ }
26
+ }
27
+ console.error(parts.join(" | "));
28
+ }
29
+ export function logInfo(msg, fields = {}) {
30
+ emit({ ...fields, level: "info", msg });
31
+ }
32
+ export function logAudit(fields) {
33
+ emit({ ...fields, event: "mcp_tool_call", level: "info", msg: "mcp_tool_call" });
34
+ }
package/dist/shared.js ADDED
@@ -0,0 +1,317 @@
1
+ /**
2
+ * Shared config, fetch helpers, sandbox path resolution, API client, and image
3
+ * helpers for the barmesh MCP proxy. Adapted from @barivia/barsom-mcp; the proxy
4
+ * remains a thin HTTPS client to the same Barivia API (no domain logic here).
5
+ */
6
+ import fs from "node:fs/promises";
7
+ import path from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { logInfo } from "./logger.js";
10
+ // ---------------------------------------------------------------------------
11
+ // Config
12
+ // ---------------------------------------------------------------------------
13
+ export const API_URL = process.env.BARIVIA_API_URL ?? process.env.BARSOM_API_URL ?? "https://api.barivia.se";
14
+ export const API_KEY = process.env.BARIVIA_API_KEY ?? process.env.BARSOM_API_KEY ?? "";
15
+ export const FETCH_TIMEOUT_MS = parseInt(process.env.BARIVIA_FETCH_TIMEOUT_MS ?? "30000", 10);
16
+ export const MAX_RETRIES = 2;
17
+ export const RETRYABLE_STATUS = new Set([502, 503, 504]);
18
+ /** Single source of truth for the proxy version. Keep in sync with package.json on bump. */
19
+ export const CLIENT_VERSION = "0.1.0";
20
+ export const PUBLIC_SITE_ORIGIN = "https://barivia.se";
21
+ /** Large per-cell CSV uploads may exceed the default fetch timeout. */
22
+ export const UPLOAD_DATASET_TIMEOUT_MS = 180_000;
23
+ // ---------------------------------------------------------------------------
24
+ // Fetch helpers
25
+ // ---------------------------------------------------------------------------
26
+ export function isTransientError(err, status) {
27
+ if (status !== undefined && RETRYABLE_STATUS.has(status))
28
+ return true;
29
+ if (err instanceof DOMException && err.name === "AbortError")
30
+ return true;
31
+ if (err instanceof TypeError)
32
+ return true;
33
+ return false;
34
+ }
35
+ export async function fetchWithTimeout(url, init, timeoutMs = FETCH_TIMEOUT_MS) {
36
+ const controller = new AbortController();
37
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
38
+ try {
39
+ return await fetch(url, { ...init, signal: controller.signal });
40
+ }
41
+ finally {
42
+ clearTimeout(timer);
43
+ }
44
+ }
45
+ // ---------------------------------------------------------------------------
46
+ // Path / sandbox helpers
47
+ // ---------------------------------------------------------------------------
48
+ export function getSandboxRoot() {
49
+ const raw = process.env.BARIVIA_WORKSPACE_ROOT ?? process.env.BARSOM_WORKSPACE_ROOT;
50
+ if (raw)
51
+ return path.resolve(process.cwd(), raw);
52
+ const workspaceFolder = process.env.CURSOR_WORKSPACE_FOLDER ?? process.env.WORKSPACE_FOLDER;
53
+ if (workspaceFolder)
54
+ return path.resolve(workspaceFolder);
55
+ const pwd = process.env.PWD;
56
+ if (pwd && path.isAbsolute(pwd))
57
+ return pwd;
58
+ return process.cwd();
59
+ }
60
+ export async function getWorkspaceRootAsync(mcpServer) {
61
+ try {
62
+ const result = await mcpServer.server.listRoots();
63
+ const roots = result?.roots ?? [];
64
+ const fileRoot = roots.find((r) => r.uri.startsWith("file://"));
65
+ if (fileRoot)
66
+ return fileURLToPath(fileRoot.uri);
67
+ }
68
+ catch {
69
+ /* client may not support roots/list */
70
+ }
71
+ return getSandboxRoot();
72
+ }
73
+ function enforceWorkspaceSandboxUpload() {
74
+ const v = process.env.BARIVIA_ENFORCE_WORKSPACE_SANDBOX ?? "1";
75
+ if (v === "0" || v.toLowerCase() === "false")
76
+ return false;
77
+ return true;
78
+ }
79
+ /**
80
+ * Resolve file_path for dataset upload. Rejects "..", enforces workspace sandbox
81
+ * by default (set BARIVIA_ENFORCE_WORKSPACE_SANDBOX=0 to allow absolute paths).
82
+ */
83
+ export async function resolveFilePathForUpload(filePath, mcpServer) {
84
+ const trimmed = filePath.trim();
85
+ if (trimmed.includes(".."))
86
+ throw new Error("Path traversal ('..') is not allowed.");
87
+ const checkUnder = async (p) => {
88
+ const root = await getWorkspaceRootAsync(mcpServer);
89
+ const real = await fs.realpath(p);
90
+ const realRoot = await fs.realpath(root);
91
+ if (real !== realRoot && !real.startsWith(realRoot + path.sep)) {
92
+ throw new Error(`Path outside the workspace is disabled (BARIVIA_ENFORCE_WORKSPACE_SANDBOX). Workspace root: ${realRoot}`);
93
+ }
94
+ const stat = await fs.stat(real);
95
+ if (!stat.isFile())
96
+ throw new Error("Path must be a regular file, not a directory.");
97
+ return real;
98
+ };
99
+ if (trimmed.startsWith("file://")) {
100
+ const url = new URL(trimmed);
101
+ if (url.protocol !== "file:" || (url.hostname && url.hostname !== "localhost")) {
102
+ throw new Error("Only local file:// URIs are allowed (no remote hosts).");
103
+ }
104
+ const p = fileURLToPath(trimmed);
105
+ return enforceWorkspaceSandboxUpload() ? checkUnder(p) : p;
106
+ }
107
+ if (path.isAbsolute(trimmed)) {
108
+ return enforceWorkspaceSandboxUpload() ? checkUnder(trimmed) : trimmed;
109
+ }
110
+ const root = await getWorkspaceRootAsync(mcpServer);
111
+ const resolved = path.resolve(root, trimmed);
112
+ return checkUnder(resolved);
113
+ }
114
+ export function formatApiErrorMessage(status, bodyText, requestId) {
115
+ let parsed = null;
116
+ try {
117
+ const j = JSON.parse(bodyText);
118
+ if (j && typeof j === "object")
119
+ parsed = j;
120
+ }
121
+ catch {
122
+ /* ignore */
123
+ }
124
+ const detail = (parsed?.error != null && String(parsed.error)) ||
125
+ (bodyText.trim() ? bodyText.trim() : `HTTP ${status}`);
126
+ const code = parsed?.error_code != null ? ` (error_code: ${parsed.error_code})` : "";
127
+ const accountHint = ` Regenerate or verify your key via ${PUBLIC_SITE_ORIGIN} if needed.`;
128
+ const hint = status === 400
129
+ ? " Check parameter types and required fields."
130
+ : status === 401
131
+ ? ` Check BARIVIA_API_KEY in your MCP config.${accountHint}`
132
+ : status === 403
133
+ ? ` Access denied (your plan may not include the CFD mesh-convergence tools).${accountHint}`
134
+ : status === 404
135
+ ? " The resource may not exist or may have been deleted."
136
+ : status === 409
137
+ ? " The job may not be in the expected state."
138
+ : status === 429
139
+ ? " Plan limit or rate limit — read the error above; delete unused datasets or wait and retry."
140
+ : status === 502
141
+ ? " Object storage error from API — retry later."
142
+ : status === 503
143
+ ? " API or database temporarily unavailable — retry later."
144
+ : status >= 500
145
+ ? " Server error — retry later."
146
+ : "";
147
+ const rid = ` (request id: ${requestId} — include if contacting support)`;
148
+ return `${detail}${code}${hint}${rid}`;
149
+ }
150
+ function throwApiError(status, bodyText, requestId) {
151
+ const err = new Error(formatApiErrorMessage(status, bodyText, requestId));
152
+ err.httpStatus = status;
153
+ throw err;
154
+ }
155
+ export async function apiCall(method, pathPart, body, extraHeaders, requestTimeoutMs) {
156
+ const url = `${API_URL}${pathPart}`;
157
+ const contentType = extraHeaders?.["Content-Type"] ?? "application/json";
158
+ const requestId = Math.random().toString(36).slice(2, 10);
159
+ const headers = {
160
+ Authorization: `Bearer ${API_KEY}`,
161
+ "Content-Type": contentType,
162
+ "X-Request-ID": requestId,
163
+ "X-Barmesh-Client-Version": CLIENT_VERSION,
164
+ ...extraHeaders,
165
+ };
166
+ let serializedBody;
167
+ if (body !== undefined) {
168
+ if (body instanceof Uint8Array) {
169
+ serializedBody = body;
170
+ }
171
+ else {
172
+ serializedBody = contentType === "application/json" ? JSON.stringify(body) : String(body);
173
+ }
174
+ }
175
+ const effectiveTimeout = requestTimeoutMs ?? FETCH_TIMEOUT_MS;
176
+ const t0 = Date.now();
177
+ logInfo("API request", { rid: requestId, action: method, path: pathPart });
178
+ let lastError;
179
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
180
+ try {
181
+ const resp = await fetchWithTimeout(url, { method, headers, body: serializedBody }, effectiveTimeout);
182
+ const text = await resp.text();
183
+ if (!resp.ok) {
184
+ if (attempt < MAX_RETRIES && isTransientError(null, resp.status)) {
185
+ await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
186
+ continue;
187
+ }
188
+ logInfo("API response", { rid: requestId, outcome: "error", duration_ms: Date.now() - t0, error_code: `http_${resp.status}` });
189
+ throwApiError(resp.status, text, requestId);
190
+ }
191
+ logInfo("API response", { rid: requestId, outcome: "ok", duration_ms: Date.now() - t0 });
192
+ return JSON.parse(text);
193
+ }
194
+ catch (err) {
195
+ lastError = err;
196
+ if (attempt < MAX_RETRIES && isTransientError(err)) {
197
+ await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
198
+ continue;
199
+ }
200
+ if (err instanceof DOMException && err.name === "AbortError" && !err.httpStatus) {
201
+ throw new Error(`Request timed out after ${effectiveTimeout}ms. Increase BARIVIA_FETCH_TIMEOUT_MS (e.g. 120000) for slow or large requests. (request id: ${requestId})`);
202
+ }
203
+ throw err;
204
+ }
205
+ }
206
+ throw lastError;
207
+ }
208
+ /** Fetch raw bytes from the API (for image downloads). */
209
+ export async function apiRawCall(pathPart, requestTimeoutMs) {
210
+ const url = `${API_URL}${pathPart}`;
211
+ const requestId = Math.random().toString(36).slice(2, 10);
212
+ const effectiveTimeout = requestTimeoutMs ?? FETCH_TIMEOUT_MS;
213
+ let lastError;
214
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
215
+ try {
216
+ const resp = await fetchWithTimeout(url, { method: "GET", headers: { Authorization: `Bearer ${API_KEY}`, "X-Request-ID": requestId } }, effectiveTimeout);
217
+ if (!resp.ok) {
218
+ if (attempt < MAX_RETRIES && isTransientError(null, resp.status)) {
219
+ await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
220
+ continue;
221
+ }
222
+ const text = await resp.text();
223
+ throwApiError(resp.status, text, requestId);
224
+ }
225
+ const arrayBuf = await resp.arrayBuffer();
226
+ return {
227
+ data: Buffer.from(arrayBuf),
228
+ contentType: resp.headers.get("content-type") ?? "application/octet-stream",
229
+ };
230
+ }
231
+ catch (err) {
232
+ lastError = err;
233
+ if (attempt < MAX_RETRIES && isTransientError(err)) {
234
+ await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
235
+ continue;
236
+ }
237
+ throw err;
238
+ }
239
+ }
240
+ throw lastError;
241
+ }
242
+ // ---------------------------------------------------------------------------
243
+ // Result helpers
244
+ // ---------------------------------------------------------------------------
245
+ export function textResult(data) {
246
+ const text = typeof data === "string" ? data : JSON.stringify(data, null, 2);
247
+ return { content: [{ type: "text", text }] };
248
+ }
249
+ export async function pollUntilComplete(jobId, maxWaitMs = 30_000, intervalMs = 2000) {
250
+ const start = Date.now();
251
+ while (Date.now() - start < maxWaitMs) {
252
+ const data = (await apiCall("GET", `/v1/jobs/${jobId}`));
253
+ const status = data.status;
254
+ if (status === "completed" || status === "failed" || status === "cancelled") {
255
+ return {
256
+ status,
257
+ result_ref: data.result_ref,
258
+ error: data.error,
259
+ };
260
+ }
261
+ await new Promise((r) => setTimeout(r, intervalMs));
262
+ }
263
+ return { status: "timeout" };
264
+ }
265
+ // ---------------------------------------------------------------------------
266
+ // Image helpers
267
+ // ---------------------------------------------------------------------------
268
+ export function mimeForFilename(fname) {
269
+ if (fname.endsWith(".pdf"))
270
+ return "application/pdf";
271
+ if (fname.endsWith(".svg"))
272
+ return "image/svg+xml";
273
+ return "image/png";
274
+ }
275
+ /** Per-image caption for multimodal LLM context (what to look for). */
276
+ export function getCaptionForImage(filename) {
277
+ const base = filename.replace(/\.(png|pdf|svg)$/i, "");
278
+ if (base === "KL_ref")
279
+ return "KL divergence to the reference mesh: steel blue is from the reference, coral is to the reference. Lower and converging toward the finest mesh indicates sufficiency.";
280
+ if (base === "KL_stepwise")
281
+ return "Stepwise KL between consecutive meshes (coarsening blue, refinement red on the right axis). Shrinking on the finest steps indicates a plateau.";
282
+ if (base === "EMD_ref")
283
+ return "Wasserstein-1 (EMD) distance from each mesh to the reference. A monotone decrease toward the finest mesh indicates field-level convergence.";
284
+ if (base === "EMD_stepwise")
285
+ return "Stepwise EMD between consecutive meshes. Small, plateauing values on the finest pairs indicate the fingerprint has stopped changing.";
286
+ if (base === "plot_vol_coarse_fine")
287
+ return "Volume-weighted SOM fingerprints P_vol for the coarsest and reference meshes (shared color scale).";
288
+ if (base.startsWith("plot_vol_steps") || base.startsWith("plot_vol_seven_steps"))
289
+ return "Stepwise change in the volume fingerprint between consecutive meshes; diverging-then-shrinking patterns trace where refinement still moves mass.";
290
+ if (base.startsWith("plot_"))
291
+ return `Component plane for ${base.replace(/^plot_/, "")} on the trained SOM.`;
292
+ return "";
293
+ }
294
+ export async function tryAttachImage(content, jobId, filename) {
295
+ if (filename.endsWith(".pdf") || filename.endsWith(".svg")) {
296
+ content.push({
297
+ type: "text",
298
+ text: `${filename} is ready (vector PDF — not inlineable). Use barmesh_results(action=image, job_id="${jobId}", filename="${filename}") to download it.`,
299
+ });
300
+ return;
301
+ }
302
+ const caption = getCaptionForImage(filename);
303
+ if (caption)
304
+ content.push({ type: "text", text: `${filename}: ${caption}` });
305
+ try {
306
+ const { data: imgBuf } = await apiRawCall(`/v1/results/${jobId}/image/${filename}`);
307
+ content.push({
308
+ type: "image",
309
+ data: imgBuf.toString("base64"),
310
+ mimeType: mimeForFilename(filename),
311
+ annotations: { audience: ["user"], priority: 0.8 },
312
+ });
313
+ }
314
+ catch {
315
+ content.push({ type: "text", text: `(${filename} not available for inline display)` });
316
+ }
317
+ }
@@ -0,0 +1,82 @@
1
+ import { z } from "zod";
2
+ import { registerAuditedTool } from "../audit.js";
3
+ import { apiCall, textResult } from "../shared.js";
4
+ export function registerCfdTools(server) {
5
+ registerAuditedTool(server, "barmesh_mesh_convergence", `Run SOM-based mesh-convergence analysis on an uploaded combined per-cell CSV.
6
+
7
+ What it does (one call): joint-normalizes all meshes, trains one SOM on a stratified pooled subsample, projects each mesh to a volume-weighted fingerprint, and computes symmetric KL and Wasserstein-1 (EMD) distances stepwise and against a reference mesh, plus publication figures and an advisory convergence reading.
8
+
9
+ BEST FOR: A mesh-refinement study where you want a field-level (not just scalar) view of whether the mesh has converged.
10
+ NOT FOR: Single scalar quantities of interest — use barmesh_richardson for classical GCI.
11
+ ASYNC: This is a queued job. It returns a job id immediately. Then poll barmesh_jobs(action=status) every 10-20s; datacenter-scale grids plus EMD can take a few minutes — keep polling. On completion call barmesh_results(action=get).
12
+ COMMON MISTAKES: omitting feature_columns (required); choosing a reference_mesh label that is not present; forgetting the cell-volume column at upload time.`, {
13
+ action: z.enum(["submit"]).optional().describe("submit: enqueue the analysis (default)"),
14
+ dataset_id: z.string().describe("Dataset ID from barmesh_datasets(upload)"),
15
+ feature_columns: z.array(z.string()).describe("Per-cell numeric feature columns to train the SOM on (e.g. [\"p\",\"U_mag\",\"k\",\"log_epsilon\"])"),
16
+ preset: z.enum(["generic", "cavity", "pitz", "datacenter"]).optional().describe("Hyperparameter preset; defaults to generic. cavity/pitz/datacenter reproduce the published settings."),
17
+ mesh_column: z.string().optional().describe("Column with the mesh label (default mesh_id)"),
18
+ volume_column: z.string().optional().describe("Cell-volume column for fingerprint weights (default V; equal weights if absent)"),
19
+ reference_mesh: z.string().optional().describe("Mesh label used as reference (default: the mesh with the most cells = finest)"),
20
+ mesh_order: z.array(z.string()).optional().describe("Explicit coarse-to-fine mesh order (default: ascending cell count)"),
21
+ grid: z.array(z.number().int()).optional().describe("SOM grid [rows, cols] (overrides preset)"),
22
+ epochs: z.array(z.number().int()).optional().describe("[ordering, convergence] epochs (overrides preset)"),
23
+ batch_size: z.number().int().optional().describe("SOM batch size (overrides preset)"),
24
+ sigma_f: z.number().optional().describe("Final ordering neighborhood width (overrides preset)"),
25
+ backend: z.enum(["auto", "cpu", "gpu", "gpu_graphs"]).optional().describe("Compute backend (default auto / preset)"),
26
+ stratify_scale: z.number().optional().describe("[0,1] per-mesh training-row cap; 1 uses all cells (default 1)"),
27
+ emd_method: z.enum(["exact", "sinkhorn"]).optional().describe("EMD solver: exact LP (default) or sinkhorn (fast approximation for large grids)"),
28
+ component_planes_physical: z.boolean().optional().describe("Physical-scale component-plane colorbars (default true)"),
29
+ figures: z.boolean().optional().describe("Generate publication figures (default true)"),
30
+ label: z.string().optional().describe("Optional job label"),
31
+ }, async (args) => {
32
+ const { action, dataset_id, label, ...rest } = args;
33
+ void action;
34
+ const params = {};
35
+ for (const [k, v] of Object.entries(rest)) {
36
+ if (v !== undefined && v !== null)
37
+ params[k] = v;
38
+ }
39
+ const body = { dataset_id, params };
40
+ if (typeof label === "string" && label.length > 0)
41
+ body.label = label;
42
+ const data = (await apiCall("POST", "/v1/cfd/mesh-convergence", body));
43
+ const id = data.id;
44
+ if (id != null)
45
+ data.suggested_next_step = `Poll barmesh_jobs(action=status, job_id=${id}); on completion call barmesh_results(action=get, job_id=${id}).`;
46
+ return textResult(data);
47
+ });
48
+ registerAuditedTool(server, "barmesh_richardson", `Run classical Richardson extrapolation / Grid Convergence Index (GCI) on scalar quantities of interest.
49
+
50
+ What it does: from a small CSV with one row per mesh (a mesh label, a representative cell size h or a cell count, and one or more QoI columns), slides the three-level Celik (2008) GCI over consecutive mesh triplets (finest first) and reports the observed order p, the extrapolated value, and the fine-grid GCI per triplet per QoI.
51
+
52
+ BEST FOR: Scalar benchmarks (reattachment length, centerline values, probe readings) where a classical asymptotic-convergence check is expected.
53
+ NOT FOR: High-dimensional field comparison — use barmesh_mesh_convergence (complementary).
54
+ ASYNC: queued job; poll barmesh_jobs(action=status), then barmesh_results(action=get).
55
+ COMMON MISTAKES: not providing h_column or n_cells_column; mixing QoIs with different mesh sets in one CSV.`, {
56
+ action: z.enum(["submit"]).optional().describe("submit: enqueue the GCI job (default)"),
57
+ dataset_id: z.string().describe("Dataset ID (one row per mesh)"),
58
+ qoi_columns: z.array(z.string()).describe("Scalar QoI column names to extrapolate"),
59
+ mesh_column: z.string().optional().describe("Mesh label column (default mesh_id)"),
60
+ h_column: z.string().optional().describe("Representative cell-size column (provide this or n_cells_column)"),
61
+ n_cells_column: z.string().optional().describe("Cell-count column; h is derived as n_cells^(-1/dimension)"),
62
+ dimension: z.number().int().optional().describe("Spatial dimension d for deriving h from cell count (default 3)"),
63
+ safety_factor: z.number().optional().describe("GCI safety factor Fs (default 1.25, Roache)"),
64
+ label: z.string().optional().describe("Optional job label"),
65
+ }, async (args) => {
66
+ const { action, dataset_id, label, ...rest } = args;
67
+ void action;
68
+ const params = {};
69
+ for (const [k, v] of Object.entries(rest)) {
70
+ if (v !== undefined && v !== null)
71
+ params[k] = v;
72
+ }
73
+ const body = { dataset_id, params };
74
+ if (typeof label === "string" && label.length > 0)
75
+ body.label = label;
76
+ const data = (await apiCall("POST", "/v1/cfd/richardson", body));
77
+ const id = data.id;
78
+ if (id != null)
79
+ data.suggested_next_step = `Poll barmesh_jobs(action=status, job_id=${id}); on completion call barmesh_results(action=get, job_id=${id}).`;
80
+ return textResult(data);
81
+ });
82
+ }
@@ -0,0 +1,64 @@
1
+ import { gzipSync } from "node:zlib";
2
+ import { z } from "zod";
3
+ import fs from "node:fs/promises";
4
+ import { registerAuditedTool } from "../audit.js";
5
+ import { apiCall, resolveFilePathForUpload, textResult, UPLOAD_DATASET_TIMEOUT_MS, } from "../shared.js";
6
+ export function registerDatasetsTool(server) {
7
+ registerAuditedTool(server, "barmesh_datasets", `Upload, preview, or list the combined per-cell mesh CSV used for convergence analysis.
8
+
9
+ | Action | Use when |
10
+ |--------|----------|
11
+ | upload | You have prepared a combined per-cell CSV (mesh_id + feature columns + cell volume V). Do this first. |
12
+ | preview | After upload — verify the mesh column, feature columns, and volume column are present and numeric. |
13
+ | list | Find dataset IDs for analysis. |
14
+
15
+ BEST FOR: One combined CSV holding all meshes of a refinement study (one row per cell, a mesh label column, the physical channels, and a cell-volume column).
16
+ NOT FOR: Raw OpenFOAM case directories — extract a per-cell CSV first (see barmesh_prepare_mesh_data).
17
+ COMMON MISTAKES: omitting the cell-volume column (defaults to equal weights, which weakens the fingerprint); inconsistent feature columns across meshes.
18
+ ESCALATION: If preview shows a feature column as non-numeric, fix the extraction and re-upload.`, {
19
+ action: z.enum(["upload", "preview", "list"]).describe("upload: add the combined CSV; preview: inspect columns; list: see datasets"),
20
+ name: z.string().optional().describe("Dataset name (required for upload)"),
21
+ file_path: z.string().optional().describe("Path to the combined CSV (PREFERRED): absolute, file:// URI, or relative to the workspace root"),
22
+ csv_data: z.string().optional().describe("Inline CSV string for small pastes only (<10KB). Prefer file_path."),
23
+ dataset_id: z.string().optional().describe("Dataset ID (required for preview)"),
24
+ n_rows: z.number().int().optional().default(5).describe("Sample rows to return (preview only)"),
25
+ }, async (args) => {
26
+ const { action, name, file_path, csv_data, dataset_id, n_rows } = args;
27
+ if (action === "upload") {
28
+ if (!name)
29
+ throw new Error("barmesh_datasets(upload) requires name.");
30
+ let body;
31
+ if (file_path && file_path.length > 0) {
32
+ const resolved = await resolveFilePathForUpload(file_path, server);
33
+ body = await fs.readFile(resolved, "utf-8");
34
+ }
35
+ else if (csv_data && csv_data.length > 0) {
36
+ body = csv_data;
37
+ }
38
+ else {
39
+ throw new Error("barmesh_datasets(upload) requires file_path or csv_data. Prefer file_path.");
40
+ }
41
+ const GZIP_THRESHOLD = 1024 * 1024;
42
+ const uploadHeaders = { "X-Dataset-Name": name, "Content-Type": "text/csv" };
43
+ let uploadBody = body;
44
+ if (Buffer.byteLength(body, "utf-8") > GZIP_THRESHOLD) {
45
+ uploadBody = gzipSync(Buffer.from(body, "utf-8"));
46
+ uploadHeaders["Content-Encoding"] = "gzip";
47
+ }
48
+ const data = (await apiCall("POST", "/v1/datasets", uploadBody, uploadHeaders, UPLOAD_DATASET_TIMEOUT_MS));
49
+ const id = data.id ?? data.dataset_id;
50
+ if (id != null)
51
+ data.suggested_next_step = `Next: barmesh_datasets(action=preview, dataset_id=${id}) to verify the mesh, feature, and volume columns.`;
52
+ return textResult(data);
53
+ }
54
+ if (action === "preview") {
55
+ if (!dataset_id)
56
+ throw new Error("barmesh_datasets(preview) requires dataset_id.");
57
+ const data = await apiCall("GET", `/v1/datasets/${dataset_id}/preview?n_rows=${n_rows ?? 5}`);
58
+ return textResult(data);
59
+ }
60
+ // list
61
+ const data = await apiCall("GET", "/v1/datasets");
62
+ return textResult(data);
63
+ });
64
+ }
@@ -0,0 +1,36 @@
1
+ import { z } from "zod";
2
+ import { registerAuditedTool } from "../audit.js";
3
+ import { apiCall, textResult } from "../shared.js";
4
+ const OFFLINE_GUIDE = `barmesh: CFD mesh-convergence on the Barivia API.
5
+ Two tracks: barmesh_mesh_convergence (SOM fingerprint distances) and barmesh_richardson (classical GCI).
6
+ Workflow: barmesh_prepare_mesh_data -> barmesh_datasets(upload) -> barmesh_mesh_convergence / barmesh_richardson -> barmesh_jobs(status) -> barmesh_results(get).
7
+ (API unreachable; this is the offline summary. Set BARIVIA_API_KEY / BARIVIA_API_URL.)`;
8
+ export function registerGuideTool(server) {
9
+ registerAuditedTool(server, "barmesh_guide_workflow", `Get the barmesh CFD mesh-convergence workflow and tool map from the API (tier-scoped).
10
+
11
+ BEST FOR: First call in a session — orients you on the two tracks (SOM fingerprint distances and Richardson/GCI), the upload->submit->poll->results flow, and what your plan allows.
12
+ NOT FOR: Step-by-step mesh-data preparation — use barmesh_prepare_mesh_data for that.
13
+ ESCALATION: If the response says your plan does not include CFD tools, the analysis tools will return 403; contact Barivia to enable the cfd entitlement.`, { action: z.enum(["get"]).optional().describe("get: fetch the workflow guide (default)") }, async () => {
14
+ try {
15
+ const data = (await apiCall("GET", "/v1/cfd/guide"));
16
+ return textResult({
17
+ guide: data.guide,
18
+ entitled: data.entitled,
19
+ allowed_job_types: data.allowed_job_types,
20
+ });
21
+ }
22
+ catch (e) {
23
+ if (e?.httpStatus === 401 || e?.httpStatus === 403)
24
+ throw e;
25
+ return textResult(OFFLINE_GUIDE);
26
+ }
27
+ });
28
+ registerAuditedTool(server, "barmesh_prepare_mesh_data", `Get the step-by-step recipe for turning a mesh-refinement study into ONE combined per-cell CSV for SOM convergence analysis.
29
+
30
+ BEST FOR: Before barmesh_datasets(upload) — tells you which physical channels to extract, how to label meshes (mesh_id), the cell-volume column (V), and how to pick the reference mesh.
31
+ NOT FOR: Submitting jobs — after preparing the CSV, use barmesh_datasets(upload) then barmesh_mesh_convergence.
32
+ COMMON MISTAKES: forgetting the per-cell cell-volume column; using different channels across meshes; not log-compressing turbulence quantities (k, epsilon, omega).`, { action: z.enum(["get"]).optional().describe("get: fetch the mesh-prep recipe (default)") }, async () => {
33
+ const data = (await apiCall("GET", "/v1/cfd/prep"));
34
+ return textResult({ recipe: data.recipe, entitled: data.entitled });
35
+ });
36
+ }
@@ -0,0 +1,23 @@
1
+ import { z } from "zod";
2
+ import { registerAuditedTool } from "../audit.js";
3
+ import { apiCall, textResult } from "../shared.js";
4
+ export function registerJobsTool(server) {
5
+ registerAuditedTool(server, "barmesh_jobs", `Check job status or list jobs.
6
+
7
+ BEST FOR: Polling a submitted barmesh_mesh_convergence or barmesh_richardson job until status is completed/failed.
8
+ ASYNC PROTOCOL: Poll action=status every 10-20s. Keep polling for several minutes — datacenter-scale grids plus EMD are slow; do not give up after one poll. When status=completed, call barmesh_results(action=get, job_id=...).
9
+ ESCALATION: status=failed returns an error message and (when available) a failure_stage; read it before retrying.`, {
10
+ action: z.enum(["status", "list"]).describe("status: check one job; list: recent jobs"),
11
+ job_id: z.string().optional().describe("Job ID (required for status)"),
12
+ }, async (args) => {
13
+ const { action, job_id } = args;
14
+ if (action === "status") {
15
+ if (!job_id)
16
+ throw new Error("barmesh_jobs(status) requires job_id.");
17
+ const data = await apiCall("GET", `/v1/jobs/${job_id}`);
18
+ return textResult(data);
19
+ }
20
+ const data = await apiCall("GET", "/v1/jobs");
21
+ return textResult(data);
22
+ });
23
+ }
@@ -0,0 +1,68 @@
1
+ import { z } from "zod";
2
+ import { registerAuditedTool } from "../audit.js";
3
+ import { apiCall, apiRawCall, textResult, tryAttachImage } from "../shared.js";
4
+ const MESH_DEFAULT_FIGURES = ["KL_ref", "EMD_ref", "plot_vol_coarse_fine"];
5
+ export function registerResultsTool(server) {
6
+ registerAuditedTool(server, "barmesh_results", `Fetch results of a completed CFD job: distances, convergence reading, and figures.
7
+
8
+ | Action | Use when |
9
+ |--------|----------|
10
+ | 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.pdf, EMD_stepwise.png). |
12
+
13
+ BEST FOR: After barmesh_jobs(action=status) shows completed.
14
+ FIGURES: For mesh_convergence, action=get inlines the headline panels (KL_ref, EMD_ref, volume coarse/fine) as PNGs with captions; pass figures="all" for every figure, "none" for metrics only, or a list of names. PDFs are publication vector copies — fetch with action=image.
15
+ NOT FOR: Submitting jobs.`, {
16
+ action: z.enum(["get", "image"]).describe("get: summary + figures; image: download one figure file"),
17
+ job_id: z.string().describe("Job ID"),
18
+ filename: z.string().optional().describe("For action=image: the figure filename (e.g. KL_ref.pdf)"),
19
+ figures: z
20
+ .union([z.enum(["default", "all", "none"]), z.array(z.string())])
21
+ .optional()
22
+ .describe("For action=get: which figures to inline (default headline panels; 'all'; 'none'; or a list of names)"),
23
+ }, async (args) => {
24
+ const { action, job_id, filename, figures } = args;
25
+ if (action === "image") {
26
+ if (!filename)
27
+ throw new Error("barmesh_results(image) requires filename.");
28
+ const { data, contentType } = await apiRawCall(`/v1/results/${job_id}/image/${filename}`);
29
+ if (contentType.startsWith("image/")) {
30
+ return {
31
+ content: [
32
+ { type: "text", text: `${filename} (${contentType})` },
33
+ { type: "image", data: data.toString("base64"), mimeType: contentType, annotations: { audience: ["user"], priority: 0.8 } },
34
+ ],
35
+ };
36
+ }
37
+ return textResult(`${filename} is ${contentType}; saved out-of-band. For vector PDFs, open the downloaded file directly.`);
38
+ }
39
+ const summary = (await apiCall("GET", `/v1/results/${job_id}`));
40
+ const content = [{ type: "text", text: JSON.stringify(summary, null, 2) }];
41
+ if (figures === "none")
42
+ return { content };
43
+ const allFiles = summary.files ?? [];
44
+ const imageFiles = allFiles.filter((f) => /\.png$/i.test(f));
45
+ let toFetch;
46
+ if (figures === "all") {
47
+ toFetch = imageFiles;
48
+ }
49
+ else if (Array.isArray(figures)) {
50
+ toFetch = figures
51
+ .map((key) => {
52
+ if (/\.(png|pdf|svg)$/i.test(key))
53
+ return key;
54
+ const png = imageFiles.find((f) => f.replace(/\.png$/i, "") === key);
55
+ return png ?? null;
56
+ })
57
+ .filter((f) => f != null);
58
+ }
59
+ else {
60
+ // default headline panels (PNG) when present
61
+ toFetch = MESH_DEFAULT_FIGURES.map((b) => `${b}.png`).filter((f) => imageFiles.includes(f));
62
+ }
63
+ for (const f of toFetch) {
64
+ await tryAttachImage(content, job_id, f);
65
+ }
66
+ return { content };
67
+ });
68
+ }
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@barivia/barmesh-mcp",
3
+ "version": "0.1.0",
4
+ "description": "barmesh MCP proxy — SOM-based CFD mesh-convergence and Richardson/GCI analysis on the Barivia cloud API",
5
+ "keywords": [
6
+ "mcp",
7
+ "cfd",
8
+ "mesh-convergence",
9
+ "grid-convergence",
10
+ "richardson",
11
+ "gci",
12
+ "som",
13
+ "barivia"
14
+ ],
15
+ "license": "UNLICENSED",
16
+ "author": "Barivia AB",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/barestrand/barivia-platform.git",
20
+ "directory": "apps/barmesh-mcp"
21
+ },
22
+ "homepage": "https://github.com/barestrand/barivia-platform/tree/main/apps/barmesh-mcp#readme",
23
+ "bugs": {
24
+ "url": "https://github.com/barestrand/barivia-platform/issues"
25
+ },
26
+ "type": "module",
27
+ "main": "dist/index.js",
28
+ "bin": {
29
+ "barmesh-mcp": "dist/index.js"
30
+ },
31
+ "files": [
32
+ "LICENSE",
33
+ "dist/**/*.js"
34
+ ],
35
+ "scripts": {
36
+ "minify": "terser dist/index.js -o dist/index.js -c -m --toplevel",
37
+ "build": "tsc && npm run minify",
38
+ "build:publish": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.publish.json && npm run minify",
39
+ "dev": "tsx src/index.ts",
40
+ "test": "vitest run --config vitest.config.ts",
41
+ "prepublishOnly": "npm run build:publish"
42
+ },
43
+ "dependencies": {
44
+ "@modelcontextprotocol/sdk": "^1.0.0",
45
+ "zod": "^3.23.0"
46
+ },
47
+ "devDependencies": {
48
+ "@types/node": "^22.0.0",
49
+ "terser": "^5.46.0",
50
+ "tsx": "^4.19.0",
51
+ "typescript": "^5.5.0",
52
+ "vitest": "^4.0.18"
53
+ },
54
+ "engines": {
55
+ "node": ">=18"
56
+ }
57
+ }