@barivia/barmesh-mcp 0.2.0 → 0.3.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 CHANGED
@@ -50,6 +50,11 @@ API key; otherwise the analysis calls return HTTP 403. Contact Barivia to enable
50
50
  | `barmesh_richardson` | Richardson/GCI on scalar QoIs (async job). |
51
51
  | `barmesh_jobs` | Poll job status / list jobs. |
52
52
  | `barmesh_results` | Distances, convergence reading, and figures. |
53
+ | `barmesh_send_feedback` | Send a short note or bug report to the Barivia team. |
54
+
55
+ ### Migration notes
56
+
57
+ - **`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.
53
58
 
54
59
  ## Data format (mesh_convergence)
55
60
 
@@ -63,6 +68,6 @@ cell-volume column (`V`). Use `barmesh_prepare_mesh_data` for the full recipe.
63
68
  |----------|---------|---------|
64
69
  | `BARIVIA_API_KEY` | (required) | Your Barivia API key. |
65
70
  | `BARIVIA_API_URL` | `https://api.barivia.se` | API base URL. |
66
- | `BARIVIA_FETCH_TIMEOUT_MS` | `30000` | Per-request timeout (raise for large uploads). |
71
+ | `BARIVIA_FETCH_TIMEOUT_MS` | `60000` | Per-request timeout (raise for large uploads). |
67
72
  | `BARIVIA_WORKSPACE_ROOT` | workspace/cwd | Root for resolving relative `file_path` uploads. |
68
73
  | `BARIVIA_ENFORCE_WORKSPACE_SANDBOX` | `1` | Restrict uploads to the workspace; set `0` to allow absolute paths. |
package/dist/shared.js CHANGED
@@ -4,6 +4,10 @@
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";
8
+ import { createGzip } from "node:zlib";
9
+ import { createHash } from "node:crypto";
10
+ import { Readable } from "node:stream";
7
11
  import path from "node:path";
8
12
  import { fileURLToPath } from "node:url";
9
13
  import { logInfo } from "./logger.js";
@@ -12,24 +16,65 @@ import { logInfo } from "./logger.js";
12
16
  // ---------------------------------------------------------------------------
13
17
  export const API_URL = process.env.BARIVIA_API_URL ?? process.env.BARSOM_API_URL ?? "https://api.barivia.se";
14
18
  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);
19
+ export const FETCH_TIMEOUT_MS = parseInt(process.env.BARIVIA_FETCH_TIMEOUT_MS ?? "60000", 10);
16
20
  export const MAX_RETRIES = 2;
17
21
  export const RETRYABLE_STATUS = new Set([502, 503, 504]);
18
22
  /** Single source of truth for the proxy version. Keep in sync with package.json on bump. */
19
- export const CLIENT_VERSION = "0.2.0";
23
+ export const CLIENT_VERSION = "0.3.0";
20
24
  export const PUBLIC_SITE_ORIGIN = "https://barivia.se";
21
25
  /** Large per-cell CSV uploads may exceed the default fetch timeout. */
22
26
  export const UPLOAD_DATASET_TIMEOUT_MS = 180_000;
27
+ /** Files at/above this size use the presigned direct-to-R2 streaming upload path. */
28
+ export const LARGE_UPLOAD_BYTES = 64 * 1024 * 1024; // 64 MB
29
+ /** Timeout for a direct presigned PUT of a large (gzipped) file to R2. */
30
+ export const PRESIGNED_PUT_TIMEOUT_MS = 30 * 60_000; // 30 min
31
+ /** Poll window for the async stage_dataset job. */
32
+ export const POLL_STAGE_MAX_MS = 30 * 60_000; // 30 min
33
+ /** Streaming SHA-256 of a file (idempotency key) without reading it into memory. */
34
+ export async function streamFileSha256(srcPath) {
35
+ return new Promise((resolve, reject) => {
36
+ const h = createHash("sha256");
37
+ const s = createReadStream(srcPath);
38
+ s.on("data", (chunk) => h.update(chunk));
39
+ s.on("end", () => resolve(h.digest("hex")));
40
+ s.on("error", reject);
41
+ });
42
+ }
43
+ /** Stream a local file through gzip directly to a presigned PUT URL (e.g. R2). */
44
+ export async function putPresignedStream(url, srcPath, contentType, timeoutMs = PRESIGNED_PUT_TIMEOUT_MS) {
45
+ const gz = createReadStream(srcPath).pipe(createGzip());
46
+ const webStream = Readable.toWeb(gz);
47
+ const controller = new AbortController();
48
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
49
+ try {
50
+ const resp = await fetch(url, {
51
+ method: "PUT",
52
+ body: webStream,
53
+ headers: { "Content-Type": contentType },
54
+ duplex: "half",
55
+ signal: controller.signal,
56
+ });
57
+ if (!resp.ok) {
58
+ const t = await resp.text().catch(() => "");
59
+ throw new Error(`Presigned upload failed: HTTP ${resp.status} ${t.slice(0, 200)}`);
60
+ }
61
+ }
62
+ finally {
63
+ clearTimeout(timer);
64
+ }
65
+ }
23
66
  // ---------------------------------------------------------------------------
24
67
  // Fetch helpers
25
68
  // ---------------------------------------------------------------------------
26
69
  export function isTransientError(err, status) {
27
70
  if (status !== undefined && RETRYABLE_STATUS.has(status))
28
71
  return true;
29
- if (err instanceof DOMException && err.name === "AbortError")
30
- return true;
72
+ // NOTE: a client-side timeout (AbortError) is deliberately NOT retried. Re-firing
73
+ // a slow/expensive request (e.g. mesh-convergence enqueue, large upload) while the
74
+ // server is still processing the first one only multiplies load and risks duplicates
75
+ // — raise BARIVIA_FETCH_TIMEOUT_MS or use the async (job_id + poll) path instead.
31
76
  if (err instanceof TypeError)
32
- return true;
77
+ return true; // network-level fetch failure
33
78
  return false;
34
79
  }
35
80
  export async function fetchWithTimeout(url, init, timeoutMs = FETCH_TIMEOUT_MS) {
@@ -234,6 +279,9 @@ export async function apiRawCall(pathPart, requestTimeoutMs) {
234
279
  await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
235
280
  continue;
236
281
  }
282
+ if (err instanceof DOMException && err.name === "AbortError" && !err.httpStatus) {
283
+ throw new Error(`Image request timed out after ${effectiveTimeout}ms. Increase BARIVIA_FETCH_TIMEOUT_MS (e.g. 120000) for large images. (request id: ${requestId})`);
284
+ }
237
285
  throw err;
238
286
  }
239
287
  }
package/dist/tools/cfd.js CHANGED
@@ -10,7 +10,6 @@ BEST FOR: A mesh-refinement study where you want a field-level (not just scalar)
10
10
  NOT FOR: Single scalar quantities of interest — use barmesh_richardson for classical GCI.
11
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
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
13
  dataset_id: z.string().describe("Dataset ID from barmesh_datasets(upload)"),
15
14
  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
15
  preset: z.enum(["generic", "cavity", "pitz", "datacenter"]).optional().describe("Hyperparameter preset; defaults to generic. cavity/pitz/datacenter reproduce the published settings."),
@@ -29,8 +28,7 @@ COMMON MISTAKES: omitting feature_columns (required); choosing a reference_mesh
29
28
  figures: z.boolean().optional().describe("Generate publication figures (default true)"),
30
29
  label: z.string().optional().describe("Optional job label"),
31
30
  }, async (args) => {
32
- const { action, dataset_id, label, ...rest } = args;
33
- void action;
31
+ const { dataset_id, label, ...rest } = args;
34
32
  const params = {};
35
33
  for (const [k, v] of Object.entries(rest)) {
36
34
  if (v !== undefined && v !== null)
@@ -53,7 +51,6 @@ BEST FOR: Scalar benchmarks (reattachment length, centerline values, probe readi
53
51
  NOT FOR: High-dimensional field comparison — use barmesh_mesh_convergence (complementary).
54
52
  ASYNC: queued job; poll barmesh_jobs(action=status), then barmesh_results(action=get).
55
53
  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
54
  dataset_id: z.string().describe("Dataset ID (one row per mesh)"),
58
55
  qoi_columns: z.array(z.string()).describe("Scalar QoI column names to extrapolate"),
59
56
  mesh_column: z.string().optional().describe("Mesh label column (default mesh_id)"),
@@ -63,8 +60,7 @@ COMMON MISTAKES: not providing h_column or n_cells_column; mixing QoIs with diff
63
60
  safety_factor: z.number().optional().describe("GCI safety factor Fs (default 1.25, Roache)"),
64
61
  label: z.string().optional().describe("Optional job label"),
65
62
  }, async (args) => {
66
- const { action, dataset_id, label, ...rest } = args;
67
- void action;
63
+ const { dataset_id, label, ...rest } = args;
68
64
  const params = {};
69
65
  for (const [k, v] of Object.entries(rest)) {
70
66
  if (v !== undefined && v !== null)
@@ -1,8 +1,10 @@
1
1
  import { gzipSync } from "node:zlib";
2
+ import { createHash } from "node:crypto";
2
3
  import { z } from "zod";
3
4
  import fs from "node:fs/promises";
5
+ import path from "node:path";
4
6
  import { registerAuditedTool } from "../audit.js";
5
- import { apiCall, resolveFilePathForUpload, textResult, UPLOAD_DATASET_TIMEOUT_MS, } from "../shared.js";
7
+ import { apiCall, resolveFilePathForUpload, textResult, pollUntilComplete, UPLOAD_DATASET_TIMEOUT_MS, LARGE_UPLOAD_BYTES, PRESIGNED_PUT_TIMEOUT_MS, POLL_STAGE_MAX_MS, streamFileSha256, putPresignedStream, } from "../shared.js";
6
8
  export function registerDatasetsTool(server) {
7
9
  registerAuditedTool(server, "barmesh_datasets", `Upload, preview, or list the combined per-cell mesh CSV used for convergence analysis.
8
10
 
@@ -29,7 +31,39 @@ ESCALATION: If preview shows a feature column as non-numeric, fix the extraction
29
31
  throw new Error("barmesh_datasets(upload) requires name.");
30
32
  let body;
31
33
  if (file_path && file_path.length > 0) {
34
+ // Preflight: warm plan/limits and reject over-limit uploads before reading the file.
35
+ await apiCall("GET", "/v1/system/info");
32
36
  const resolved = await resolveFilePathForUpload(file_path, server);
37
+ const ext = path.extname(resolved).toLowerCase();
38
+ if (ext !== ".csv" && ext !== ".tsv") {
39
+ throw new Error("Only .csv and .tsv files can be uploaded as datasets.");
40
+ }
41
+ const HARD_MAX_BYTES = 5 * 1024 * 1024 * 1024; // 5 GB
42
+ const stat = await fs.stat(resolved);
43
+ if (stat.size > HARD_MAX_BYTES) {
44
+ throw new Error(`File too large (${(stat.size / 1024 / 1024 / 1024).toFixed(2)} GB). Maximum upload size is 5 GB.`);
45
+ }
46
+ if (stat.size >= LARGE_UPLOAD_BYTES) {
47
+ const idem = await streamFileSha256(resolved);
48
+ const init = (await apiCall("POST", "/v1/datasets/upload-url", { name, size_bytes: stat.size }, { "Idempotency-Key": idem }));
49
+ const datasetId = (init.dataset_id ?? init.id);
50
+ if (init.idempotent_replay) {
51
+ return textResult({ id: datasetId, status: init.status, idempotent_replay: true,
52
+ suggested_next_step: `barmesh_datasets(action=preview, dataset_id=${datasetId})` });
53
+ }
54
+ await putPresignedStream(init.upload_url, resolved, init.content_type ?? "application/octet-stream", PRESIGNED_PUT_TIMEOUT_MS);
55
+ const fin = (await apiCall("POST", `/v1/datasets/${datasetId}/finalize`, {}));
56
+ const jobId = (fin.id ?? fin.job_id);
57
+ const poll = await pollUntilComplete(jobId, POLL_STAGE_MAX_MS);
58
+ if (poll.status === "failed") {
59
+ return textResult({ id: datasetId, status: "failed", error: poll.error ?? "staging failed" });
60
+ }
61
+ const ready = poll.status === "completed";
62
+ return textResult({ id: datasetId, status: ready ? "ready" : "staging", job_id: jobId,
63
+ suggested_next_step: ready
64
+ ? `barmesh_datasets(action=preview, dataset_id=${datasetId})`
65
+ : `Still staging; poll barmesh_jobs(action=status, job_id="${jobId}").` });
66
+ }
33
67
  body = await fs.readFile(resolved, "utf-8");
34
68
  }
35
69
  else if (csv_data && csv_data.length > 0) {
@@ -39,7 +73,13 @@ ESCALATION: If preview shows a feature column as non-numeric, fix the extraction
39
73
  throw new Error("barmesh_datasets(upload) requires file_path or csv_data. Prefer file_path.");
40
74
  }
41
75
  const GZIP_THRESHOLD = 1024 * 1024;
42
- const uploadHeaders = { "X-Dataset-Name": name, "Content-Type": "text/csv" };
76
+ const uploadHeaders = {
77
+ "X-Dataset-Name": name,
78
+ "Content-Type": "text/csv",
79
+ // Deterministic key so a timed-out retry of the SAME upload reconciles to
80
+ // the original dataset server-side instead of creating a duplicate.
81
+ "Idempotency-Key": createHash("sha256").update(`${name}\n`).update(body).digest("hex"),
82
+ };
43
83
  let uploadBody = body;
44
84
  if (Buffer.byteLength(body, "utf-8") > GZIP_THRESHOLD) {
45
85
  uploadBody = gzipSync(Buffer.from(body, "utf-8"));
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
  import { registerAuditedTool } from "../audit.js";
3
3
  import { apiCall, API_URL, fetchWithTimeout, textResult } from "../shared.js";
4
4
  export function registerFeedbackTool(server) {
5
- registerAuditedTool(server, "send_feedback", `Send feedback or feature requests to Barivia developers (CFD mesh-convergence / barmesh workflow). Use when the user has suggestions, ran into issues, or wants something improved. Do NOT call without asking the user first — but after a mesh-convergence or Richardson session, you SHOULD prepare feedback based on the user's workflow or errors encountered, show it to them, and ask for permission to send it. Once they accept, call this tool.
5
+ registerAuditedTool(server, "barmesh_send_feedback", `Send feedback or feature requests to Barivia developers (CFD mesh-convergence / barmesh workflow). Use when the user has suggestions, ran into issues, or wants something improved. Do NOT call without asking the user first — but after a mesh-convergence or Richardson session, you SHOULD prepare feedback based on the user's workflow or errors encountered, show it to them, and ask for permission to send it. Once they accept, call this tool.
6
6
 
7
7
  For a substantial issue, prefer feedback_items: submit several focused instances (each max 1400 chars) covering, e.g., symptoms, exact reproduction steps, environment, and concrete asks — they are stored together as one batch so developers see the full picture. Use the single feedback field for a short one-off note.`, {
8
8
  feedback: z.string().max(1400).optional().describe("Single feedback note (max 1400 characters). Use feedback_items instead for a multi-part report."),
@@ -12,7 +12,7 @@ For a substantial issue, prefer feedback_items: submit several focused instances
12
12
  if (feedback && feedback.trim().length > 0)
13
13
  items.unshift(feedback.trim());
14
14
  if (items.length === 0) {
15
- throw new Error("send_feedback requires feedback or feedback_items (at least one non-empty entry).");
15
+ throw new Error("barmesh_send_feedback requires feedback or feedback_items (at least one non-empty entry).");
16
16
  }
17
17
  const body = items.length === 1 ? { feedback: items[0] } : { feedback_items: items };
18
18
  try {
@@ -1,16 +1,20 @@
1
- import { z } from "zod";
2
1
  import { registerAuditedTool } from "../audit.js";
3
2
  import { apiCall, textResult } from "../shared.js";
4
3
  const OFFLINE_GUIDE = `barmesh: CFD mesh-convergence on the Barivia API.
5
4
  Two tracks: barmesh_mesh_convergence (SOM fingerprint distances) and barmesh_richardson (classical GCI).
6
5
  Workflow: barmesh_prepare_mesh_data -> barmesh_datasets(upload) -> barmesh_mesh_convergence / barmesh_richardson -> barmesh_jobs(status) -> barmesh_results(get).
7
6
  (API unreachable; this is the offline summary. Set BARIVIA_API_KEY / BARIVIA_API_URL.)`;
7
+ const OFFLINE_PREP = `barmesh mesh-data prep (offline summary; API unreachable):
8
+ Build ONE combined per-cell CSV across all meshes of the refinement study:
9
+ - one row per cell; a mesh label column (mesh_id); the physical channels you want compared (e.g. p, U_mag, k, log_epsilon — log-compress turbulence quantities); and a cell-volume column (V) for fingerprint weighting.
10
+ - keep the SAME feature columns across every mesh; pick the finest mesh as the reference.
11
+ Then: barmesh_datasets(upload) -> barmesh_mesh_convergence. (Set BARIVIA_API_KEY / BARIVIA_API_URL.)`;
8
12
  export function registerGuideTool(server) {
9
13
  registerAuditedTool(server, "barmesh_guide_workflow", `Get the barmesh CFD mesh-convergence workflow and tool map from the API (tier-scoped).
10
14
 
11
15
  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
16
  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 () => {
17
+ 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.`, {}, async () => {
14
18
  try {
15
19
  const data = (await apiCall("GET", "/v1/cfd/guide"));
16
20
  return textResult({
@@ -29,8 +33,15 @@ ESCALATION: If the response says your plan does not include CFD tools, the analy
29
33
 
30
34
  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
35
  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 });
36
+ COMMON MISTAKES: forgetting the per-cell cell-volume column; using different channels across meshes; not log-compressing turbulence quantities (k, epsilon, omega).`, {}, async () => {
37
+ try {
38
+ const data = (await apiCall("GET", "/v1/cfd/prep"));
39
+ return textResult({ recipe: data.recipe, entitled: data.entitled });
40
+ }
41
+ catch (e) {
42
+ if (e?.httpStatus === 401 || e?.httpStatus === 403)
43
+ throw e;
44
+ return textResult(OFFLINE_PREP);
45
+ }
35
46
  });
36
47
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barivia/barmesh-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "barmesh MCP proxy — SOM-based CFD mesh-convergence and Richardson/GCI analysis on the Barivia cloud API",
5
5
  "keywords": [
6
6
  "mcp",