From b7b90608e472366109153484ea19eefafe5a278f Mon Sep 17 00:00:00 2001 From: YPARK Date: Tue, 20 Oct 2026 14:52:21 -0700 Subject: [PATCH 1/4] MCP write tools run in the background on request; legume-mcp 0.3; version 0.11.0 qc (with out), import, subset, subsample, split, convert and merge take `background`: the step runs as a legume-mcp job, and the call answers at once with the job and the paths it will write; job_status, job_wait and job_cancel (from legume-mcp) follow it. A job is recorded in runall.sh and INDEX.tsv when it ends, like any other step. Outputs being written, by a running call or a job, are claimed until the call ends (for a job, while the server runs): an unnamed output takes the next free name and a named one is refused, so no two writers share a file. A subset's names file outlives the call for its job; an import's input must exist before its provenance is noted; qc refuses `background` on a dry run. The watch pane opens a job's output once it exists. --- Cargo.toml | 6 +- README.md | 7 +- src/mcp.rs | 179 ++++++++++++++++++++++++++++++++++++++------ tests/mcp_server.rs | 113 ++++++++++++++++++++++++++++ 4 files changed, 277 insertions(+), 28 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 2056ee6..df52d66 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ edition = "2021" readme = "README.md" # Own version (decoupled from workspace.version): data-beans evolves its CLI / # QC API independently of the shared utility crates. -version = "0.10.0" +version = "0.11.0" rust-version = "1.91" # Ship only the sources, README and license: nothing else in the working tree # (data stores, saved name lists, scratch files) can end up on crates.io. @@ -97,7 +97,7 @@ ratatui = { version = "0.30", default-features = false, features = ["crossterm"] # Detachable sessions for the views; no pictures, so no graphics. legume-tui = { version = "0.3.2", optional = true, default-features = false, features = ["clap", "logs"] } # Its `tui` view (`data-beans mcp watch`) comes on with data-beans' `tui`. -legume-mcp = { version = "0.1", optional = true } +legume-mcp = { version = "0.3", optional = true } legume-plot = { version = "0.3.2", optional = true } base64 = { version = "0.22", optional = true } @@ -106,5 +106,5 @@ base64 = { version = "0.22", optional = true } # tests/detach_session.rs. legume-tui = { version = "0.3.2", default-features = false, features = ["testing"] } # tests/mcp_server.rs follows a server through the watch pane. -legume-mcp = { version = "0.1", features = ["tui"] } +legume-mcp = { version = "0.3", features = ["tui"] } ratatui = { version = "0.30", default-features = false, features = ["crossterm"] } diff --git a/README.md b/README.md index 3012217..054c34f 100644 --- a/README.md +++ b/README.md @@ -101,11 +101,13 @@ Look before you derive: Quality control: qc runs the cell QC senna and pinto apply (a near-empty floor, then robust MAD bands on detected genes, total counts and the mito fraction). Call it without `out` first: it reports each metric's median, the band and how many cells fall outside, and writes nothing. Adjust n_mads or the caps until the drops are what the data warrant, then call it with `out` to write the kept cells as a new backend. plot shows the same settings as a figure (histograms with the band edges, counts against genes by verdict), so look at it before and after tuning. -Derive new backends with import (mtx, zarr triplets, fragments, h5ad, 10x; say in `source` where the input came from), subset, subsample, split, convert and merge. Each writes to `out`, which must not exist yet: no tool replaces a file. Leave `out` out and the output goes in today's results folder, named after its input and the tool. Outputs are .zarr.zip archives unless `out` ends in .zarr (a directory) or .h5. Paths are resolved against the server's working directory; results give the paths written. +Derive new backends with import (mtx, zarr triplets, fragments, h5ad, 10x; say in `source` where the input came from), subset, subsample, split, convert and merge. Each writes to `out`, which must not exist yet: no tool replaces a file. Leave `out` out and the output goes in today's results folder, named after its input and the tool. Outputs are .zarr.zip archives unless `out` ends in .zarr (a directory) or .h5. Paths are resolved against the server's working directory; results give the paths written. On a big store, pass `background` to these (and to qc with `out`): the call returns a job at once, with the paths it will write. A person may be watching (`data-beans mcp watch`): a "Note from the user" in a result is theirs to you; take it into account. A call they stopped fails with "stopped by the user": do not run it again unasked. ``` +After these, [legume-mcp](https://crates.io/crates/legume-mcp) adds its own notes: where the records go (below), and how to follow a background job. + ### Tools | Tool | What it does | Writes | @@ -124,6 +126,9 @@ A person may be watching (`data-beans mcp watch`): a "Note from the user" in a r | `convert` | zarr ↔ h5, `.zarr` ↔ `.zarr.zip` | a backend | | `merge` | backends side by side, batch-labelled; `common_rows` keeps the rows all share | a backend and its `.batch.gz` | | `notebook` | the agent's plan, observations and conclusions (from legume-mcp) | the day's `notebook.md` | +| `job_status`, `job_wait`, `job_cancel` | follow or stop a step started with `background` (from legume-mcp) | | + +Every tool that writes a backend (`qc` with `out`, `import`, `subset`, `subsample`, `split`, `convert`, `merge`) takes `background`: it returns at once with the `job` and the paths it will write (`written`, and merge's `batches`, qc's `report`). Wait for `job_wait` to say `done` before using them: a `.zarr` or `.h5` is there, half written, while the job runs. What the command printed (qc's medians and bands, say) is the job's `output`. Until the job ends, its outputs are no other call's to write. No tool replaces a file: an output that is an input, or anything named after the output beside it, is refused, and `squeeze`, which rewrites its input, is not offered. diff --git a/src/mcp.rs b/src/mcp.rs index 13af0be..11f7b38 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -29,6 +29,7 @@ use legume_mcp::{Call, Content, Handler, ServerInfo, ToolSpec}; use serde_json::{json, Map, Value}; use std::path::{Path, PathBuf}; use std::process::Command; +use std::sync::Mutex; #[derive(Args, Debug)] pub struct McpArgs { @@ -84,7 +85,9 @@ convert and merge. Each writes to `out`, which must not exist yet: no tool \ replaces a file. Leave `out` out and the output goes in today's results \ folder, named after its input and the tool. Outputs are .zarr.zip archives \ unless `out` ends in .zarr (a directory) or .h5. Paths are resolved against \ -the server's working directory; results give the paths written. +the server's working directory; results give the paths written. On a big \ +store, pass `background` to these (and to qc with `out`): the call returns \ +a job at once, with the paths it will write. A person may be watching (`data-beans mcp watch`): a \"Note from the user\" \ in a result is theirs to you; take it into account. A call they stopped \ @@ -136,6 +139,10 @@ fn tools() -> Vec { "type": "array", "items": { "type": "string" }, "description": "Further command-line options, as `data-beans --help` lists them" }); + let background = json!({ + "type": "boolean", + "description": "Run in the background: the job's id comes back at once, and job_status / job_wait follow it (for big stores)" + }); let limit = json!({ "type": "integer", "minimum": 1, "maximum": MAX_LIMIT, "description": "Entries to return (default 100)" }); vec![ ToolSpec::new( @@ -200,6 +207,7 @@ fn tools() -> Vec { let mut p = qc_properties(&data); p["report"] = json!({ "type": "string", "description": "A .tsv(.gz) for the per-cell metrics and verdicts; must not exist" }); p["out"] = json!({ "type": "string", "description": "Output for the kept cells; must not exist. Omit for a dry run" }); + p["background"] = background.clone(); p }, &["data"], @@ -229,7 +237,7 @@ fn tools() -> Vec { "command": { "type": "string", "enum": import_commands() }, "input": { "type": "string" }, "source": { "type": "string", "description": "Where the input came from (a URL or accession, and its version), kept in the project's data/README.md" }, - "out": out, "options": options, + "out": out, "options": options, "background": background, }), &["command", "input"], ), @@ -247,7 +255,7 @@ fn tools() -> Vec { { "type": "string" } ] }, "prefix_match": { "type": "boolean" }, - "out": out, "options": options, + "out": out, "options": options, "background": background, }), &["data", "axis"], ), @@ -263,7 +271,7 @@ fn tools() -> Vec { "genes": { "type": "integer", "minimum": 1 }, "gene_frac": { "type": "number" }, "seed": { "type": "integer", "minimum": 0 }, - "out": out, + "out": out, "background": background, }), &["data"], ), @@ -278,7 +286,7 @@ fn tools() -> Vec { "folds": { "type": "integer", "minimum": 2 }, "seed": { "type": "integer", "minimum": 0 }, "out": { "type": "string", "description": "Output prefix; nothing named {out}.train*, {out}.test* or {out}.fold* may exist. Omitted, a new prefix in today's results folder" }, - "options": options, + "options": options, "background": background, }), &["data"], ), @@ -286,7 +294,10 @@ fn tools() -> Vec { ToolSpec::new( "convert", "Copy a backend into another format: zarr <-> h5, .zarr <-> .zarr.zip; the format follows `out`.", - schema(json!({ "data": data, "out": out }), &["data"]), + schema( + json!({ "data": data, "out": out, "background": background }), + &["data"], + ), ), ToolSpec::new( "merge", @@ -295,7 +306,7 @@ fn tools() -> Vec { json!({ "data": { "type": "array", "items": { "type": "string" }, "minItems": 2 }, "common_rows": { "type": "boolean" }, - "out": out, "options": options, + "out": out, "options": options, "background": background, }), &["data"], ), @@ -321,7 +332,10 @@ fn written_backend(text: &str) -> Option { .ok()?; let w = &v["written"]; let path = w.as_str().or_else(|| w.get(0)?.as_str())?; - (strip_backend_suffix(path) != path).then(|| path.to_string()) + // A job's output is not there until it is done; a .zarr.zip only + // appears then, but a .zarr or .h5 is there while it is written. + let done = v.get("job").is_none() || Path::new(path).exists(); + (strip_backend_suffix(path) != path && done).then(|| path.to_string()) } pub fn run_mcp(args: &McpArgs) -> Result<()> { @@ -363,14 +377,21 @@ impl Handler for DataBeans { let mut argv = argv(["subsample", &data]); argv.extend(a.numbers(&["cells", "cell_frac", "genes", "gene_frac", "seed"])?); let what = format!("cells and genes drawn at random from {data}"); - written(cx, argv, &out, &what, &[data])? + written(cx, a.flag("background")?, argv, &out, &what, &[data])? } "split" => split(&a, cx)?, "convert" => { let data = a.path("data")?; let out = a.backend_out(cx, &named(&data, "converted"))?; let what = format!("{data} in another format"); - written(cx, argv(["convert", &data]), &out, &what, &[data])? + written( + cx, + a.flag("background")?, + argv(["convert", &data]), + &out, + &what, + &[data], + )? } "merge" => merge(&a, cx)?, _ => anyhow::bail!("no tool {name}"), @@ -496,7 +517,9 @@ impl Arguments { fn backend_out(&self, cx: &Call, name: &str) -> Result { self.out_or(cx, (name, ""), |c| { backend_target(c).map_or(true, |t| { - outputs_beside(strip_backend_suffix(&t.effective)).map_or(true, |v| !v.is_empty()) + let stem = strip_backend_suffix(&t.effective); + being_written(stem).is_some() + || outputs_beside(stem).map_or(true, |v| !v.is_empty()) }) }) } @@ -581,6 +604,24 @@ fn data_beans_making( cx.run_making(command(argv)?, &outputs, what) } +/// [`data_beans_making`] as a background job: its id comes back at once, +/// with `written`, where its output will be once it is done. +fn data_beans_job( + cx: &Call, + argv: &[String], + outputs: &[impl AsRef], + what: &str, + written: Value, +) -> Result> { + let outputs: Vec = outputs.iter().map(|p| p.as_ref().to_path_buf()).collect(); + let job = cx.spawn_job(command(argv)?, &outputs, what)?; + let mut v = Map::new(); + v.insert("note".into(), json!(format!("{job} writes `written`"))); + v.insert("job".into(), json!(job)); + v.insert("written".into(), written); + Ok(v) +} + /// `{stem}.{tool}`: `data`'s name without its folder and backend suffix, /// then the tool, so a chain of unnamed outputs spells its lineage /// (`pbmc.imported.qc.subset`). The name an output takes when the agent @@ -614,17 +655,67 @@ fn fresh(path: &str) -> Result<()> { Ok(()) } +/// The stems being written (all `{stem}.*` is theirs): by a call still +/// running, or by a background job for as long as the server runs. Until +/// a command has made its outputs, only this says they are spoken for. +static WRITING: Mutex> = Mutex::new(Vec::new()); + +/// Whether two stems name some file alike: one is the other, or a file +/// named after it. +fn overlap(a: &str, b: &str) -> bool { + let after = |a: &str, b: &str| a.strip_prefix(b).is_some_and(|r| r.starts_with('.')); + a == b || after(a, b) || after(b, a) +} + +/// The stem being written that `stem` would touch, if any. +fn being_written(stem: &str) -> Option { + let stem = absolute(stem); + let writing = WRITING.lock().unwrap_or_else(|e| e.into_inner()); + writing.iter().find(|w| overlap(w, &stem)).cloned() +} + +/// `stems` spoken for until the claim drops, or, [`Claim::keep`]ed, for +/// good; refused when another call or job has one. +struct Claim(Vec); + +impl Claim { + fn new(stems: &[&str]) -> Result { + let stems: Vec = stems.iter().map(|s| absolute(s)).collect(); + let mut writing = WRITING.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(w) = writing.iter().find(|w| stems.iter().any(|s| overlap(w, s))) { + anyhow::bail!( + "{w} is being written by another call or a background job: choose another `out`" + ); + } + writing.extend(stems.iter().cloned()); + Ok(Self(stems)) + } + + /// For a background job: the server cannot tell when it is done. + fn keep(self) { + std::mem::forget(self); + } +} + +impl Drop for Claim { + fn drop(&mut self) { + let mut writing = WRITING.lock().unwrap_or_else(|e| e.into_inner()); + writing.retain(|w| !self.0.contains(w)); + } +} + /// Run `argv`, a command that writes the backend `out` (made of `what`) /// from `inputs`, with `-o`; say where the backend is and how big. fn written( cx: &Call, + background: bool, argv: Vec, out: &str, what: &str, inputs: &[String], ) -> Result { Ok(Value::Object( - written_with(cx, argv, out, what, inputs, &[])?.0, + written_with(cx, background, argv, out, what, inputs, &[])?.0, )) } @@ -636,6 +727,7 @@ fn written( /// it wrote come back as `also`. fn written_with( cx: &Call, + background: bool, mut argv: Vec, out: &str, what: &str, @@ -667,6 +759,12 @@ fn written_with( let path: &str = &target.found; let mut outputs = vec![path]; outputs.extend(also); + let claim = Claim::new(&[&[stem.as_str()], also].concat())?; + if background { + let job = data_beans_job(cx, &argv, &outputs, what, json!(absolute(path)))?; + claim.keep(); + return Ok((job, String::new())); + } let printed = data_beans_making(cx, &argv, &outputs, what)?; let written = absolute(path); // Nothing was there before, so the rest there now was written too. @@ -984,12 +1082,17 @@ fn qc(a: &Arguments, cx: &Call) -> Result { argv.push(format!("--qc-report={report}")); } let what = format!("the cells of {data} that cell QC keeps"); + anyhow::ensure!( + a.given("out").is_some() || !a.flag("background")?, + "`background` runs a qc that writes `out`; a dry run answers in the call" + ); // Without `out` a dry run: only the summary, and the report if asked. let (mut v, printed) = match (a.given("out"), report) { (Some(_), _) => { let out = a.path("out")?; written_with( cx, + a.flag("background")?, argv, &out, &what, @@ -1070,6 +1173,8 @@ fn import(a: &Arguments, cx: &Call) -> Result { import_commands() ); let input = a.path("input")?; + // Its provenance is noted before a background job has read it. + anyhow::ensure!(Path::new(&input).exists(), "{input} does not exist"); // The input's own suffixes (`.mtx.gz`, `.h5ad`) go. let file = Path::new(&input).file_name().map(|n| n.to_string_lossy()); let stem = file @@ -1080,9 +1185,16 @@ fn import(a: &Arguments, cx: &Call) -> Result { let what = format!("{input} as a backend ({command})"); let mut argv = argv([&command, &input]); argv.extend(a.options()?); - let v = written(cx, argv, &out, &what, std::slice::from_ref(&input))?; + let v = written( + cx, + a.flag("background")?, + argv, + &out, + &what, + std::slice::from_ref(&input), + )?; // Where the input came from, in the project's data/README.md, once it - // is in. + // is in (or, in the background, once its job has started). let source = a.str("source").unwrap_or("a local file, source not given"); cx.provenance(Path::new(&input), source); Ok(v) @@ -1141,7 +1253,9 @@ fn subset(a: &Arguments, cx: &Call) -> Result { given.iter().filter(|&&g| g).count() == 1, "give one of `names`, `pattern` or `indices`" ); - // The names go to the command in a file, kept until it has run. + let background = a.flag("background")?; + // The names go to the command in a file, kept until it has run; a + // job's is left in the temp folder, as the job reads it later. let file; let what = if let Some(p) = a.str("pattern") { // `--flag=value`, so a pattern cannot read as an option. @@ -1156,13 +1270,16 @@ fn subset(a: &Arguments, cx: &Call) -> Result { // Through the call, so the session script carries the names too. cx.write_file(file.path(), &(names.join("\n") + "\n"))?; argv.extend(["-f".into(), file.path().to_string_lossy().into_owned()]); + if background { + file.keep()?; + } if !a.flag("prefix_match")? { argv.push("--exact-names".into()); } format!("{} named {side} of {data}", names.len()) }; argv.extend(a.options()?); - written(cx, argv, &out, &what, &[data]) + written(cx, background, argv, &out, &what, &[data]) } fn merge(a: &Arguments, cx: &Call) -> Result { @@ -1178,7 +1295,16 @@ fn merge(a: &Arguments, cx: &Call) -> Result { argv.push("--common-rows".into()); } argv.extend(a.options()?); - let mut v = written_with(cx, argv, &out, &what, &data, &[&batches])?.0; + let mut v = written_with( + cx, + a.flag("background")?, + argv, + &out, + &what, + &data, + &[&batches], + )? + .0; v.insert("batches".into(), json!(absolute(&batches))); Ok(Value::Object(v)) } @@ -1217,7 +1343,7 @@ fn outputs_beside(stem: &str) -> Result> { fn split(a: &Arguments, cx: &Call) -> Result { let data = a.path("data")?; let prefix = a.out_or(cx, (&named(&data, "split"), ""), |c| { - outputs_beside(c).map_or(true, |v| !v.is_empty()) + being_written(c).is_some() || outputs_beside(c).map_or(true, |v| !v.is_empty()) })?; let before = outputs_beside(&prefix)?; anyhow::ensure!( @@ -1238,12 +1364,15 @@ fn split(a: &Arguments, cx: &Call) -> Result { let mut argv = argv(["split", &data, "-o", &prefix]); argv.extend(a.numbers(&["test_frac", "folds", "seed"])?); argv.extend(a.options()?); - data_beans_making( - cx, - &argv, - &halves, - &format!("cells of {data} split for testing"), - )?; + let what = format!("cells of {data} split for testing"); + let claim = Claim::new(&[&prefix])?; + if a.flag("background")? { + let written: Vec = halves.iter().map(|h| absolute(h)).collect(); + let job = data_beans_job(cx, &argv, &halves, &what, json!(written))?; + claim.keep(); + return Ok(Value::Object(job)); + } + data_beans_making(cx, &argv, &halves, &what)?; // Nothing was there before, so all there is now was written. Ok(json!({ "written": outputs_beside(&prefix)? })) } @@ -1275,6 +1404,8 @@ mod tests { written_backend(halves).as_deref(), Some("/p/cv.test.zarr.zip") ); + let job = r#"{ "job": "job-1", "written": "/p/results/x.subset.zarr.zip" }"#; + assert_eq!(written_backend(job), None); // A note the person left follows the result. let noted = format!("{one}\nNote from the user: use exact names"); assert_eq!( diff --git a/tests/mcp_server.rs b/tests/mcp_server.rs index f0d6b5c..f0b1e9b 100644 --- a/tests/mcp_server.rs +++ b/tests/mcp_server.rs @@ -321,6 +321,119 @@ fn unnamed_outputs_go_to_the_results_folder_and_the_records() { assert!(readme.contains("the test's fixture")); } +#[test] +fn a_write_in_the_background_returns_a_job_that_writes_it() { + let (dir, mut c) = setup(); + let wait = |c: &mut Client, job: &Value| { + let mut v = Value::Null; + for _ in 0..10 { + v = c + .call("job_wait", json!({ "id": job, "seconds": 30 })) + .unwrap(); + if v["state"] != "running" { + break; + } + } + assert_eq!(v["state"], "done", "{v}"); + }; + + let started = c + .call( + "subset", + json!({ "data": "base.zarr.zip", "axis": "rows", "pattern": "^G", "out": "genes", "background": true }), + ) + .unwrap(); + assert!( + started["job"].as_str().unwrap().starts_with("job-"), + "{started}" + ); + assert!(started.get("info").is_none()); + let written = started["written"].as_str().unwrap(); + assert!(written.ends_with("genes.zarr.zip")); + wait(&mut c, &started["job"]); + assert!(Path::new(written).exists()); + let info = c.call("info", json!({ "data": written })).unwrap(); + assert_eq!(info["rows"], 4, "{info}"); + + // The names file outlives the call that wrote it. + let named = c + .call( + "subset", + json!({ "data": "base.zarr.zip", "axis": "rows", "names": ["G2", "G3"], "out": "named", "background": true }), + ) + .unwrap(); + wait(&mut c, &named["job"]); + let info = c.call("info", json!({ "data": named["written"] })).unwrap(); + assert_eq!(info["rows"], 2, "{info}"); + + // Two jobs started together, unnamed, write two outputs. + let jobs: Vec = ["^G", "^MT"] + .map(|p| { + c.call( + "subset", + json!({ "data": "base.zarr.zip", "axis": "rows", "pattern": p, "background": true }), + ) + .unwrap() + }) + .into(); + assert_ne!(jobs[0]["written"], jobs[1]["written"], "{jobs:?}"); + for j in &jobs { + wait(&mut c, &j["job"]); + } + // And an output a job is writing is no one else's. + let first = c + .call( + "subsample", + json!({ "data": "base.zarr.zip", "cells": 2, "out": "drawn", "background": true }), + ) + .unwrap(); + let again = c.call( + "subsample", + json!({ "data": "base.zarr.zip", "cells": 3, "out": "drawn" }), + ); + wait(&mut c, &first["job"]); + assert!(again.is_err(), "{again:?}"); + + // Split's halves are named up front. + let started = c + .call( + "split", + json!({ "data": "base.zarr.zip", "test_frac": 0.5, "out": "cv", "background": true }), + ) + .unwrap(); + let halves: Vec<&str> = started["written"] + .as_array() + .unwrap() + .iter() + .map(|h| h.as_str().unwrap()) + .collect(); + assert_eq!(halves.len(), 2, "{started}"); + wait(&mut c, &started["job"]); + assert!(halves.iter().all(|h| Path::new(h).exists()), "{halves:?}"); + + // A job is a step of the session like any other. + let day = std::fs::read_dir(dir.path().join("results")) + .unwrap() + .flatten() + .find(|e| e.path().is_dir()) + .unwrap() + .path(); + let script = std::fs::read_to_string(day.join("runall.sh")).unwrap(); + for made in ["genes.zarr.zip", "cv.train.zarr.zip"] { + assert!(script.contains(&format!("-e {made} ]")), "{made}: {script}"); + } + + // A job has nothing to wait for: a dry run, or an import of nothing. + let dry = c.call("qc", json!({ "data": "base.zarr.zip", "background": true })); + assert!(dry.unwrap_err().contains("dry run")); + let missing = c.call( + "import", + json!({ "command": "from-mtx", "input": "missing.mtx", "source": "nowhere", "background": true }), + ); + assert!(missing.unwrap_err().contains("does not exist")); + assert!(!dir.path().join("data/README.md").exists()); +} + /// What a call should come to. enum Want { /// Not an error, and the check holds on its structured content. From bce73bebd523bb680d1b119c6aa2ef6935bbb0ff Mon Sep 17 00:00:00 2001 From: YPARK Date: Tue, 20 Oct 2026 14:54:37 -0700 Subject: [PATCH 2/4] Version 0.10.1: background is an MCP tool argument, not a CLI change --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index df52d66..34f54f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ edition = "2021" readme = "README.md" # Own version (decoupled from workspace.version): data-beans evolves its CLI / # QC API independently of the shared utility crates. -version = "0.11.0" +version = "0.10.1" rust-version = "1.91" # Ship only the sources, README and license: nothing else in the working tree # (data stores, saved name lists, scratch files) can end up on crates.io. From 168bf08f10498de7c31b2838801eefc942023efc Mon Sep 17 00:00:00 2001 From: YPARK Date: Tue, 20 Oct 2026 14:59:19 -0700 Subject: [PATCH 3/4] MCP children run without backtraces, so a failure quotes the error, not a backtrace --- src/mcp.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/mcp.rs b/src/mcp.rs index 11f7b38..5980707 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -570,6 +570,9 @@ fn command(argv: &[String]) -> Result { let mut cmd = Command::new(own_exe()?); // `-v` so its log lines come back as progress. cmd.arg("-v").args(argv); + // No backtrace after an error: a failure quotes the last lines of the + // child's stderr, and one would push the error itself out of them. + cmd.env("RUST_BACKTRACE", "0").env("RUST_LIB_BACKTRACE", "0"); Ok(cmd) } From da1a83f5f0484e6e1f962599cd626da78668315e Mon Sep 17 00:00:00 2001 From: YPARK Date: Tue, 20 Oct 2026 14:59:26 -0700 Subject: [PATCH 4/4] rustfmt --- src/mcp.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/mcp.rs b/src/mcp.rs index 5980707..eb08b43 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -572,7 +572,8 @@ fn command(argv: &[String]) -> Result { cmd.arg("-v").args(argv); // No backtrace after an error: a failure quotes the last lines of the // child's stderr, and one would push the error itself out of them. - cmd.env("RUST_BACKTRACE", "0").env("RUST_LIB_BACKTRACE", "0"); + cmd.env("RUST_BACKTRACE", "0") + .env("RUST_LIB_BACKTRACE", "0"); Ok(cmd) }