Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions rust/crates/sift_cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ This project adheres to [Semantic Versioning](http://semver.org/).
release before 9.0.3, so Cargo could no longer resolve the `zip = "8.2"`
requirement. The workspace now depends on `zip` 9 and commits its
`Cargo.lock`, so a build from a release tag uses the versions pinned there.
- `get_data` no longer matches archived runs by `run_name`. Before, an archived
run with the same name as an active one could supply the data in its place.
A name that matches more than one active run now fails and lists the
candidate run ids.
- `get_data` accepts `run_id` in place of `run_name`. It is the only way to
fetch an archived run. The result names the run in `run_id` and sets
`run_archived` when the run is archived.

## [v0.7.4] - October 14, 2026

Expand Down
26 changes: 16 additions & 10 deletions rust/crates/sift_mcp/src/prompt/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,15 @@ impl SiftMcpServer {
(`name == \"{asset}\"`); if nothing comes back, fall back to a substring match \
(`name.matches(\"{asset}\")`). When several assets match, ask the user which one they \
mean before continuing.\n\
2. List recent runs with `list_runs` filtered by `asset_id == \"<resolved asset_id>\"`, \
ordered `start_time desc`. Run and channel namespaces are per-asset, so always scope by \
`asset_id` instead of listing everything.\n\
2. List recent runs with `list_runs` filtered by \
`asset_id == \"<resolved asset_id>\" && is_archived == false`, ordered `start_time desc`. \
Run and channel namespaces are per-asset, so always scope by `asset_id` instead of \
listing everything.\n\
3. List channels with `list_channels` filtered by `asset_id == \"<resolved asset_id>\"`.\n\
4. Summarize for the user: the resolved asset (name and id), its most recent runs (name, \
start/stop, duration), and the channel inventory grouped by data type. Surface the exact \
run and channel names so they can be reused with `get_data`.\n\n\
start/stop, duration), and the channel inventory grouped by data type. Surface each run's \
`run_id` with its name, and the exact channel names, so they can be reused with \
`get_data`.\n\n\
This step is discovery only. Do not pull sample data."
);

Expand Down Expand Up @@ -87,10 +89,11 @@ impl SiftMcpServer {
{question_line}\
Steps:\n\
1. Resolve the asset and run. Use `list_assets` (`name == \"{asset}\"`) for the \
asset_id, then `list_runs` (`name == \"{run}\" && asset_id == \"...\"`) to confirm the run.\n\
asset_id, then `list_runs` (`name == \"{run}\" && asset_id == \"...\" && is_archived == false`) to \
confirm the run and take its `run_id`. If more than one run matches, ask the user which one.\n\
2. Confirm the target channels exist with `list_channels` scoped by `asset_id`. If no \
channels were named, choose a sensible set and tell the user which you picked.\n\
3. Pull data with `get_data`. Pass `run_name` so the run's start/stop bounds apply \
3. Pull data with `get_data`. Pass `run_id` so the run's start/stop bounds apply \
automatically; do not hand-compute timestamps. Use `channel_names` with exact \
channel names. Pass `sample_ms = 0`: this flow ends in statistics, and statistics taken \
off decimated data are wrong. If a long run makes the pull too large, narrow the time \
Expand Down Expand Up @@ -138,9 +141,12 @@ impl SiftMcpServer {
Source asset: \"{source_asset}\"\nSource run: \"{source_run}\"\n\
Transform: \"{transform}\"\n{target_asset_line}\n{target_run_line}\n\n\
Steps:\n\
1. Resolve the source asset and run with `list_assets` and `list_runs`. Identify the \
channels the transform needs via `list_channels` scoped by `asset_id`.\n\
2. Extract with `get_data`, passing `run_name` so the run bounds apply. Choose \
1. Resolve the source asset and run. Use `list_assets` (`name == \"{source_asset}\"`) for \
the asset_id, then `list_runs` (`name == \"{source_run}\" && asset_id == \"...\" && \
is_archived == false`) to confirm the run and take its `run_id`. If more than one run \
matches, ask the user which one. Identify the channels the transform needs via \
`list_channels` scoped by `asset_id`.\n\
2. Extract with `get_data`, passing `run_id` so the run bounds apply. Choose \
`channel_names`, and pass `sample_ms = 0` so the transform runs on real samples rather \
than a decimated approximation of them.\n\
3. Apply the transform with `sql`. CRITICAL: column 0 of any dataset uploaded to Sift \
Expand Down
207 changes: 172 additions & 35 deletions rust/crates/sift_mcp/src/tool/data/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ use rmcp::{
use serde::Deserialize;
use serde_json::Value;

use sift_rs::metadata::v1::MetadataValue;
use sift_rs::{assets::v1::Asset, metadata::v1::MetadataValue, runs::v2::Run};

use crate::{
error::{self, from_anyhow},
Expand Down Expand Up @@ -157,6 +157,7 @@ pub struct GetDataParams {
asset_name: Option<String>,
asset_id: Option<String>,
run_name: Option<String>,
run_id: Option<String>,
start_time_unix_nanos: Option<i64>,
end_time_unix_nanos: Option<i64>,
sample_ms: u32,
Expand All @@ -167,6 +168,12 @@ pub struct GetDataParams {
output: PathBuf,
}

/// Which run scopes a `get_data` call, before it is resolved on the asset.
enum RunSelector {
Id(String),
Name(String),
}

#[derive(Debug, Deserialize, JsonSchema)]
pub struct SqlParams {
inputs: Vec<PathBuf>,
Expand Down Expand Up @@ -247,16 +254,20 @@ impl SiftMcpServer {
always what was requested: `sample_ms` is ignored for data types that cannot be sampled. Both
keys are ALWAYS present; `sample_ms_applied` is null when no page reported a rate.
`mixed_sample_rates` appears when some channels came back decimated and others raw.
- `run_id` names the run that scoped the query, when one did. `run_archived` is `true` when that
run is archived.

Parameters:
- `asset_name`: optional, exact asset name (not a pattern). Mutually exclusive with `asset_id`;
exactly one of the two MUST be set.
- `asset_id`: optional, asset UUID. Mutually exclusive with `asset_name`; exactly one of the two
MUST be set. Runs reference assets by id (`list_runs` returns `asset_id`, not a name), so pass
that id here directly instead of guessing a name.
- `run_name`: optional, exact run name within the asset. When provided, the run's start/stop bounds are
used as the time range; `start_time_unix_nanos` and/or `end_time_unix_nanos` may narrow either side.
When omitted, BOTH `start_time_unix_nanos` and `end_time_unix_nanos` are required.
- `run_name`: optional, exact name of an active (non-archived) run on the asset. The run's start/stop
bounds become the time range; `start_time_unix_nanos`/`end_time_unix_nanos` may narrow either side.
Set at most one of `run_name` and `run_id`; with neither, BOTH time bounds are required.
- `run_id`: optional, run UUID from `list_runs`. Same as `run_name`, but also reaches archived runs.
Prefer it when known: names are not unique.
- `sample_ms`: decimation interval in milliseconds. Set this to `0` unless the data will be used
EXCLUSIVELY to generate a visualization. Decimation applies LTTB, which selects shape-defining
extremes rather than representative samples: counts, means, standard deviations and trends
Expand Down Expand Up @@ -292,8 +303,10 @@ impl SiftMcpServer {
channel does not apply when the asset is outside its scope or lacks a channel its expression
references. Verify the name with `list_calculated_channels` filtered by `asset_id`.
- `INVALID_PARAMS` if neither `asset_name` nor `asset_id` is set, or if both are set.
- `INVALID_PARAMS` if `run_name` is absent and the full time range is not supplied, if not exactly
one channel selector is set, if a selection array is empty, or if a channel ID is blank.
- `INVALID_PARAMS` if both `run_name` and `run_id` are set or either is blank, if `run_name`
matches more than one active run (the message lists their ids), if neither is set and the full
time range is not supplied, if not exactly one channel selector is set, if a selection array is
empty, or if a channel ID is blank.
- `INVALID_PARAMS` if the channel selection matches more than 200 channels and would be incomplete.
Narrow `channel_regex`, pass explicit `channel_names` or `channel_ids`, or split the request.

Expand Down Expand Up @@ -333,6 +346,7 @@ impl SiftMcpServer {
asset_name,
asset_id,
run_name,
run_id,
channel_names,
channel_regex,
channel_id,
Expand All @@ -343,14 +357,37 @@ impl SiftMcpServer {
output,
}) = params;

if run_name.is_none() && (start_time_unix_nanos.is_none() || end_time_unix_nanos.is_none())
if run_name
.iter()
.chain(&run_id)
.any(|value| value.trim().is_empty())
{
return Err(ErrorData::invalid_params(
"start_time_unix_nanos and end_time_unix_nanos are required when run_name is not provided",
"`run_name` and `run_id` must not be blank; omit the one you are not using",
None,
));
}

let run_selector = match (run_name, run_id) {
(Some(_), Some(_)) => {
return Err(ErrorData::invalid_params(
"set at most one of `run_name` or `run_id`, not both",
None,
));
}
(Some(name), None) => Some(RunSelector::Name(name)),
(None, Some(id)) => Some(RunSelector::Id(id)),
(None, None) => {
if start_time_unix_nanos.is_none() || end_time_unix_nanos.is_none() {
return Err(ErrorData::invalid_params(
"start_time_unix_nanos and end_time_unix_nanos are required when neither run_name nor run_id is provided",
None,
));
}
None
}
};

if [
channel_names.is_some(),
channel_regex.is_some(),
Expand Down Expand Up @@ -441,29 +478,8 @@ impl SiftMcpServer {
ErrorData::resource_not_found(format!("{asset_label} not found"), None)
})?;

let run = match run_name.as_deref() {
Some(name) => {
let filter = format!(
"name == \"{}\" && asset_id == \"{}\"",
cel_escape(name),
asset.asset_id,
);
let run = self
.run_service
.list_runs(filter, None, Some(1))
.await
.map_err(from_anyhow)?
.items
.into_iter()
.next()
.ok_or_else(|| {
ErrorData::resource_not_found(
format!("run '{name}' not found for asset '{}'", asset.name),
None,
)
})?;
Some(run)
}
let run = match run_selector {
Some(selector) => Some(self.resolve_run(&asset, selector).await?),
None => None,
};

Expand Down Expand Up @@ -576,7 +592,7 @@ impl SiftMcpServer {
}

let unresolved_report = (!unresolved.is_empty())
.then(|| unresolved_message(&unresolved, &asset.name, run_name.as_deref()));
.then(|| unresolved_message(&unresolved, &asset.name, run.as_ref()));

if channel_inputs.is_empty() {
return Err(ErrorData::resource_not_found(
Expand All @@ -598,6 +614,17 @@ impl SiftMcpServer {
.map(|entry| entry.name.clone())
.collect::<Vec<_>>();

// Only an explicit `run_id` reaches an archived run; say so in the
// result so the caller does not present it as the active one.
let run_id = run.as_ref().map(|run| run.run_id.clone());
let archived_note = run.as_ref().filter(|run| run.is_archived).map(|run| {
format!(
" Run `{}` (id `{}`) is ARCHIVED. Tell the user this data comes from an \
archived run.",
run.name, run.run_id,
)
});

let time_range = match run {
Some(run) => TimeRange::Run {
run: Box::new(run),
Expand Down Expand Up @@ -663,6 +690,9 @@ impl SiftMcpServer {

let mut next_step =
format!("Wrote channel data to `{output_str}`. Inform the user where the data lives.");
if let Some(note) = &archived_note {
next_step.push_str(note);
}
// The caller asked for a rate; the service reports what it actually
// applied, and the two are not the same thing for a data type it cannot
// sample. Saying so here is the difference between an agent that knows
Expand Down Expand Up @@ -727,6 +757,12 @@ impl SiftMcpServer {
if applied.mixed() {
body.insert("mixed_sample_rates".to_string(), Value::from(true));
}
if let Some(run_id) = run_id {
body.insert("run_id".to_string(), Value::from(run_id));
}
if archived_note.is_some() {
body.insert("run_archived".to_string(), Value::from(true));
}

if !unresolved.is_empty() {
body.insert(
Expand Down Expand Up @@ -969,16 +1005,117 @@ impl SiftMcpServer {
}
}

impl SiftMcpServer {
/// Resolve a run selector to a run on `asset`.
async fn resolve_run(&self, asset: &Asset, selector: RunSelector) -> Result<Run, ErrorData> {
match selector {
// An explicit id is honored even when the run is archived.
RunSelector::Id(id) => {
let filter = format!(
"run_id == \"{}\" && asset_id == \"{}\"",
cel_escape(&id),
asset.asset_id,
);
self.run_service
.list_runs(filter, None, Some(1))
.await
.map_err(from_anyhow)?
.items
.into_iter()
.next()
.ok_or_else(|| {
ErrorData::resource_not_found(
format!("run with id '{id}' not found for asset '{}'", asset.name),
None,
)
})
}
// Names are not unique. An archived duplicate must never stand in
// for the active run, and two active matches are ambiguous, so the
// lookup excludes archived runs and fetches two to detect a tie.
RunSelector::Name(name) => {
let filter = format!(
"name == \"{}\" && asset_id == \"{}\" && is_archived == false",
cel_escape(&name),
asset.asset_id,
);
let page = self
.run_service
.list_runs(filter, None, Some(2))
.await
.map_err(from_anyhow)?;
if page.items.len() > 1 {
let ids = page
.items
.iter()
.map(|r| r.run_id.clone())
.collect::<Vec<_>>();
let more = if page.has_more { " and more" } else { "" };
return Err(ErrorData::invalid_params(
format!(
"run name '{name}' matches more than one active run on asset '{}' \
(ids {}{more}); pick one with `list_runs` and pass it as `run_id`",
asset.name,
common::name_list(&ids),
),
None,
));
}
if let Some(run) = page.items.into_iter().next() {
return Ok(run);
}

// Point at an archived run only when one holds the name, so a
// typo does not send the caller after archived data. The check
// is best effort: if it fails, report the plain miss.
let archived_filter = format!(
"name == \"{}\" && asset_id == \"{}\" && is_archived == true",
cel_escape(&name),
asset.asset_id,
);
let archived = self
.run_service
.list_runs(archived_filter, None, Some(1))
.await
.ok()
.and_then(|page| page.items.into_iter().next());
let hint = match archived {
Some(run) => format!(
"; an archived run has that name (id '{}'). Archived runs are not matched \
by name; pass it as `run_id` only if the user asked for archived data",
run.run_id
),
None => "; check the name with `list_runs`".to_string(),
};
Err(ErrorData::resource_not_found(
format!(
"no active run named '{name}' found for asset '{}'{hint}",
asset.name
),
None,
))
}
}
}
}

/// One phrasing for calculated channels that yielded no data, shared by the
/// partial-result report and the all-unresolved error so the caller reads the
/// same wording either way.
fn unresolved_message(
unresolved: &[UnresolvedCalculation],
asset_name: &str,
run_name: Option<&str>,
run: Option<&Run>,
) -> String {
let scope = match run_name {
Some(run) => format!("asset '{asset_name}' (run '{run}')"),
// The id and archived flag tell an archived run from an active one that
// shares its name.
let scope = match run {
Some(run) => format!(
"asset '{asset_name}' ({}run '{}', id '{}')",
if run.is_archived { "archived " } else { "" },
run.name,
run.run_id,
),
None => format!("asset '{asset_name}'"),
};
let items = unresolved
Expand Down
Loading
Loading