@barivia/barsom-mcp 0.14.0 → 0.15.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
@@ -86,7 +86,7 @@ Call at the **start of mapping work** (or when the user asks what the MCP can do
86
86
 
87
87
  | Action | Use when |
88
88
  |--------|----------|
89
- | `upload` | Adding a new CSV — returns dataset_id |
89
+ | `upload` | Adding a new CSV — returns dataset_id. Accepts `.csv`/`.tsv` **and pre-gzipped `.csv.gz`/`.tsv.gz`** (compress big tables locally to transfer ~3× less). Large files stream directly to object storage; multi-GB uploads are supported. |
90
90
  | `preview` | Before training — inspect columns, stats, cyclic/datetime hints |
91
91
  | `analyze` | Pre-training recommendations (correlation, columns to consider dropping, etc.) |
92
92
  | `list` | Finding dataset IDs |
package/dist/shared.js CHANGED
@@ -2,10 +2,12 @@
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";
5
+ import { createReadStream, createWriteStream } from "node:fs";
6
6
  import { createGzip } from "node:zlib";
7
- import { createHash } from "node:crypto";
7
+ import { createHash, randomUUID } from "node:crypto";
8
8
  import { Readable } from "node:stream";
9
+ import { pipeline } from "node:stream/promises";
10
+ import os from "node:os";
9
11
  import path from "node:path";
10
12
  import { fileURLToPath } from "node:url";
11
13
  import { logInfo } from "./logger.js";
@@ -24,7 +26,7 @@ export const RETRYABLE_STATUS = new Set([502, 503, 504]);
24
26
  * X-Barsom-Client-Version so the server can annotate tool guidance with the
25
27
  * wrapper version each action requires. Keep in sync with package.json on bump.
26
28
  */
27
- export const CLIENT_VERSION = "0.14.0";
29
+ export const CLIENT_VERSION = "0.15.0";
28
30
  /** User-facing links; keep aligned with barivia.se / api.barivia.se. */
29
31
  export const PUBLIC_SITE_ORIGIN = "https://barivia.se";
30
32
  /** Self-serve account dashboard (manage plan, billing, and API keys). */
@@ -80,31 +82,64 @@ export async function streamFileSha256(srcPath) {
80
82
  s.on("error", reject);
81
83
  });
82
84
  }
85
+ /** Turn a raw S3/R2 presigned-PUT error into a clean, actionable message. */
86
+ function presignedPutError(status, bodyText) {
87
+ const snippet = bodyText.slice(0, 200);
88
+ if (status === 411 || /MissingContentLength/i.test(bodyText)) {
89
+ // Should no longer happen now that we always send Content-Length, but keep a
90
+ // clear message instead of leaking raw S3 XML if a CDN strips the header.
91
+ return new Error("Upload rejected by storage: the Content-Length header was missing. " +
92
+ "This is a client bug — please update @barivia/barsom-mcp to the latest version.");
93
+ }
94
+ if (status === 413 || /EntityTooLarge|entity is too large/i.test(bodyText)) {
95
+ return new Error("Upload rejected by storage: file exceeds the maximum upload size.");
96
+ }
97
+ if (status === 403) {
98
+ return new Error("Upload rejected by storage: the presigned URL expired or was invalid; retry datasets(action=upload).");
99
+ }
100
+ return new Error(`Presigned upload failed: HTTP ${status} ${snippet}`);
101
+ }
83
102
  /**
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.
103
+ * Stream a local file directly to a presigned PUT URL (e.g. R2), gzip-compressing
104
+ * on the way unless the source is already gzipped. Never materializes the payload
105
+ * in process memory: when compression is needed we gzip to a temp file first, then
106
+ * PUT that file with an explicit Content-Length.
107
+ *
108
+ * R2/S3 presigned PUT requires Content-Length and rejects chunked transfer-encoding
109
+ * with HTTP 411 (MissingContentLength). Node's fetch sends a streaming body as
110
+ * chunked unless an explicit Content-Length is set, and gzip output length is not
111
+ * known until compression finishes — hence the gzip-to-temp-file step.
86
112
  */
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);
113
+ export async function putPresignedStream(url, srcPath, contentType, timeoutMs = PRESIGNED_PUT_TIMEOUT_MS, alreadyGzipped = false) {
114
+ let putPath = srcPath;
115
+ let tempPath;
116
+ if (!alreadyGzipped) {
117
+ tempPath = path.join(os.tmpdir(), `barsom-upload-${randomUUID()}.csv.gz`);
118
+ await pipeline(createReadStream(srcPath), createGzip(), createWriteStream(tempPath));
119
+ putPath = tempPath;
120
+ }
90
121
  const controller = new AbortController();
91
122
  const timer = setTimeout(() => controller.abort(), timeoutMs);
92
123
  try {
124
+ const contentLength = (await fs.stat(putPath)).size;
125
+ const webStream = Readable.toWeb(createReadStream(putPath));
93
126
  const resp = await fetch(url, {
94
127
  method: "PUT",
95
128
  body: webStream,
96
- headers: { "Content-Type": contentType },
129
+ headers: { "Content-Type": contentType, "Content-Length": String(contentLength) },
97
130
  // Required by Node's fetch to send a streaming request body.
98
131
  duplex: "half",
99
132
  signal: controller.signal,
100
133
  });
101
134
  if (!resp.ok) {
102
135
  const t = await resp.text().catch(() => "");
103
- throw new Error(`Presigned upload failed: HTTP ${resp.status} ${t.slice(0, 200)}`);
136
+ throw presignedPutError(resp.status, t);
104
137
  }
105
138
  }
106
139
  finally {
107
140
  clearTimeout(timer);
141
+ if (tempPath)
142
+ await fs.rm(tempPath, { force: true }).catch(() => { });
108
143
  }
109
144
  }
110
145
  export async function fetchWithTimeout(url, init, timeoutMs = FETCH_TIMEOUT_MS) {
@@ -224,9 +224,14 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
224
224
  if (file_path) {
225
225
  await apiCall("GET", "/v1/system/info");
226
226
  const resolved = await resolveFilePathForUpload(file_path, server);
227
- const ext = path.extname(resolved).toLowerCase();
228
- if (ext !== ".csv" && ext !== ".tsv") {
229
- throw new Error("Only .csv and .tsv files can be uploaded as datasets.");
227
+ const lower = resolved.toLowerCase();
228
+ // Accept pre-gzipped CSV/TSV (.csv.gz / .tsv.gz) so large tables transfer
229
+ // ~3x smaller. Already-gzipped files are stored as-is (the platform stores
230
+ // every dataset gzipped anyway); plain files are gzipped on the way up.
231
+ const isGzipInput = lower.endsWith(".gz");
232
+ const baseExt = path.extname(isGzipInput ? lower.slice(0, -3) : lower);
233
+ if (baseExt !== ".csv" && baseExt !== ".tsv") {
234
+ throw new Error("Only .csv, .tsv, .csv.gz, or .tsv.gz files can be uploaded as datasets.");
230
235
  }
231
236
  const HARD_MAX_BYTES = 5 * 1024 * 1024 * 1024; // 5 GB (R2 single-PUT limit)
232
237
  let stat;
@@ -246,11 +251,14 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
246
251
  const idem = await streamFileSha256(resolved);
247
252
  const init = (await apiCall("POST", "/v1/datasets/upload-url", { name, size_bytes: stat.size }, { "Idempotency-Key": idem }));
248
253
  const datasetId = (init.dataset_id ?? init.id);
249
- if (init.idempotent_replay) {
254
+ // Only short-circuit when the prior upload actually completed. A stale
255
+ // `pending_upload` row (a previous PUT that never landed) comes back with
256
+ // a fresh upload_url and no idempotent_replay flag, so we re-PUT below.
257
+ if (init.idempotent_replay && !init.upload_url) {
250
258
  return textResult({ id: datasetId, status: init.status, idempotent_replay: true,
251
259
  suggested_next_step: `datasets(action=preview, dataset_id=${datasetId})` });
252
260
  }
253
- await putPresignedStream(init.upload_url, resolved, init.content_type ?? "application/octet-stream", PRESIGNED_PUT_TIMEOUT_MS);
261
+ await putPresignedStream(init.upload_url, resolved, init.content_type ?? "application/octet-stream", PRESIGNED_PUT_TIMEOUT_MS, isGzipInput);
254
262
  const fin = (await apiCall("POST", `/v1/datasets/${datasetId}/finalize`, {}));
255
263
  const jobId = (fin.id ?? fin.job_id);
256
264
  const poll = await pollUntilComplete(jobId, POLL_STAGE_MAX_MS);
@@ -267,6 +275,21 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
267
275
  : `Still staging; poll jobs(action=status, job_id="${jobId}") then datasets(action=preview, dataset_id=${datasetId}).`,
268
276
  });
269
277
  }
278
+ // Small pre-gzipped files: send the bytes as-is (the API decompresses
279
+ // Content-Encoding: gzip), never decoding them as UTF-8 text.
280
+ if (isGzipInput) {
281
+ const gzBytes = await fs.readFile(resolved);
282
+ const data = (await apiCall("POST", "/v1/datasets", gzBytes, {
283
+ "X-Dataset-Name": name,
284
+ "Content-Type": "text/csv",
285
+ "Content-Encoding": "gzip",
286
+ "Idempotency-Key": createHash("sha256").update(`${name}\n`).update(gzBytes).digest("hex"),
287
+ }, UPLOAD_DATASET_TIMEOUT_MS));
288
+ const gid = data.id ?? data.dataset_id;
289
+ if (gid != null)
290
+ data.suggested_next_step = `Suggested next step: datasets(action=preview, dataset_id=${gid}) to inspect columns before training.`;
291
+ return textResult(data);
292
+ }
270
293
  body = await fs.readFile(resolved, "utf-8");
271
294
  }
272
295
  else if (csv_data && csv_data.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barivia/barsom-mcp",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
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",