@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 +1 -1
- package/dist/shared.js +45 -10
- package/dist/tools/datasets.js +28 -5
- package/package.json +1 -1
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.
|
|
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
|
|
85
|
-
*
|
|
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
|
-
|
|
89
|
-
|
|
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
|
|
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) {
|
package/dist/tools/datasets.js
CHANGED
|
@@ -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
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
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) {
|