@barivia/barsom-mcp 0.10.5 → 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/README.md CHANGED
@@ -42,7 +42,7 @@ MCP clients typically run it with **`npx`** (downloads on first use):
42
42
  | `BARIVIA_API_KEY` | Yes | -- | API key (starts with `bv_`) |
43
43
  | `BARIVIA_API_URL` | No | `https://api.barivia.se` | API base URL |
44
44
  | `BARIVIA_WORKSPACE_ROOT` | No | `process.cwd()` or `PWD` | Directory for relative `file_path` and `save_to_disk`. In Cursor MCP, `process.cwd()` is often the MCP install dir — add `BARIVIA_WORKSPACE_ROOT` to your MCP config `env` with your project path (e.g. `/home/user/myproject`). Absolute paths and `file://` URIs work without it. |
45
- | `BARIVIA_FETCH_TIMEOUT_MS` | No | `30000` | Per-request HTTP timeout (ms) to the API. Increase (e.g. `120000`) on slow networks or when the API does long-running work. Large dataset uploads use a separate longer timeout internally. |
45
+ | `BARIVIA_FETCH_TIMEOUT_MS` | No | `60000` | Per-request HTTP timeout (ms) to the API. Increase (e.g. `120000`) on slow networks or when the API does long-running work. Large dataset uploads use a separate longer timeout internally, and `datasets(analyze)` runs asynchronously (auto-polled) so it is not bound by this. A timeout is not auto-retried (re-firing slow work would only multiply load / risk duplicates). |
46
46
  | `BARIVIA_VIZ_PORT` | No | OS-assigned | When the client has no MCP Apps support, the proxy may start a local viz server on `127.0.0.1`; set a fixed port if you need stable bookmark URLs. |
47
47
  | `BARIVIA_ENFORCE_WORKSPACE_SANDBOX` | No | `1` (enabled) | File uploads are constrained to the MCP workspace root by default. Set to `0` or `false` to allow absolute paths anywhere on the machine (high trust). **Changed in v0.x:** previously defaulted to off; now on for security. If uploads fail with "paths outside the workspace are disabled", either set `BARIVIA_WORKSPACE_ROOT` to cover your data directory, or opt out with `BARIVIA_ENFORCE_WORKSPACE_SANDBOX=0`. |
48
48
 
@@ -203,7 +203,7 @@ MCP Client (Cursor/Claude) ←stdio→ @barivia/barsom-mcp ←HTTPS→ api.bariv
203
203
  | Symptom | What to check |
204
204
  |--------|----------------|
205
205
  | `401` / invalid key | `BARIVIA_API_KEY` in MCP config; check your email or manage your key in the [account dashboard](https://barivia.se/dashboard). Error text includes a **request id** for support. |
206
- | Request timed out | Raise `BARIVIA_FETCH_TIMEOUT_MS` (e.g. `120000`). Large uploads already use an extended timeout. |
206
+ | Request timed out | Raise `BARIVIA_FETCH_TIMEOUT_MS` (e.g. `120000`). Large uploads already use an extended timeout; `datasets(analyze)` is async (auto-polled). Timeouts are not auto-retried, so re-running a timed-out upload is safe — an idempotency key reconciles it to the original dataset instead of duplicating. |
207
207
  | `Path must be within the workspace` / upload can’t find file | Set `BARIVIA_WORKSPACE_ROOT` to your project directory, or use an absolute path / `file:///...` URI. |
208
208
  | Job stuck “running” | Poll `jobs(action=status)` every 10–15s; large grids or FLooP-SIOM can take several minutes—not an MCP error. |
209
209
  | `429` | Rate limit—wait and retry. |
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
1
  #!/usr/bin/env node
2
- import{McpServer as e}from"@modelcontextprotocol/sdk/server/mcp.js";import{StdioServerTransport as t}from"@modelcontextprotocol/sdk/server/stdio.js";import{z as o}from"zod";import{getUiCapability as r,registerAppResource as n,RESOURCE_MIME_TYPE as s}from"@modelcontextprotocol/ext-apps/server";import{startVizServer as a}from"./viz-server.js";import{API_KEY as i,apiCall as l,apiRawCall as p,loadViewHtml as c,setVizPort as m,setClientSupportsMcpApps as d,CLIENT_VERSION as u}from"./shared.js";import{registerDatasetsTool as f}from"./tools/datasets.js";import{registerJobsTool as g,JOBS_DESCRIPTION_BASE as _}from"./tools/jobs.js";import{registerResultsTool as b}from"./tools/results.js";import{registerExploreMapTool as h,RESULTS_EXPLORER_URI as y}from"./tools/explore_map.js";import{registerAccountTool as w}from"./tools/account.js";import{registerInferenceTool as j}from"./tools/inference.js";import{registerGuideBarsomTool as v}from"./tools/guide_barsom.js";import{registerTrainingGuidanceTool as P}from"./tools/training_guidance.js";import{registerFeedbackTool as x}from"./tools/feedback.js";import{registerTrainingPrepTools as I,TRAINING_PREP_URI as k}from"./tools/training_prep.js";import{registerTrainingMonitorTool as M,TRAINING_MONITOR_URI as O}from"./tools/training_monitor.js";import{resolvePrepareTrainingPromptText as S}from"./prepare_training_prompt.js";i||(console.error("Error: BARIVIA_API_KEY not set. Set it in your MCP client config."),process.exit(1));const A=new e({name:"analytics-engine",version:u,instructions:'# Barivia Mapping Analytics Engine\n\nSelf-organizing map (SOM) analytics: project high-dimensional data to a 2D grid for clusters, gradients, and anomalies.\n\n## Workflow (short)\n\nUpload (`datasets(upload)`) → `datasets(preview)` and `datasets(analyze)` before train → submit one of `jobs(train_map)`, `jobs(train_siom_map)`, or `jobs(train_floop_siom)` (only if plan allows FLooP) → poll `jobs(status)` every 10–15s until `completed` → `results(get)` for metrics and figures (there is no separate analyze tool). Then `jobs(compare)`, `results(download/recolor/transition_flow)`, or `inference` as needed.\n\n**Full detail:** Call `guide_barsom_workflow` for plan-scoped tool map, training modes, async rules, optional MCP App UIs, and step-by-step SOP (from the Barivia API when online).\n\n## Tool map (compact)\n\n| Area | Tool | Notes |\n|------|------|--------|\n| Data | `datasets` | upload, preview, analyze, list, subset, add_expression, reduce_spectral (pca/log_sample/uniform_sample/stats for long ordered numeric blocks), delete |\n| Jobs | `jobs` | train_map, train_impute (sparse data: map + dense imputed.csv), train_siom_map, train_floop_siom (entitled), status, list, compare, cancel, delete, batch_predict, run_baseline_study; `train_floop_chain` = deprecated alias for train_floop_siom |\n| Results | `results` | get (figures="none" for metrics-only), export, download, recolor (async), transition_flow (async; time-ordered rows only) |\n| Inference | `inference` | predict (regime-aware; output="compact"|"annotated"), impute_column (neighbor-pool fill for a non-training column), compare, project_columns, report |\n| Account | `account` | status, burst/compute actions, history, add_funds |\n| Bootstrap | `guide_barsom_workflow` | orientation + SOP |\n| Parameters | `training_guidance` | presets and field hints (API-scoped) |\n| Prep | `prepare_training` prompt, `training_prep` + `submit_prepared_training` | checklist / interactive UI |\n| Explore | `results_explorer`, `training_monitor` | optional MCP Apps; `explore_map` = deprecated alias of `results_explorer`; `jobs(status)` and `results(get)` suffice without them |\n| Other | `send_feedback` | only after user agrees |\n\n## Async pattern\n\n- **Manual poll:** Training submits return `job_id` immediately — poll `jobs(status)` every 10–15s. **Running is not failed**; large grids or FLooP-SIOM can take many minutes. `max_nodes` (FLooP) is a total node budget, not grid side length.\n- **Often auto-polled:** `inference` actions, `results(recolor)`, `results(transition_flow)` may wait in-proxy; if you get a `job_id`, poll `jobs(status)` the same way.\n\nCredits: jobs consume compute credits; check `account(status)` before big runs. Slow networks: users can raise `BARIVIA_FETCH_TIMEOUT_MS`.\n\n## Constraints\n\n- Prep ladder: `prepare_training` prompt = narrative checklist; `training_guidance` = structured hints; `training_prep` = UI + guarded submit. Do not guess tiers or FLooP entitlement.\n- `inference(predict)`: prefer `dataset_id` for batch and for SIOM/irregular maps; single-row `rows` uses a fast path that can fail on some topologies — retry with `dataset_id`. FLooP-SIOM: if predict jobs fail while grid SIOM works, capture errors + `job_id`.\n- Column names are case-sensitive — match `datasets(preview)`.\n- Default training path is numeric/cyclic/temporal; use explicit `categorical_features` for baseline categoricals. `predict` must match the model contract.\n- After `recolor`, `transition_flow`, or `project_columns`, use the **new** `job_id` returned for follow-up `results` if applicable.'});n(A,y,y,{mimeType:s},async()=>{const e=await c("results-explorer");return{contents:[{uri:y,mimeType:s,text:e??"<html><body>Results Explorer view not built yet. Run: npm run build:views</body></html>"}]}}),n(A,k,k,{mimeType:s},async()=>{const e=await c("training-prep");return{contents:[{uri:k,mimeType:s,text:e??"<html><body>Training Preparation view not built yet.</body></html>"}]}}),n(A,O,O,{mimeType:s},async()=>{const e=await c("training-monitor");return{contents:[{uri:O,mimeType:s,text:e??"<html><body>Training Monitor view not built yet.</body></html>"}]}}),v(A),h(A),I(A),M(A),f(A),g(A,_),b(A),w(A),j(A),P(A),x(A),A.prompt("info","Short orientation for the Barivia Mapping MCP. For full plan-scoped workflow, tool map, and SOP, the model should call guide_barsom_workflow. Use when the user asks what this MCP can do or how to get started.",{},()=>({messages:[{role:"user",content:{type:"text",text:["Give a concise, scannable answer (headers + bullets):","","**What it is:** MCP client to the Barivia mapping engine (2D SOM / SIOM / FLooP-SIOM when entitled) over HTTPS.","","**First step:** Call `guide_barsom_workflow` for plan-scoped bootstrap (full tool list, async rules, training modes, optional MCP Apps, SOP).","","**Core path:** `datasets(upload)` → `datasets(preview)` + `datasets(analyze)` → choose training action → poll `jobs(status)` every 10–15s until completed → `results(get)` (all main figures/metrics; no separate analyze tool).","",'**Key tools:** `datasets` (data; reduce_spectral for spectra/long blocks), `jobs` (train/poll/compare/…; train_map accepts an optional `label` for readable compare rows), `results` (get/download/export/recolor/transition_flow; figures="none" for metrics-only), `inference` (predict; impute_column for topology-neighbor pool fill; compare; project_columns; report), `account` (status/credits/queue).',"","**Prep help:** `prepare_training` prompt (checklist) · `training_guidance` (presets/JSON hints) · `training_prep` + `submit_prepared_training` (interactive UI).","","**Optional UI:** `results_explorer`, `training_monitor` — nice for browsing; not required if you use `results` + `jobs(status)`.","","**After training:** `jobs(compare)` across runs, `results(recolor)`, `inference(project_columns)` for variables not in training, `transition_flow` only if rows are time-ordered.","","**Rules:** Running ≠ failed. Column names must match `datasets(preview)` exactly. Do not call `_fetch_figure` from chat (host/UI only); use `results(get)` or `results_explorer`.","","Offer `send_feedback` only after asking the user."].join("\n")}}]})),A.prompt("prepare_training","Narrative pre-training checklist (prompt). Use after upload and before train. Content is tier-scoped from the API when online. Prep ladder: this prompt = story checklist; training_guidance tool = JSON presets/parameter hints; training_prep tool = interactive UI + submit_prepared_training.",{dataset_id:o.string().describe("Dataset ID to prepare for training")},async({dataset_id:e})=>({messages:[{role:"user",content:{type:"text",text:await S(e)}}]}));const T=new t;(async function(){try{const e=await a(l,p,c);m(e)}catch(e){process.env.BARIVIA_VIZ_PORT&&console.error("Barivia viz server failed to start:",e)}const e=A.server;e.oninitialized=()=>{const t=e.getClientCapabilities(),o=r(t);d(!!o?.mimeTypes?.includes(s))},await A.connect(T)})().catch(console.error);
2
+ import{McpServer as e}from"@modelcontextprotocol/sdk/server/mcp.js";import{StdioServerTransport as t}from"@modelcontextprotocol/sdk/server/stdio.js";import{z as o}from"zod";import{getUiCapability as r,registerAppResource as n,RESOURCE_MIME_TYPE as s}from"@modelcontextprotocol/ext-apps/server";import{startVizServer as a}from"./viz-server.js";import{API_KEY as i,apiCall as l,apiRawCall as p,loadViewHtml as c,setVizPort as m,setClientSupportsMcpApps as d,CLIENT_VERSION as u}from"./shared.js";import{registerDatasetsTool as f}from"./tools/datasets.js";import{registerJobsTool as g,JOBS_DESCRIPTION_BASE as _}from"./tools/jobs.js";import{registerResultsTool as b}from"./tools/results.js";import{registerExploreMapTool as h,RESULTS_EXPLORER_URI as y}from"./tools/explore_map.js";import{registerAccountTool as w}from"./tools/account.js";import{registerInferenceTool as j}from"./tools/inference.js";import{registerGuideBarsomTool as v}from"./tools/guide_barsom.js";import{registerTrainingGuidanceTool as P}from"./tools/training_guidance.js";import{registerFeedbackTool as x}from"./tools/feedback.js";import{registerTrainingPrepTools as I,TRAINING_PREP_URI as k}from"./tools/training_prep.js";import{registerTrainingMonitorTool as T,TRAINING_MONITOR_URI as M}from"./tools/training_monitor.js";import{resolvePrepareTrainingPromptText as O}from"./prepare_training_prompt.js";i||(console.error("Error: BARIVIA_API_KEY not set. Set it in your MCP client config."),process.exit(1));const A=new e({name:"analytics-engine",version:u,instructions:'# Barivia Mapping Analytics Engine\n\nSelf-organizing map (SOM) analytics: project high-dimensional data to a 2D grid for clusters, gradients, and anomalies.\n\n## Workflow (short)\n\nUpload (`datasets(upload)`) → `datasets(preview)` and `datasets(analyze)` before train → submit one of `jobs(train_map)`, `jobs(train_siom_map)`, or `jobs(train_floop_siom)` (only if plan allows FLooP) → poll `jobs(status)` every 10–15s until `completed` → `results(get)` for metrics and figures (there is no separate analyze tool). Then `jobs(compare)`, `results(download/recolor/transition_flow)`, or `inference` as needed.\n\n**Full detail:** Call `guide_barsom_workflow` for plan-scoped tool map, training modes, async rules, optional MCP App UIs, and step-by-step SOP (from the Barivia API when online).\n\n## Tool map (compact)\n\n| Area | Tool | Notes |\n|------|------|--------|\n| Data | `datasets` | upload, preview, analyze, list, subset, add_expression, reduce_spectral (pca/log_sample/uniform_sample/stats for long ordered numeric blocks), delete |\n| Jobs | `jobs` | train_map, train_impute (sparse data: map + dense imputed.csv), train_siom_map, train_floop_siom (entitled), status, list, compare, cancel, delete, batch_predict, run_baseline_study; `train_floop_chain` = deprecated alias for train_floop_siom |\n| Results | `results` | get (figures="none" for metrics-only), export, download, recolor (async), transition_flow (async; time-ordered rows only) |\n| Inference | `inference` | predict (regime-aware; output="compact"|"annotated"), impute_column (neighbor-pool fill for a non-training column), compare, project_columns, report |\n| Account | `account` | status, burst/compute actions, history, add_funds |\n| Bootstrap | `guide_barsom_workflow` | orientation + SOP |\n| Parameters | `training_guidance` | presets and field hints (API-scoped) |\n| Prep | `prepare_training` prompt, `training_prep` + `submit_prepared_training` | checklist / interactive UI |\n| Explore | `results_explorer`, `training_monitor` | optional MCP Apps; `explore_map` = deprecated alias of `results_explorer`; `jobs(status)` and `results(get)` suffice without them |\n| Other | `send_feedback` | only after user agrees |\n\n## Async pattern\n\n- **Manual poll:** Training submits return `job_id` immediately — poll `jobs(status)` every 10–15s. **Running is not failed**; large grids or FLooP-SIOM can take many minutes. `max_nodes` (FLooP) is a total node budget, not grid side length.\n- **Often auto-polled:** `inference` actions, `results(recolor)`, `results(transition_flow)` may wait in-proxy; if you get a `job_id`, poll `jobs(status)` the same way.\n\nCredits: jobs consume compute credits; check `account(status)` before big runs. Per-request HTTP timeout defaults to 60s (`datasets(analyze)` is async + auto-polled, so it is not bound by it); on slow networks raise `BARIVIA_FETCH_TIMEOUT_MS`. Timeouts are NOT auto-retried (avoids duplicating slow/expensive work).\n\n## Constraints\n\n- Prep ladder: `prepare_training` prompt = narrative checklist; `training_guidance` = structured hints; `training_prep` = UI + guarded submit. Do not guess tiers or FLooP entitlement.\n- `inference(predict)`: prefer `dataset_id` for batch and for SIOM/irregular maps; single-row `rows` uses a fast path that can fail on some topologies — retry with `dataset_id`. FLooP-SIOM: if predict jobs fail while grid SIOM works, capture errors + `job_id`.\n- Column names are case-sensitive — match `datasets(preview)`.\n- Default training path is numeric/cyclic/temporal; use explicit `categorical_features` for baseline categoricals. `predict` must match the model contract.\n- After `recolor`, `transition_flow`, or `project_columns`, use the **new** `job_id` returned for follow-up `results` if applicable.'});n(A,y,y,{mimeType:s},async()=>{const e=await c("results-explorer");return{contents:[{uri:y,mimeType:s,text:e??"<html><body>Results Explorer view not built yet. Run: npm run build:views</body></html>"}]}}),n(A,k,k,{mimeType:s},async()=>{const e=await c("training-prep");return{contents:[{uri:k,mimeType:s,text:e??"<html><body>Training Preparation view not built yet.</body></html>"}]}}),n(A,M,M,{mimeType:s},async()=>{const e=await c("training-monitor");return{contents:[{uri:M,mimeType:s,text:e??"<html><body>Training Monitor view not built yet.</body></html>"}]}}),v(A),h(A),I(A),T(A),f(A),g(A,_),b(A),w(A),j(A),P(A),x(A),A.prompt("info","Short orientation for the Barivia Mapping MCP. For full plan-scoped workflow, tool map, and SOP, the model should call guide_barsom_workflow. Use when the user asks what this MCP can do or how to get started.",{},()=>({messages:[{role:"user",content:{type:"text",text:["Give a concise, scannable answer (headers + bullets):","","**What it is:** MCP client to the Barivia mapping engine (2D SOM / SIOM / FLooP-SIOM when entitled) over HTTPS.","","**First step:** Call `guide_barsom_workflow` for plan-scoped bootstrap (full tool list, async rules, training modes, optional MCP Apps, SOP).","","**Core path:** `datasets(upload)` → `datasets(preview)` + `datasets(analyze)` → choose training action → poll `jobs(status)` every 10–15s until completed → `results(get)` (all main figures/metrics; no separate analyze tool).","",'**Key tools:** `datasets` (data; reduce_spectral for spectra/long blocks), `jobs` (train/poll/compare/…; train_map accepts an optional `label` for readable compare rows), `results` (get/download/export/recolor/transition_flow; figures="none" for metrics-only), `inference` (predict; impute_column for topology-neighbor pool fill; compare; project_columns; report), `account` (status/credits/queue).',"","**Prep help:** `prepare_training` prompt (checklist) · `training_guidance` (presets/JSON hints) · `training_prep` + `submit_prepared_training` (interactive UI).","","**Optional UI:** `results_explorer`, `training_monitor` — nice for browsing; not required if you use `results` + `jobs(status)`.","","**After training:** `jobs(compare)` across runs, `results(recolor)`, `inference(project_columns)` for variables not in training, `transition_flow` only if rows are time-ordered.","","**Rules:** Running ≠ failed. Column names must match `datasets(preview)` exactly. Do not call `_fetch_figure` from chat (host/UI only); use `results(get)` or `results_explorer`.","","Offer `send_feedback` only after asking the user."].join("\n")}}]})),A.prompt("prepare_training","Narrative pre-training checklist (prompt). Use after upload and before train. Content is tier-scoped from the API when online. Prep ladder: this prompt = story checklist; training_guidance tool = JSON presets/parameter hints; training_prep tool = interactive UI + submit_prepared_training.",{dataset_id:o.string().describe("Dataset ID to prepare for training")},async({dataset_id:e})=>({messages:[{role:"user",content:{type:"text",text:await O(e)}}]}));const S=new t;(async function(){try{const e=await a(l,p,c);m(e)}catch(e){process.env.BARIVIA_VIZ_PORT&&console.error("Barivia viz server failed to start:",e)}const e=A.server;e.oninitialized=()=>{const t=e.getClientCapabilities(),o=r(t);d(!!o?.mimeTypes?.includes(s))},await A.connect(S)})().catch(console.error);
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";
@@ -12,7 +16,7 @@ export const API_URL = process.env.BARIVIA_API_URL ??
12
16
  process.env.BARSOM_API_URL ??
13
17
  "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
  /**
@@ -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.10.5";
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
  // ---------------------------------------------------------------------------
@@ -52,12 +62,51 @@ export function setClientSupportsMcpApps(value) {
52
62
  export function isTransientError(err, status) {
53
63
  if (status !== undefined && RETRYABLE_STATUS.has(status))
54
64
  return true;
55
- if (err instanceof DOMException && err.name === "AbortError")
56
- return true;
65
+ // NOTE: a client-side timeout (AbortError) is deliberately NOT retried. Re-firing
66
+ // a slow/expensive request (e.g. analyze, large upload) while the server is still
67
+ // processing the first one only multiplies load and risks duplicates — the user
68
+ // should raise BARIVIA_FETCH_TIMEOUT_MS or use the async (job_id + poll) path.
57
69
  if (err instanceof TypeError)
58
70
  return true; // network-level fetch failure
59
71
  return false;
60
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
+ }
61
110
  export async function fetchWithTimeout(url, init, timeoutMs = FETCH_TIMEOUT_MS) {
62
111
  const controller = new AbortController();
63
112
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -1,9 +1,97 @@
1
1
  import path from "node:path";
2
2
  import fs from "node:fs/promises";
3
3
  import { gzipSync } from "node:zlib";
4
+ import { createHash } from "node:crypto";
4
5
  import { z } from "zod";
5
6
  import { registerAuditedTool } from "../audit.js";
6
- 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
+ /**
9
+ * Format a dataset analyze result (from either the async job summary or the
10
+ * legacy sync GET response) into the human-readable text block.
11
+ */
12
+ function formatAnalyzeResult(data, dataset_id) {
13
+ const totalRows = (data.total_rows ?? data.total_data_rows);
14
+ const analyzedRows = data.analyzed_rows;
15
+ const sampled = data.sampled === true;
16
+ const sampleNote = sampled && analyzedRows !== undefined && totalRows !== undefined
17
+ ? `Note: analyzed a ${analyzedRows.toLocaleString()}-row sample of ${totalRows.toLocaleString()} rows (subsampled for speed — correlations use an even spread across the table, periodicity uses the first rows).`
18
+ : "";
19
+ const note = data.note;
20
+ if (note) {
21
+ return [
22
+ `Dataset: ${data.name} (${data.dataset_id ?? dataset_id})`,
23
+ `${totalRows ?? "?"} rows.`,
24
+ sampleNote,
25
+ ``,
26
+ note,
27
+ ]
28
+ .filter(Boolean)
29
+ .join("\n");
30
+ }
31
+ const columns = data.columns ?? [];
32
+ const highPairs = data.high_correlation_pairs ?? [];
33
+ const uniqueness = data.uniqueness_scores ?? {};
34
+ const periodicity = data.periodicity ?? [];
35
+ const rec = data.recommendations ?? { train: [], consider_dropping: [], project_later: [], low_variance: [] };
36
+ const trainList = rec.train ?? [];
37
+ const considerDropping = rec.consider_dropping ?? [];
38
+ const projectLater = rec.project_later ?? [];
39
+ const lowVariance = rec.low_variance ?? [];
40
+ const periodicityCaveat = data.periodicity_caveat;
41
+ const lines = [
42
+ `Pre-training analysis: ${data.name} (${data.dataset_id ?? dataset_id})`,
43
+ `${totalRows ?? "?"} rows × ${columns.length} numeric columns analyzed`,
44
+ ...(sampleNote ? [sampleNote] : []),
45
+ ``,
46
+ ...(periodicityCaveat ? [`Note: ${periodicityCaveat}`, ``] : []),
47
+ `Column recommendations:`,
48
+ ` Train (use in jobs(action=train_map)): ${trainList.length ? trainList.join(", ") : "—"}`,
49
+ ` Consider dropping (highly correlated with another): ${considerDropping.length ? considerDropping.join(", ") : "—"}`,
50
+ ` Project later (after training, use inference(action=project_columns, job_id=<training_job_id>, dataset_id=<dataset_id>, columns=[...]) onto map): ${projectLater.length ? projectLater.join(", ") : "—"}`,
51
+ ` Low variance (near-constant): ${lowVariance.length ? lowVariance.join(", ") : "—"}`,
52
+ ``,
53
+ ];
54
+ if (highPairs.length > 0) {
55
+ lines.push(`High correlation pairs (|r| > 0.85):`);
56
+ for (const p of highPairs) {
57
+ lines.push(` • ${p.column_a} ↔ ${p.column_b}: r = ${p.correlation}`);
58
+ }
59
+ lines.push(``);
60
+ }
61
+ if (Object.keys(uniqueness).length > 0) {
62
+ lines.push(`Uniqueness score (1 − max|r| with others; higher = more unique):`);
63
+ for (const col of columns) {
64
+ const u = uniqueness[col];
65
+ if (u !== undefined)
66
+ lines.push(` • ${col}: ${u.toFixed(3)}`);
67
+ }
68
+ lines.push(``);
69
+ }
70
+ if (periodicity.length > 0) {
71
+ lines.push(`Periodicity ranking (by autocorrelation strength at lags 7, 12, 24, 52, 365):`);
72
+ for (const p of periodicity) {
73
+ lines.push(` • ${p.column}: dominant lag ${p.dominant_lag} (score ${p.dominant_score})`);
74
+ }
75
+ lines.push(``);
76
+ }
77
+ const nextSteps = [];
78
+ if (trainList.length > 0) {
79
+ nextSteps.push(`Train with columns: [${trainList.join(", ")}].`);
80
+ }
81
+ if (projectLater.length > 0) {
82
+ nextSteps.push(`After training, use inference(action=project_columns, job_id=<training_job_id>, dataset_id=${dataset_id}, columns=[${projectLater.map((c) => `"${c}"`).join(", ")}]) to project these onto the map.`);
83
+ }
84
+ const periodicStrong = periodicity.filter((p) => p.dominant_score > 0.4);
85
+ if (periodicStrong.length > 0) {
86
+ const lags = [...new Set(periodicStrong.map((p) => p.dominant_lag))].sort((a, b) => a - b);
87
+ nextSteps.push(`Note: periodicity is computed on row order. If your CSV is not sorted by time, ignore lag-based suggestions; only use cyclic_features when the column is genuinely cyclic (e.g. hour, month, angle).`);
88
+ nextSteps.push(`Columns show periodic behavior at lags ${lags.join(", ")} (assuming row order is meaningful). Consider cyclic_features or temporal_features with matching periods.`);
89
+ nextSteps.push(`For datetime columns: run datasets(action=preview) for temporal_suggestions; match lags (e.g. 365→day_of_year, 12→month, 7→day_of_week, 24→hour_of_day) to choose which temporal components to extract.`);
90
+ }
91
+ nextSteps.push(`Then call jobs(action=train_map, dataset_id=${dataset_id}, columns=[...], ...).`);
92
+ lines.push(`Next steps:`, ...nextSteps.map((s) => ` ${s}`));
93
+ return lines.join("\n");
94
+ }
7
95
  export function registerDatasetsTool(server) {
8
96
  registerAuditedTool(server, "datasets", `Manage datasets: upload, preview, list, subset, add_expression, or delete.
9
97
 
@@ -26,12 +114,13 @@ Step 1 (after upload): Upload, then always datasets(action=preview) to verify co
26
114
  action=preview: Show columns, stats, sample rows, cyclic/datetime detections. ALWAYS preview before jobs(action=train_map) on an unfamiliar dataset.
27
115
  action=analyze: Pre-training analysis on numeric columns — Pearson correlation, autocorrelation periodicity scores, and column recommendations (train, consider_dropping, project_later, low_variance). Use to choose which columns to include in training and which to project onto the map after training.
28
116
  action=list: List all datasets belonging to the organisation with id, name, rows, cols, created_at (use created_at or id to distinguish datasets with the same name).
29
- action=subset: Create a new dataset from a subset of an existing one. Requires name and at least one of row_range or filters.
117
+ action=subset: Create a new dataset from a subset of an existing one. Requires name and at least one of row_range, filters, or sample_n.
30
118
  - row_range: [start, end] 1-based inclusive (e.g. [1, 2000] for first 2000 rows)
31
119
  - filters: array of conditions, ALL must match (AND logic). Each: { column, op, value }.
32
120
  Operators: eq, ne, in, gt, lt, gte, lte, between
33
121
  Examples: { column: "region", op: "eq", value: "Europe" } | { column: "age", op: "between", value: [18, 65] }
34
- - Combine row_range + filters to slice both rows and values.
122
+ - sample_n: keep a random N-row sample (seeded via sample_seed, default 42; row order preserved). Shrink a huge table server-side instead of pre-sampling locally. Applied after row_range/filters; can also be used alone.
123
+ - Combine row_range + filters + sample_n to slice rows, values, then downsample.
35
124
  - Single filter object is also accepted (auto-wrapped).
36
125
  action=reduce_spectral: Run a pre-training reducer over an ordered block of numeric columns. All four methods produce one feature vector per row (rows in = rows out; only the column dimension is collapsed) and append derived columns to the dataset. Choose by data shape:
37
126
  - pca: top-k principal components — general first try when many columns are correlated (spectroscopy, gene panels, sensor arrays). Returns explained_variance_ratio.
@@ -104,7 +193,18 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
104
193
  .min(1)
105
194
  .optional()
106
195
  .describe("action=reduce_spectral: output dimensionality. Required for pca/log_sample/uniform_sample; ignored for stats."),
107
- }, async ({ action, name, file_path, csv_data, dataset_id, n_rows, row_range, filters, filter, expression, options, method, columns_block, k }) => {
196
+ sample_n: z
197
+ .number()
198
+ .int()
199
+ .min(1)
200
+ .optional()
201
+ .describe("action=subset: keep a random N-row sample (seeded, row order preserved). Use to shrink a huge table server-side instead of pre-sampling locally. Combine with row_range/filters, or use alone."),
202
+ sample_seed: z
203
+ .number()
204
+ .int()
205
+ .optional()
206
+ .describe("action=subset: RNG seed for sample_n (default 42; same seed + dataset reproduces the same sample)."),
207
+ }, async ({ action, name, file_path, csv_data, dataset_id, n_rows, row_range, filters, filter, expression, options, method, columns_block, k, sample_n, sample_seed }) => {
108
208
  if (action === "upload") {
109
209
  if (!name)
110
210
  throw new Error("datasets(upload) requires name");
@@ -116,20 +216,46 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
116
216
  if (ext !== ".csv" && ext !== ".tsv") {
117
217
  throw new Error("Only .csv and .tsv files can be uploaded as datasets.");
118
218
  }
119
- 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;
120
221
  try {
121
- const stat = await fs.stat(resolved);
122
- if (stat.size > MAX_UPLOAD_BYTES) {
123
- throw new Error(`File too large (${(stat.size / 1024 / 1024).toFixed(1)} MB). Maximum upload size is ${MAX_UPLOAD_BYTES / 1024 / 1024} MB.`);
124
- }
125
- body = await fs.readFile(resolved, "utf-8");
222
+ stat = await fs.stat(resolved);
126
223
  }
127
- catch (err) {
128
- if (err instanceof Error && err.message.includes("too large"))
129
- throw err;
224
+ catch {
130
225
  throw new Error(`File not accessible at resolved path. file_path is relative to workspace root. ` +
131
226
  `Set BARIVIA_WORKSPACE_ROOT in your MCP config env if needed (current: ${await getWorkspaceRootAsync(server)}).`);
132
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");
133
259
  }
134
260
  else if (csv_data && csv_data.length > 0) {
135
261
  body = csv_data;
@@ -143,6 +269,9 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
143
269
  const uploadHeaders = {
144
270
  "X-Dataset-Name": name,
145
271
  "Content-Type": "text/csv",
272
+ // Deterministic key so a timed-out retry of the SAME upload reconciles to
273
+ // the original dataset server-side instead of creating a duplicate.
274
+ "Idempotency-Key": createHash("sha256").update(`${name}\n`).update(body).digest("hex"),
146
275
  };
147
276
  let uploadBody = body;
148
277
  if (Buffer.byteLength(body, "utf-8") > GZIP_THRESHOLD) {
@@ -243,76 +372,34 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
243
372
  if (action === "analyze") {
244
373
  if (!dataset_id)
245
374
  throw new Error("datasets(analyze) requires dataset_id");
246
- const data = (await apiCall("GET", `/v1/datasets/${dataset_id}/analyze`));
247
- const note = data.note;
248
- if (note) {
249
- return { content: [{ type: "text", text: `Dataset: ${data.name} (${data.dataset_id})\n${data.total_rows} rows.\n\n${note}` }] };
250
- }
251
- const columns = data.columns ?? [];
252
- const highPairs = data.high_correlation_pairs ?? [];
253
- const uniqueness = data.uniqueness_scores ?? {};
254
- const periodicity = data.periodicity ?? [];
255
- const rec = data.recommendations ?? { train: [], consider_dropping: [], project_later: [], low_variance: [] };
256
- const trainList = rec.train ?? [];
257
- const considerDropping = rec.consider_dropping ?? [];
258
- const projectLater = rec.project_later ?? [];
259
- const lowVariance = rec.low_variance ?? [];
260
- const periodicityCaveat = data.periodicity_caveat;
261
- const lines = [
262
- `Pre-training analysis: ${data.name} (${data.dataset_id})`,
263
- `${data.total_rows} rows × ${columns.length} numeric columns analyzed`,
264
- ``,
265
- ...(periodicityCaveat ? [`Note: ${periodicityCaveat}`, ``] : []),
266
- `Column recommendations:`,
267
- ` Train (use in jobs(action=train_map)): ${trainList.length ? trainList.join(", ") : "—"}`,
268
- ` Consider dropping (highly correlated with another): ${considerDropping.length ? considerDropping.join(", ") : "—"}`,
269
- ` Project later (after training, use inference(action=project_columns, job_id=<training_job_id>, dataset_id=<dataset_id>, columns=[...]) onto map): ${projectLater.length ? projectLater.join(", ") : "—"}`,
270
- ` Low variance (near-constant): ${lowVariance.length ? lowVariance.join(", ") : "—"}`,
271
- ``,
272
- ];
273
- if (highPairs.length > 0) {
274
- lines.push(`High correlation pairs (|r| > 0.85):`);
275
- for (const p of highPairs) {
276
- lines.push(` • ${p.column_a} ↔ ${p.column_b}: r = ${p.correlation}`);
375
+ // Async path: enqueue an analyze job, poll, then fetch results. The proxy
376
+ // auto-polls so the caller still issues a single tool call. Falls back to
377
+ // the legacy synchronous GET on older APIs that lack the POST route (404).
378
+ let data;
379
+ try {
380
+ const submit = (await apiCall("POST", `/v1/datasets/${dataset_id}/analyze`));
381
+ const jobId = (submit.id ?? submit.job_id);
382
+ if (!jobId)
383
+ throw new Error("analyze did not return a job id");
384
+ const poll = await pollUntilComplete(jobId, POLL_DERIVE_MAX_MS);
385
+ if (poll.status === "failed") {
386
+ return { content: [{ type: "text", text: `datasets(analyze) job ${jobId} failed: ${poll.error ?? "unknown error"}` }] };
277
387
  }
278
- lines.push(``);
279
- }
280
- if (Object.keys(uniqueness).length > 0) {
281
- lines.push(`Uniqueness score (1 − max|r| with others; higher = more unique):`);
282
- for (const col of columns) {
283
- const u = uniqueness[col];
284
- if (u !== undefined)
285
- lines.push(` • ${col}: ${u.toFixed(3)}`);
388
+ if (poll.status !== "completed") {
389
+ return { content: [{ type: "text", text: `datasets(analyze) job ${jobId} is still running. Poll with jobs(action=status, job_id="${jobId}") then results(action=get, job_id="${jobId}").` }] };
286
390
  }
287
- lines.push(``);
391
+ const results = (await apiCall("GET", `/v1/results/${jobId}`));
392
+ data = (results.summary ?? results);
288
393
  }
289
- if (periodicity.length > 0) {
290
- lines.push(`Periodicity ranking (by autocorrelation strength at lags 7, 12, 24, 52, 365):`);
291
- for (const p of periodicity) {
292
- const col = p.column;
293
- const dominantLag = p.dominant_lag;
294
- const score = p.dominant_score;
295
- lines.push(` • ${col}: dominant lag ${dominantLag} (score ${score})`);
394
+ catch (err) {
395
+ if (err?.httpStatus === 404) {
396
+ data = (await apiCall("GET", `/v1/datasets/${dataset_id}/analyze`));
397
+ }
398
+ else {
399
+ throw err;
296
400
  }
297
- lines.push(``);
298
- }
299
- const nextSteps = [];
300
- if (trainList.length > 0) {
301
- nextSteps.push(`Train with columns: [${trainList.join(", ")}].`);
302
- }
303
- if (projectLater.length > 0) {
304
- nextSteps.push(`After training, use inference(action=project_columns, job_id=<training_job_id>, dataset_id=${dataset_id}, columns=[${projectLater.map((c) => `"${c}"`).join(", ")}]) to project these onto the map.`);
305
- }
306
- const periodicStrong = periodicity.filter((p) => p.dominant_score > 0.4);
307
- if (periodicStrong.length > 0) {
308
- const lags = [...new Set(periodicStrong.map((p) => p.dominant_lag))].sort((a, b) => a - b);
309
- nextSteps.push(`Note: periodicity is computed on row order. If your CSV is not sorted by time, ignore lag-based suggestions; only use cyclic_features when the column is genuinely cyclic (e.g. hour, month, angle).`);
310
- nextSteps.push(`Columns show periodic behavior at lags ${lags.join(", ")} (assuming row order is meaningful). Consider cyclic_features or temporal_features with matching periods.`);
311
- nextSteps.push(`For datetime columns: run datasets(action=preview) for temporal_suggestions; match lags (e.g. 365→day_of_year, 12→month, 7→day_of_week, 24→hour_of_day) to choose which temporal components to extract.`);
312
401
  }
313
- nextSteps.push(`Then call jobs(action=train_map, dataset_id=${dataset_id}, columns=[...], ...).`);
314
- lines.push(`Next steps:`, ...nextSteps.map((s) => ` ${s}`));
315
- return { content: [{ type: "text", text: lines.join("\n") }] };
402
+ return { content: [{ type: "text", text: formatAnalyzeResult(data, dataset_id) }] };
316
403
  }
317
404
  if (action === "add_expression") {
318
405
  if (!dataset_id)
@@ -411,14 +498,18 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
411
498
  if (!name)
412
499
  throw new Error("datasets(subset) requires name");
413
500
  const allFilters = filters ?? (filter ? [filter] : undefined);
414
- if (row_range === undefined && allFilters === undefined) {
415
- throw new Error("datasets(subset) requires at least one of row_range or filters");
501
+ if (row_range === undefined && allFilters === undefined && sample_n === undefined) {
502
+ throw new Error("datasets(subset) requires at least one of row_range, filters, or sample_n");
416
503
  }
417
504
  const body = { name };
418
505
  if (row_range !== undefined)
419
506
  body.row_range = row_range;
420
507
  if (allFilters !== undefined)
421
508
  body.filters = allFilters;
509
+ if (sample_n !== undefined)
510
+ body.sample_n = sample_n;
511
+ if (sample_seed !== undefined)
512
+ body.sample_seed = sample_seed;
422
513
  const data = await apiCall("POST", `/v1/datasets/${dataset_id}/subset`, body);
423
514
  return textResult(data);
424
515
  }
@@ -2,9 +2,21 @@ 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 (max 1400 characters, ~190 words). Use when the user has suggestions, ran into issues, or wants something improved. Do NOT call without asking the user first — but after any group of actions or downloading of results, you SHOULD prepare some 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.`, { feedback: z.string().max(1400).describe("Feedback text (max 1400 characters)") }, async ({ feedback }) => {
5
+ registerAuditedTool(server, "send_feedback", `Send feedback or feature requests to Barivia developers. Use when the user has suggestions, ran into issues, or wants something improved. Do NOT call without asking the user first — but after any group of actions or downloading of results, 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
+
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
+ feedback: z.string().max(1400).optional().describe("Single feedback note (max 1400 characters). Use feedback_items instead for a multi-part report."),
9
+ feedback_items: z.array(z.string().max(1400)).max(10).optional().describe("Several feedback instances (each max 1400 characters, up to 10), stored together as one batch. Prefer this for a detailed issue: split it into focused parts (symptoms, reproduction, environment, asks)."),
10
+ }, async ({ feedback, feedback_items }) => {
11
+ const items = (feedback_items ?? []).map((s) => s.trim()).filter((s) => s.length > 0);
12
+ if (feedback && feedback.trim().length > 0)
13
+ items.unshift(feedback.trim());
14
+ if (items.length === 0) {
15
+ throw new Error("send_feedback requires feedback or feedback_items (at least one non-empty entry).");
16
+ }
17
+ const body = items.length === 1 ? { feedback: items[0] } : { feedback_items: items };
6
18
  try {
7
- const data = await apiCall("POST", "/v1/feedback", { feedback });
19
+ const data = await apiCall("POST", "/v1/feedback", body);
8
20
  return textResult(data);
9
21
  }
10
22
  catch (err) {
@@ -14,7 +26,7 @@ export function registerFeedbackTool(server) {
14
26
  const resp = await fetchWithTimeout(url, {
15
27
  method: "POST",
16
28
  headers: { "Content-Type": "application/json" },
17
- body: JSON.stringify({ feedback }),
29
+ body: JSON.stringify(body),
18
30
  });
19
31
  const text = await resp.text();
20
32
  if (resp.ok) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barivia/barsom-mcp",
3
- "version": "0.10.5",
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",