@barivia/barsom-mcp 0.11.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/shared.js CHANGED
@@ -2,6 +2,10 @@
2
2
  * Shared configuration, helpers, types, and state for the MCP proxy.
3
3
  */
4
4
  import fs from "node:fs/promises";
5
+ import { createReadStream } from "node:fs";
6
+ import { createGzip } from "node:zlib";
7
+ import { createHash } from "node:crypto";
8
+ import { Readable } from "node:stream";
5
9
  import path from "node:path";
6
10
  import { fileURLToPath } from "node:url";
7
11
  import { logInfo } from "./logger.js";
@@ -20,7 +24,7 @@ export const RETRYABLE_STATUS = new Set([502, 503, 504]);
20
24
  * X-Barsom-Client-Version so the server can annotate tool guidance with the
21
25
  * wrapper version each action requires. Keep in sync with package.json on bump.
22
26
  */
23
- export const CLIENT_VERSION = "0.11.0";
27
+ export const CLIENT_VERSION = "0.11.1";
24
28
  /** User-facing links; keep aligned with barivia.se / api.barivia.se. */
25
29
  export const PUBLIC_SITE_ORIGIN = "https://barivia.se";
26
30
  /** Self-serve account dashboard (manage plan, billing, and API keys). */
@@ -29,6 +33,12 @@ export const PUBLIC_DASHBOARD_URL = `${PUBLIC_SITE_ORIGIN}/dashboard`;
29
33
  export const POLL_DERIVE_MAX_MS = 120_000;
30
34
  /** Large CSV uploads may exceed default FETCH_TIMEOUT_MS. */
31
35
  export const UPLOAD_DATASET_TIMEOUT_MS = 180_000;
36
+ /** Files at/above this size use the presigned direct-to-R2 streaming upload path. */
37
+ export const LARGE_UPLOAD_BYTES = 64 * 1024 * 1024; // 64 MB
38
+ /** Timeout for a direct presigned PUT of a large (gzipped) file to R2. */
39
+ export const PRESIGNED_PUT_TIMEOUT_MS = 30 * 60_000; // 30 min
40
+ /** Poll window for the async stage_dataset job (large files take minutes). */
41
+ export const POLL_STAGE_MAX_MS = 30 * 60_000; // 30 min
32
42
  // ---------------------------------------------------------------------------
33
43
  // Shared state (mutable)
34
44
  // ---------------------------------------------------------------------------
@@ -60,6 +70,43 @@ export function isTransientError(err, status) {
60
70
  return true; // network-level fetch failure
61
71
  return false;
62
72
  }
73
+ /** Streaming SHA-256 of a file (for idempotency keys) without reading it into memory. */
74
+ export async function streamFileSha256(srcPath) {
75
+ return new Promise((resolve, reject) => {
76
+ const h = createHash("sha256");
77
+ const s = createReadStream(srcPath);
78
+ s.on("data", (chunk) => h.update(chunk));
79
+ s.on("end", () => resolve(h.digest("hex")));
80
+ s.on("error", reject);
81
+ });
82
+ }
83
+ /**
84
+ * Stream a local file through gzip directly to a presigned PUT URL (e.g. R2),
85
+ * never materializing the file in memory. Bypasses the API/Cloudflare body cap.
86
+ */
87
+ export async function putPresignedStream(url, srcPath, contentType, timeoutMs = PRESIGNED_PUT_TIMEOUT_MS) {
88
+ const gz = createReadStream(srcPath).pipe(createGzip());
89
+ const webStream = Readable.toWeb(gz);
90
+ const controller = new AbortController();
91
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
92
+ try {
93
+ const resp = await fetch(url, {
94
+ method: "PUT",
95
+ body: webStream,
96
+ headers: { "Content-Type": contentType },
97
+ // Required by Node's fetch to send a streaming request body.
98
+ duplex: "half",
99
+ signal: controller.signal,
100
+ });
101
+ if (!resp.ok) {
102
+ const t = await resp.text().catch(() => "");
103
+ throw new Error(`Presigned upload failed: HTTP ${resp.status} ${t.slice(0, 200)}`);
104
+ }
105
+ }
106
+ finally {
107
+ clearTimeout(timer);
108
+ }
109
+ }
63
110
  export async function fetchWithTimeout(url, init, timeoutMs = FETCH_TIMEOUT_MS) {
64
111
  const controller = new AbortController();
65
112
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -4,7 +4,7 @@ import { gzipSync } from "node:zlib";
4
4
  import { createHash } from "node:crypto";
5
5
  import { z } from "zod";
6
6
  import { registerAuditedTool } from "../audit.js";
7
- import { apiCall, getWorkspaceRootAsync, resolveFilePathForUpload, textResult, pollUntilComplete, POLL_DERIVE_MAX_MS, UPLOAD_DATASET_TIMEOUT_MS, } from "../shared.js";
7
+ import { apiCall, getWorkspaceRootAsync, resolveFilePathForUpload, textResult, pollUntilComplete, POLL_DERIVE_MAX_MS, UPLOAD_DATASET_TIMEOUT_MS, LARGE_UPLOAD_BYTES, PRESIGNED_PUT_TIMEOUT_MS, POLL_STAGE_MAX_MS, streamFileSha256, putPresignedStream, } from "../shared.js";
8
8
  /**
9
9
  * Format a dataset analyze result (from either the async job summary or the
10
10
  * legacy sync GET response) into the human-readable text block.
@@ -216,20 +216,46 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
216
216
  if (ext !== ".csv" && ext !== ".tsv") {
217
217
  throw new Error("Only .csv and .tsv files can be uploaded as datasets.");
218
218
  }
219
- const MAX_UPLOAD_BYTES = 256 * 1024 * 1024; // 256 MB (gzip keeps the wire payload small)
219
+ const HARD_MAX_BYTES = 5 * 1024 * 1024 * 1024; // 5 GB (R2 single-PUT limit)
220
+ let stat;
220
221
  try {
221
- const stat = await fs.stat(resolved);
222
- if (stat.size > MAX_UPLOAD_BYTES) {
223
- throw new Error(`File too large (${(stat.size / 1024 / 1024).toFixed(1)} MB). Maximum upload size is ${MAX_UPLOAD_BYTES / 1024 / 1024} MB.`);
224
- }
225
- body = await fs.readFile(resolved, "utf-8");
222
+ stat = await fs.stat(resolved);
226
223
  }
227
- catch (err) {
228
- if (err instanceof Error && err.message.includes("too large"))
229
- throw err;
224
+ catch {
230
225
  throw new Error(`File not accessible at resolved path. file_path is relative to workspace root. ` +
231
226
  `Set BARIVIA_WORKSPACE_ROOT in your MCP config env if needed (current: ${await getWorkspaceRootAsync(server)}).`);
232
227
  }
228
+ if (stat.size > HARD_MAX_BYTES) {
229
+ throw new Error(`File too large (${(stat.size / 1024 / 1024 / 1024).toFixed(2)} GB). Maximum upload size is 5 GB.`);
230
+ }
231
+ // Large files: stream gzip directly to R2 (presigned PUT), then stage async.
232
+ // Never reads the file into memory or sends it through the API/Cloudflare.
233
+ if (stat.size >= LARGE_UPLOAD_BYTES) {
234
+ const idem = await streamFileSha256(resolved);
235
+ const init = (await apiCall("POST", "/v1/datasets/upload-url", { name, size_bytes: stat.size }, { "Idempotency-Key": idem }));
236
+ const datasetId = (init.dataset_id ?? init.id);
237
+ if (init.idempotent_replay) {
238
+ return textResult({ id: datasetId, status: init.status, idempotent_replay: true,
239
+ suggested_next_step: `datasets(action=preview, dataset_id=${datasetId})` });
240
+ }
241
+ await putPresignedStream(init.upload_url, resolved, init.content_type ?? "application/octet-stream", PRESIGNED_PUT_TIMEOUT_MS);
242
+ const fin = (await apiCall("POST", `/v1/datasets/${datasetId}/finalize`, {}));
243
+ const jobId = (fin.id ?? fin.job_id);
244
+ const poll = await pollUntilComplete(jobId, POLL_STAGE_MAX_MS);
245
+ if (poll.status === "failed") {
246
+ return textResult({ id: datasetId, status: "failed", error: poll.error ?? "staging failed" });
247
+ }
248
+ const ready = poll.status === "completed";
249
+ return textResult({
250
+ id: datasetId,
251
+ status: ready ? "ready" : "staging",
252
+ job_id: jobId,
253
+ suggested_next_step: ready
254
+ ? `datasets(action=preview, dataset_id=${datasetId}) to inspect columns before training.`
255
+ : `Still staging; poll jobs(action=status, job_id="${jobId}") then datasets(action=preview, dataset_id=${datasetId}).`,
256
+ });
257
+ }
258
+ body = await fs.readFile(resolved, "utf-8");
233
259
  }
234
260
  else if (csv_data && csv_data.length > 0) {
235
261
  body = csv_data;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barivia/barsom-mcp",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "description": "barSOM MCP proxy — connect any MCP client to the barSOM cloud API for Self-Organizing Map analytics",
5
5
  "keywords": [
6
6
  "mcp",