use super::{pipeline, render, source, Server};
use crate::data::describe;
use serde_json::{json, Value};
pub enum CallError {
UnknownTool(String),
Failed(String),
}
impl From<String> for CallError {
fn from(message: String) -> Self {
CallError::Failed(message)
}
}
pub const INSTRUCTIONS: &str = "\
tuitab computes over data files so you do not have to. Send it the operations; \
read back the numbers. Never calculate over a file yourself when you can call \
these tools — that is the entire point of them.
WORKFLOW
1. tuitab_inspect first, always. It tells you the real column names, their \
inferred types, and how many rows there are. Guessing column names wastes a \
round trip.
2. tuitab_query to compute. Operations run in order, each on the previous \
result.
3. Explain the returned numbers to the user. Quote them; do not re-derive them.
FORMATS
csv, tsv, txt, parquet, arrow/feather/ipc, xlsx/xls, sqlite, duckdb, json, jsonl, \
yaml, toml, and a directory path (which lists its files). For xlsx, sqlite and duckdb, pass \
'container' to pick a sheet or table; tuitab_inspect lists them with their row and \
column counts, and for a database also the CREATE statement and each column's \
declared SQL type, NOT NULL, PRIMARY KEY and DEFAULT. Views are listed and readable; \
they cannot be written to. A database source always needs a 'container' — without one \
there is nothing to read.
OPERATIONS for tuitab_query
{\"filter\": [{\"col\":\"region\",\"op\":\"eq\",\"value\":\"North\"}]}
Entries are combined with AND. Operators: eq, ne, gt, ge, lt, le, in, \
not_in, contains (regex, case-sensitive — write (?i) yourself), between \
([low, high]), is_empty, not_empty.
For OR, wrap predicates in any_of — one entry, several alternatives:
{\"filter\": [{\"any_of\": [{\"col\":\"region\",\"op\":\"eq\",\"value\":\"North\"},
{\"col\":\"region\",\"op\":\"eq\",\"value\":\"South\"}]},
{\"col\":\"amount\",\"op\":\"gt\",\"value\":1000}]}
reads as (North OR South) AND amount > 1000. One level of nesting only.
To compare two columns, give a column instead of a constant:
{\"col\":\"revenue\",\"op\":\"gt\",\"value\":{\"col\":\"cost\"}}
{\"select\": [\"a\",\"b\"]} keep these columns, in this order
{\"sort\": {\"col\":\"amount\",\"desc\":true}}
{\"sort\": {\"by\":[{\"col\":\"region\"},{\"col\":\"amount\",\"desc\":true}]}}
Use 'by' for several keys, first the most significant. Do NOT chain two \
sort operations to get that — the second is free to reorder rows that tie \
on its own key, so the first ordering is not preserved.
{\"compute\": {\"name\":\"margin\",\"expr\":\"revenue - cost\"}}
Expression syntax: arithmetic, comparisons, and/or/not, if(cond, a, b), \
concat, substring, len, contains(col, regex), year/month/day, \
date_format, and 'x in (a, b)'. 'or' binds loosest, then 'and', then \
'not'.
{\"group_by\": {\"by\":[\"region\"],\"agg\":[{\"col\":\"amount\",\"fn\":\"sum\"}]}}
Exactly the aggregates you ask for. Result columns are named 'col:fn'. \
Use {\"col\":\"*\",\"fn\":\"count\"} for a row count.
{\"aggregate\": [{\"col\":\"amount\",\"fn\":\"sum\"},{\"col\":\"*\",\"fn\":\"count\"}]}
A grand total over every remaining row — one row out, no grouping.
{\"frequency\": {\"by\":[\"product\"]}}
Distribution ranked by count, descending, with Count and Pct columns. Use \
this for 'how many of each' / 'most common'; use group_by when you want \
specific aggregates and your own ordering.
{\"pivot\": {\"index\":[\"region\"],\"on\":\"quarter\",\"formula\":\"sum(amount)\"}}
{\"join\": {\"source\":{\"path\":\"prices.csv\"},\"left_on\":[\"id\"],\"how\":\"left\"}}
how: inner, left, right, outer. 'right_on' defaults to 'left_on'.
{\"dedup\": {\"by\":[\"id\"],\"keep\":\"first\"}}
keep: first, last, min, max (both need \"on\": column), random (pass \
\"seed\" to repeat the same choice).
{\"duplicates\": {\"by\":[\"email\"]}} keep only rows whose key repeats
{\"window\": {\"fn\":\"rank\",\"col\":\"salary\",\"over\":[\"department\"],\"desc\":true}}
fn: row_number, rank, dense_rank, cum_sum, lag, lead, sum, avg, min, \
max, count, pct_of_total. 'over' restarts the window per group; without \
it the window is the whole table. 'as' names the new column. \
IMPORTANT: cum_sum, lag, lead and row_number read the rows in their \
current order — sort first, or a running total accumulates in load order.
{\"sample\": {\"n\":100,\"seed\":42}} keep n rows at random; the seed makes it repeatable
{\"transpose\": {}} stand the table on end; {\"row\": 3} for one row
{\"limit\": 20}
Aggregate functions: count, distinct, sum, avg, min, max, median, stdev, p5, \
p25, p50, p75, p95.
To filter groups (SQL's HAVING), put a filter after group_by.
NOT SUPPORTED — do not attempt: subqueries, self-joins, and SQL of any kind. You \
never write SQL here. When a table has to change, tuitab composes the SQL for you \
and shows it to you before anything runs.
PERCENT OF TOTAL
Use window with 'over' for a share within a group; use compute with \
'amount / sum(amount)' for a share of the whole table.
READING RESULTS
Rows come back as arrays matching the 'columns' list. Percentages are \
fractions: 0.42 means 42%. Currency and float values are raw numbers, not \
formatted strings. Always check 'truncated' — when it is true you are seeing \
part of the answer.
LARGE OR SHAREABLE RESULTS
Set output.path to write the result to a file instead of returning rows: \
.xlsx, .csv, .tsv, .parquet, .arrow, .json, .jsonl, .yaml, .toml, .sqlite, .duckdb. \
That file is formatted for a person to read. Use it whenever the user wants a \
deliverable, or when a result is too big to return. A path that does not exist yet is \
written in one call. Replacing a file that is already there destroys it, so it takes \
output.overwrite and a server started with --mcp-write, and the call plans rather than \
writes: it answers with what the old file is and a plan id, and nothing happens until \
that plan is applied. For .sqlite and .duckdb, \
output.table names the table (default 'result'). Adding a table to a database is not \
replacing anything — the tables already in it are untouched — so it needs nothing \
extra, and several writes can build up one file. Replacing a table follows the same \
rule as replacing a file: output.overwrite, --mcp-write, and a plan holding the DROP, \
CREATE and INSERT statements to be applied. The file a query read cannot be the file \
it writes.
NESTED DATA
tuitab_query flattens JSON/YAML/TOML into a table. When the structure is deeper \
than that survives, use tuitab_jq with a jq program instead.";
pub const WRITE_INSTRUCTIONS: &str = "\
CHANGING A DATABASE TABLE
This server was started with writing enabled, so two more tools exist. They work on \
.sqlite and .duckdb sources with a 'container', and on tables only — never views.
1. tuitab_write says what would happen. It writes nothing. Give it one of:
{\"set\": {\"status\": \"archived\"}, \"where\": [{\"col\":\"id\",\"op\":\"in\",\"value\":[3,7,11]}]}
{\"delete\": true, \"where\": [{\"col\":\"state\",\"op\":\"eq\",\"value\":\"draft\"}]}
{\"insert\": [{\"name\":\"ann\",\"score\":10}]}
{\"alter\": {\"add\": [{\"name\":\"tier\",\"type\":\"text\"}], \"drop\": [\"old\"], \"rename\": {\"nm\":\"name\"}}}
'where' takes the same predicates as tuitab_query's filter, and omitting it on \
'set' changes every row — the plan tells you how many that is. 'delete' always \
needs a 'where'. A JSON null in 'set' writes a real NULL. On 'insert' a column with \
no value — left out, or given null — is left out of the statement, so the schema's \
DEFAULT runs; a column with no DEFAULT is NULL. A DEFAULT therefore wins over an \
explicit null on insert, and forcing NULL into a defaulted column is not expressible \
here: insert the row, then 'set' that column to null.
It answers with the exact statements, how many rows they touch, those rows as they \
stand now, and a plan_id. Long plans are cut short: 'statements' holds the first \
twenty, 'statements_total' and 'statements_not_shown' say how many there are, and \
'show_statements' asks for more (up to 200) when the user wants to see them. \
'warnings' names anything the statements do not spell out.
2. Read the statements and the affected rows back to the user, with the counts when \
the list was cut. This is the only confirmation there is.
3. tuitab_write_apply with that plan_id runs exactly those statements, all of them or \
none. If the table changed since step 1, nothing is written and you start over.
tuitab_query's output.overwrite goes through the same two steps, for a table and for \
a plain file alike: replacing something that already exists answers with a plan_id and \
what is about to be lost — for a table the DROP, CREATE and INSERT statements and the \
rows it holds now, for a file its size — and tuitab_write_apply is what performs it.
One change per call, and any new plan — from either tool — discards the previous one. \
Raw SQL is still not available. alter adds, drops and renames columns; changing an existing \
column's type and reordering columns are only in the terminal.";
pub fn instructions(write: bool) -> String {
if write {
format!("{}\n\n{}", INSTRUCTIONS, WRITE_INSTRUCTIONS)
} else {
INSTRUCTIONS.to_string()
}
}
pub fn definitions(write: bool) -> Vec<Value> {
let source_schema = json!({
"type": "object",
"description": "The file to read. A bare path string also works.",
"properties": {
"path": {"type": "string", "description": "Path to the file, or a directory to list."},
"container": {
"type": "string",
"description": "Sheet name (Excel), or table or view name (SQLite/DuckDB). A database source needs one. Call tuitab_inspect to see what is available."
},
"delimiter": {
"type": "string",
"description": "Single character overriding CSV delimiter auto-detection."
},
"format": {
"type": "string",
"description": "Overrides the file extension, e.g. read a .conf file as 'yaml'.",
"enum": ["csv", "tsv", "json", "jsonl", "ndjson", "yaml", "yml", "toml",
"sqlite", "sqlite3", "db", "duckdb", "ddb", "xlsx", "xls",
"parquet", "arrow"]
}
},
"required": ["path"]
});
let mut tools = vec![
json!({
"name": "tuitab_inspect",
"title": "Inspect a data file",
"description":
"Read a file's structure: its sheets or tables, its column names with the types \
tuitab inferred, its row count, and a few sample rows. Call this before \
tuitab_query so you use real column names instead of guessing.",
"inputSchema": {
"type": "object",
"properties": {
"source": source_schema,
"sample_rows": {
"type": "integer",
"description": "Sample rows to return (default 5).",
"minimum": 0,
"maximum": 100
}
},
"required": ["source"]
},
"annotations": {"readOnlyHint": true, "openWorldHint": false}
}),
json!({
"name": "tuitab_query",
"title": "Compute over a data file",
"description":
"Run one or more pipelines of operations over a file and return the results. \
Operations apply in order: filter, select, sort, compute, group_by, aggregate, \
frequency, pivot, join, limit. Several pipelines in one call share a single load of the \
file. Use this for every calculation over tabular data — the numbers it returns \
are computed, not estimated.",
"inputSchema": {
"type": "object",
"properties": {
"source": source_schema,
"ops": {
"type": "array",
"description": "Operations for a single pipeline. Use this or 'pipelines', not both.",
"items": {"type": "object"}
},
"pipelines": {
"type": "array",
"description": "Several independent pipelines, each starting from the source.",
"items": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Label echoed back with this pipeline's result."},
"ops": {"type": "array", "items": {"type": "object"}}
},
"required": ["ops"]
}
},
"output": {
"type": "object",
"properties": {
"limit": {
"type": "integer",
"description": "Maximum rows to return (default 100).",
"minimum": 1
},
"path": {
"type": "string",
"description":
"Write the result here instead of returning rows. The extension \
picks the format: .xlsx, .csv, .tsv, .parquet, .arrow, .json, \
.jsonl, .yaml, .toml, .sqlite."
},
"table": {
"type": "string",
"description":
"Name for the table this creates in a .sqlite/.duckdb file \
(default 'result'). Also the top-level key for wrapped \
JSON/YAML/TOML."
},
"overwrite": {
"type": "boolean",
"description": "Allow replacing an existing file at 'path' (default false)."
}
}
}
},
"required": ["source"]
}
}),
json!({
"name": "tuitab_describe",
"title": "Profile every column",
"description":
"Statistical profile of each column: type, count, nulls, unique, min, max, mean, \
median, mode, stdev_pop (population standard deviation), range, and the 5th, \
25th, 50th, 75th and 95th percentiles. Use it to understand a dataset before \
querying it.",
"inputSchema": {
"type": "object",
"properties": {
"source": source_schema,
"columns": {
"type": "array",
"description": "Restrict the profile to these columns. Omit for all of them.",
"items": {"type": "string"}
}
},
"required": ["source"]
},
"annotations": {"readOnlyHint": true, "openWorldHint": false}
}),
json!({
"name": "tuitab_jq",
"title": "Query a nested document with jq",
"description":
"Run a jq program over a JSON, JSONL, YAML or TOML file and return the result as \
JSON. Use this when the structure is too nested for a table — otherwise prefer \
tuitab_query.",
"inputSchema": {
"type": "object",
"properties": {
"source": source_schema,
"program": {"type": "string", "description": "A jq program, e.g. '.items | map(.price) | add'."}
},
"required": ["source", "program"]
},
"annotations": {"readOnlyHint": true, "openWorldHint": false}
}),
];
if write {
tools.push(write_tool_schema(&source_schema));
tools.push(apply_tool_schema());
}
tools
}
fn write_tool_schema(source_schema: &Value) -> Value {
json!({
"name": "tuitab_write",
"title": "Plan a change to a database table",
"description":
"Work out what changing a database table would do, and return the exact SQL — nothing is written. Only .sqlite/.duckdb sources with a 'container', and only tables, never views. Give one of 'set', 'delete', 'insert' or 'alter' per call. Read the statements and the affected rows back to the user, then call tuitab_write_apply with the returned plan_id to run exactly that plan.",
"inputSchema": {
"type": "object",
"properties": {
"source": source_schema,
"set": {
"type": "object",
"description":
"Column name to new value, for every row 'where' matches. A JSON null writes a real NULL. Several columns become one UPDATE."
},
"delete": {
"type": "boolean",
"description": "Delete the rows 'where' matches. 'where' is required."
},
"insert": {
"type": "array",
"description":
"New rows, each an object of column name to value. A column with no value — left out, or null — is omitted from the INSERT, so the schema's DEFAULT applies; without a DEFAULT it is NULL. To force NULL into a defaulted column, insert the row and then 'set' it.",
"items": {"type": "object"},
"maxItems": 1000
},
"alter": {
"type": "object",
"description":
"Change the table's shape. Types are string, integer, float, boolean, date, datetime.",
"properties": {
"add": {"type": "array", "items": {"type": "object"}},
"drop": {"type": "array", "items": {"type": "string"}},
"rename": {"type": "object"}
}
},
"where": {
"type": "array",
"description":
"Which rows to change, in the same shape tuitab_query's 'filter' takes. Omit it on 'set' to change every row — the plan says how many that is. Ignored by 'insert' and 'alter'.",
"items": {"type": "object"}
},
"preview_rows": {
"type": "integer",
"description": "Affected rows to show back (default 10).",
"minimum": 0,
"maximum": 100
},
"show_statements": {
"type": "integer",
"description":
"How many statements to return (default 20). Raise it when the user asks to see all of them; the reply always says how many were not shown.",
"minimum": 1,
"maximum": 200
}
},
"required": ["source"]
},
"annotations": {"readOnlyHint": true, "openWorldHint": false}
})
}
fn apply_tool_schema() -> Value {
json!({
"name": "tuitab_write_apply",
"title": "Run a plan from tuitab_write",
"description":
"Execute exactly the statements tuitab_write returned, in one transaction. The plan is checked against the database first: if a row changed since the plan was made, nothing is written.",
"inputSchema": {
"type": "object",
"properties": {"plan_id": {"type": "string"}},
"required": ["plan_id"]
},
"annotations": {
"readOnlyHint": false,
"destructiveHint": true,
"idempotentHint": false,
"openWorldHint": false
}
})
}
pub fn call(server: &mut Server, name: &str, args: &Value) -> Result<Value, CallError> {
match name {
"tuitab_inspect" => inspect(server, args),
"tuitab_query" => query(server, args),
"tuitab_describe" => describe_tool(server, args),
"tuitab_jq" => jq(args),
"tuitab_write" | "tuitab_write_apply" if !server.write => Err(CallError::Failed(format!(
"{} is off. Restart the server with --mcp-write to allow it to change rows.",
name
))),
"tuitab_write" => super::write::plan(server, args),
"tuitab_write_apply" => super::write::apply(server, args),
other => Err(CallError::UnknownTool(other.to_string())),
}
}
pub fn where_arg(args: &Value) -> Result<Vec<crate::data::filter::Clause>, CallError> {
match args.get("where") {
None => Ok(Vec::new()),
Some(v) => pipeline::parse_predicates(v).map_err(CallError::Failed),
}
}
pub fn source_arg(args: &Value) -> Result<source::Source, CallError> {
let value = args
.get("source")
.ok_or_else(|| CallError::Failed("missing required argument 'source'".to_string()))?;
Ok(source::Source::from_json(value)?)
}
fn inspect(server: &mut Server, args: &Value) -> Result<Value, CallError> {
let src = source_arg(args)?;
let sample = args
.get("sample_rows")
.and_then(Value::as_u64)
.unwrap_or(5)
.min(100) as usize;
let ext = source::extension_of(&src);
let containers = source::containers(&src.path, &ext);
if let Some(listed) = &containers {
if src.container.is_none() {
let tables = listed.iter().filter(|c| !c.view).count();
let views = listed.len() - tables;
return Ok(json!({
"path": src.path.to_string_lossy(),
"containers": containers_json(listed),
"note": format!(
"This file holds {} table(s){}. Call tuitab_inspect again with \
'container' set to one of them to see its columns.",
tables,
if views > 0 {
format!(" and {} view(s)", views)
} else {
String::new()
}
),
}));
}
}
let is_db = crate::data::io::db_write::is_db_ext(&src.path);
let (df, table_source) = match (&src.container, is_db) {
(Some(container), true) => source::load_db_table(&src.path, container)?,
_ => (source::load(server, &src)?, None),
};
let table = render::table(&df, sample)?;
let mut out = json!({
"path": src.path.to_string_lossy(),
"columns": columns_json_with_schema(&df, table_source.as_ref()),
"row_count": df.row_order.len(),
"sample_rows": table.get("rows").cloned().unwrap_or(Value::Array(vec![])),
});
if let Some(obj) = out.as_object_mut() {
if let Some(listed) = &containers {
obj.insert("containers".into(), containers_json(listed));
obj.insert("container".into(), json!(src.container));
if let Some(here) = listed
.iter()
.find(|c| Some(&c.name) == src.container.as_ref())
{
if let Some(sql) = &here.sql {
obj.insert("create_sql".into(), json!(sql));
}
}
}
if is_db && src.container.is_some() {
obj.insert("writable".into(), json!(table_source.is_some()));
if table_source.is_none() {
obj.insert(
"note".into(),
json!("This is a view: it can be read but not written to."),
);
}
}
}
Ok(out)
}
fn containers_json(listed: &[crate::data::io::ContainerInfo]) -> Value {
Value::Array(
listed
.iter()
.map(|c| {
json!({
"name": c.name,
"kind": if c.view { "view" } else { "table" },
"rows": c.rows,
"columns": c.columns,
"create_sql": c.sql,
})
})
.collect(),
)
}
fn columns_json_with_schema(
df: &crate::data::dataframe::DataFrame,
src: Option<&crate::data::io::db_write::TableSource>,
) -> Value {
let mut cols = render::columns_json(df);
let Some(src) = src else {
return Value::Array(cols);
};
for (entry, col) in cols.iter_mut().zip(&src.columns) {
if let Some(obj) = entry.as_object_mut() {
obj.insert("declared".into(), json!(col.decl_raw));
obj.insert("not_null".into(), json!(col.notnull));
obj.insert("primary_key".into(), json!(col.pk));
obj.insert("default".into(), json!(col.default_sql));
obj.insert("generated".into(), json!(col.generated));
}
}
Value::Array(cols)
}
struct Output {
limit: usize,
path: Option<std::path::PathBuf>,
table: Option<String>,
overwrite: bool,
}
fn output_arg(args: &Value) -> Output {
let output = args.get("output");
Output {
limit: output
.and_then(|o| o.get("limit"))
.and_then(Value::as_u64)
.map(|n| n as usize)
.unwrap_or(render::DEFAULT_LIMIT),
path: output
.and_then(|o| o.get("path"))
.and_then(Value::as_str)
.map(crate::app::expand_tilde),
table: output
.and_then(|o| o.get("table"))
.and_then(Value::as_str)
.map(|s| s.trim().to_string()),
overwrite: output
.and_then(|o| o.get("overwrite"))
.and_then(Value::as_bool)
.unwrap_or(false),
}
}
type Requested = (Option<String>, Result<Vec<pipeline::Op>, String>);
fn query(server: &mut Server, args: &Value) -> Result<Value, CallError> {
let src = source_arg(args)?;
let output = output_arg(args);
let pipelines: Vec<Requested> = match args.get("pipelines") {
Some(Value::Array(items)) => items
.iter()
.enumerate()
.map(|(i, item)| {
let name = item.get("name").and_then(Value::as_str).map(str::to_string);
let ops = pipeline::parse_ops(item.get("ops").unwrap_or(&Value::Null))
.map_err(|e| format!("pipelines[{}]: {}", i, e));
(name, ops)
})
.collect(),
_ => match args.get("ops") {
Some(ops) => vec![(None, Ok(pipeline::parse_ops(ops)?))],
None => vec![(None, Ok(Vec::new()))],
},
};
if pipelines.is_empty() {
return Err(CallError::Failed("no pipelines to run".to_string()));
}
if output.path.is_some() && pipelines.len() > 1 {
return Err(CallError::Failed(
"'output.path' writes one file, so it cannot be combined with several pipelines"
.to_string(),
));
}
if let Some(path) = &output.path {
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or_default();
if !crate::data::io::writable_ext(ext) {
return Err(CallError::Failed(format!(
"There is no writer for '.{}'. Use .csv, .tsv, .json, .jsonl, .yaml, \
.toml, .parquet, .arrow, .xlsx, .sqlite or .duckdb.",
ext
)));
}
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
if !dir.is_dir() {
return Err(CallError::Failed(format!(
"There is no directory {} to write into.",
dir.display()
)));
}
}
}
let base = source::load(server, &src)?;
let mut results = Vec::with_capacity(pipelines.len());
let mut failures = 0usize;
for (index, (name, ops)) in pipelines.iter().enumerate() {
let label = name
.clone()
.unwrap_or_else(|| format!("pipeline_{}", index));
let outcome = match ops {
Err(why) => Err(why.clone()),
Ok(ops) => pipeline::apply_all_reporting_seeds(base.clone(), ops)
.map_err(|e| format!("{}: {}", label, e)),
};
let (df, seeds) = match outcome {
Ok(pair) => pair,
Err(why) => {
failures += 1;
results.push(json!({"name": label, "error": why}));
continue;
}
};
let mut entry = match &output.path {
Some(path) => {
server.cache.clear();
write_result(
server,
&df,
path,
output.table.as_deref(),
output.overwrite,
&src.path,
)?
}
None => render::table(&df, output.limit)?,
};
if let (Some(name), Some(obj)) = (name, entry.as_object_mut()) {
obj.insert("name".into(), json!(name));
}
if !seeds.0.is_empty() {
if let Some(obj) = entry.as_object_mut() {
obj.insert(
"seeds".into(),
json!(seeds
.0
.iter()
.map(|(op, seed)| json!({"op": op, "seed": seed}))
.collect::<Vec<_>>()),
);
}
}
results.push(entry);
}
if failures == results.len() {
let why: Vec<String> = results
.iter()
.filter_map(|r| r.get("error").and_then(Value::as_str).map(str::to_string))
.collect();
return Err(CallError::Failed(why.join("; ")));
}
Ok(if results.len() == 1 {
results.pop().expect("length checked")
} else {
json!({"results": results})
})
}
fn write_result(
server: &mut Server,
df: &crate::data::dataframe::DataFrame,
path: &std::path::Path,
table: Option<&str>,
overwrite: bool,
source: &std::path::Path,
) -> Result<Value, CallError> {
let write_enabled = server.write;
use crate::data::io::db_write;
let table = table.unwrap_or("result");
if let Err(why) = db_write::validate_table_name(table) {
return Err(CallError::Failed(format!("output.table: {}", why)));
}
if db_write::is_db_ext(path) {
let kind = db_write::kind_for_path(path);
if db_write::same_file(source, path) {
return Err(CallError::Failed(format!(
"{} is the source this query read. Write the result to a different file.",
path.display()
)));
}
if db_write::table_exists(kind, path, table) {
if !write_enabled {
return Err(CallError::Failed(format!(
"'{}' already exists in {}, and replacing a table the user already \
has needs the server to be started with --mcp-write. Choose another \
table name to add one beside it.",
table,
path.display()
)));
}
if !overwrite {
let holds = crate::data::io::db_containers(path)
.ok()
.and_then(|cs| cs.into_iter().find(|c| c.name == table))
.and_then(|c| c.rows)
.map(|n| format!(" and holds {} rows", n))
.unwrap_or_default();
return Err(CallError::Failed(format!(
"'{}' already exists in {}{}. Replacing it destroys them: pass \
output.overwrite only if the user asked for that, or choose another \
table name to write beside it.",
table,
path.display(),
holds
)));
}
if db_write::is_view(kind, path, table) {
return Err(CallError::Failed(format!(
"'{}' is a view in {}, not a table, and cannot be replaced by one. \
Choose another table name.",
table,
path.display()
)));
}
return super::write::plan_replacement(server, df, path, table);
}
} else if path.exists() {
let size = std::fs::metadata(path)
.map(|m| format!(" ({} bytes)", m.len()))
.unwrap_or_default();
if !write_enabled {
return Err(CallError::Failed(format!(
"{} already exists{}, and replacing a file the user already has needs \
the server to be started with --mcp-write. Write to a path that does \
not exist instead.",
path.display(),
size
)));
}
if !overwrite {
return Err(CallError::Failed(format!(
"{} already exists{}. Replacing it destroys what is there: pass \
output.overwrite only if the user asked for that, or choose another \
path.",
path.display(),
size
)));
}
return super::write::plan_file_overwrite(server, df, path, table);
}
crate::data::io::save_file_as(
df,
None,
path,
crate::data::io::doc_io::Shape::Records,
table,
)
.map_err(|e| CallError::Failed(format!("Could not write {}: {}", path.display(), e)))?;
let mut written = json!({
"written": path.to_string_lossy(),
"row_count": df.row_order.len(),
"columns": render::columns_json(df),
});
if !db_write::is_db_ext(path) {
written["note"] = json!(
"Values in the file are formatted for reading (currency symbols, fixed decimals)."
);
}
Ok(written)
}
fn describe_tool(server: &mut Server, args: &Value) -> Result<Value, CallError> {
let src = source_arg(args)?;
let mut df = source::load(server, &src)?;
if let Some(Value::Array(names)) = args.get("columns") {
let wanted: Vec<String> = names
.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect();
if !wanted.is_empty() {
df = pipeline::apply_all(df, &[pipeline::Op::Select(wanted)])?;
}
}
let mut profile = describe::describe(&df);
rename_metric(&mut profile, "stdev", "stdev_pop");
render::table(&profile, describe::METRICS.len()).map_err(CallError::Failed)
}
fn rename_metric(df: &mut crate::data::dataframe::DataFrame, from: &str, to: &str) {
let row = (0..df.visible_row_count()).find(|r| df.get_physical(df.row_order[*r], 0) == from);
if let Some(row) = row {
let _ = df.set_cell(df.row_order[row], 0, to.to_string());
}
}
fn jq(args: &Value) -> Result<Value, CallError> {
let src = source_arg(args)?;
let program = args
.get("program")
.and_then(Value::as_str)
.ok_or_else(|| CallError::Failed("missing required argument 'program'".to_string()))?;
let doc = source::load_doc(&src)?;
let result = crate::data::query::run_jq(&doc.root, program)
.map_err(|e| CallError::Failed(format!("The jq program failed: {}", e)))?;
let text = crate::data::doc::serialize(
&result,
crate::data::doc::Format::Json,
false,
&crate::data::doc::SaveOpts {
indent: false,
sort_keys: false,
},
)
.map_err(|e| CallError::Failed(format!("Could not render the jq result: {}", e)))?;
let value: Value = serde_json::from_str(&text)
.map_err(|e| CallError::Failed(format!("jq result was not valid JSON: {}", e)))?;
Ok(json!({"result": value}))
}