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
10 changes: 6 additions & 4 deletions rust/crates/sift_cli/CHANGELOG.md
Comment thread
lineville marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ This project adheres to [Semantic Versioning](http://semver.org/).

### What's New

## [v0.5.0] - August 26, 2026

### What's New

- Explore links now carry one source type. The MCP `explore_url` tool accepts
only `asset_ids` and `run_ids`, and rejects them together with
`INVALID_PARAMS` unless the new `include_assets_and_runs` flag is set. This
prevents ambiguous name resolution and stops an agent from mixing an asset
and run into one view on its own. `sift-cli import` links to the run it
imported into, and falls back to the asset only when there is no run.
- Added MCP tools for managing calculated channels: `list_calculated_channels`,
`list_calculated_channel_versions`, `create_calculated_channel`,
`update_calculated_channel`, `archive_calculated_channel`, and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@
Build the link with `explore_url`, then surface the URL to the user as plain
text, in full.

Send one source type. Pass `run_ids` when the request names a run and
`asset_ids` otherwise. Use IDs returned by `list_runs` and `list_assets`; the
tool does not accept names. A run is already scoped to its asset, so adding the
asset opens the view on a second, wider source. `explore_url` rejects both with
`INVALID_PARAMS`; set `include_assets_and_runs` to true only when the user asked
to see runs and assets together in one view.

Pick the `panel_type` that fits the request: `timeseries` (the default),
`histogram`, `table`, `fft`, `metrics`, `scatter-plot`, or `geo-map`. The tool
rejects an unknown value with `INVALID_PARAMS` and names the accepted set in
Expand Down
35 changes: 21 additions & 14 deletions rust/crates/sift_cli/src/util/explore_url.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,18 +54,21 @@ pub fn pending_import_tip(location: &str, explore_url: Option<&str>) -> String {
tip
}

/// Builds a link to a single Explore data source: the run when the import targets one, and the
/// asset otherwise. A run is already scoped to its asset, so naming both would open the view on
/// two sources instead of the one the caller imported into.
pub fn build_explore_url(
app_uri: Option<&str>,
asset_name: &str,
run: Option<&str>,
) -> Option<String> {
let host = app_uri.and_then(normalize_app_uri)?;

let mut url = format!("{host}/explore?method=single&assets={}", encode(asset_name));
if let Some(run) = run {
url.push_str(&format!("&runs={}", encode(run)));
}
Some(url)
let source = match run {
Some(run) => format!("runs={}", encode(run)),
None => format!("assets={}", encode(asset_name)),
};
Some(format!("{host}/explore?method=single&{source}"))
}

#[cfg(test)]
Expand All @@ -82,9 +85,16 @@ mod tests {
);
assert_eq!(
target.explore_url.as_deref(),
Some(
"https://sift.example.net/explore?method=single&assets=Engine%20%2F%207&runs=Test%20Run"
)
Some("https://sift.example.net/explore?method=single&runs=Test%20Run")
);
}

#[test]
fn an_import_without_a_run_links_to_the_asset() {
let target = import_target("Engine / 7", None, None, Some("https://sift.example.net"));
assert_eq!(
target.explore_url.as_deref(),
Some("https://sift.example.net/explore?method=single&assets=Engine%20%2F%207")
);
}

Expand All @@ -96,12 +106,9 @@ mod tests {
Some("run-id"),
Some("https://app.siftstack.com"),
);
assert!(
target
.explore_url
.as_deref()
.unwrap()
.ends_with("&runs=run-id")
assert_eq!(
target.explore_url.as_deref(),
Some("https://app.siftstack.com/explore?method=single&runs=run-id")
);
}

Expand Down
32 changes: 24 additions & 8 deletions rust/crates/sift_mcp/src/service/url/mod.rs
Comment thread
lineville marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,13 @@ const VALUE_ENCODE_SET: &percent_encoding::AsciiSet = &percent_encoding::NON_ALP

#[derive(Debug, Default)]
pub struct ExploreUrlRequest {
pub assets: Option<Vec<String>>,
pub runs: Option<Vec<String>>,
pub asset_ids: Option<Vec<String>>,
pub run_ids: Option<Vec<String>>,
pub channels: Option<Vec<String>>,
pub panel_type: Option<String>,
pub start_time_unix_nanos: Option<i64>,
pub end_time_unix_nanos: Option<i64>,
pub include_assets_and_runs: bool,
}

#[derive(Clone)]
Expand All @@ -42,16 +43,20 @@ impl UrlService {

pub fn build_explore_url(&self, request: ExploreUrlRequest) -> Result<String, ErrorData> {
let ExploreUrlRequest {
assets,
runs,
asset_ids,
run_ids,
channels,
panel_type,
start_time_unix_nanos,
end_time_unix_nanos,
include_assets_and_runs,
} = request;

let no_selection = assets.as_ref().is_none_or(|v| v.is_empty())
&& runs.as_ref().is_none_or(|v| v.is_empty())
let has_assets = asset_ids.as_ref().is_some_and(|v| !v.is_empty());
let has_runs = run_ids.as_ref().is_some_and(|v| !v.is_empty());

let no_selection = !has_assets
&& !has_runs
&& channels.as_ref().is_none_or(|v| v.is_empty())
&& panel_type.is_none()
&& start_time_unix_nanos.is_none()
Expand All @@ -64,6 +69,17 @@ impl UrlService {
));
}

if has_assets && has_runs && !include_assets_and_runs {
return Err(ErrorData::invalid_params(
"`asset_ids` and `run_ids` were both set. An Explore link that mixes \
asset-scoped and run-scoped sources is not the default: pass `run_ids` alone \
when the request names a run, or `asset_ids` alone otherwise. Set \
`include_assets_and_runs` to true only when the user explicitly asked to see \
runs and assets together in one view.",
None,
));
}

if let (Some(start), Some(end)) = (start_time_unix_nanos, end_time_unix_nanos)
&& end < start
{
Expand All @@ -88,11 +104,11 @@ impl UrlService {
let host = self.app_host()?;

let mut query = String::from("method=single");
if let Some(v) = assets.as_ref().filter(|v| !v.is_empty()) {
if let Some(v) = asset_ids.as_ref().filter(|v| !v.is_empty()) {
query.push_str("&assets=");
query.push_str(&join_encoded(v));
}
if let Some(v) = runs.as_ref().filter(|v| !v.is_empty()) {
if let Some(v) = run_ids.as_ref().filter(|v| !v.is_empty()) {
query.push_str("&runs=");
query.push_str(&join_encoded(v));
}
Expand Down
82 changes: 74 additions & 8 deletions rust/crates/sift_mcp/src/service/url/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,92 @@ fn service() -> UrlService {
fn full_url_with_all_params() {
let url = service()
.build_explore_url(ExploreUrlRequest {
assets: Some(vec![String::from("Engine-7")]),
runs: Some(vec![String::from("2025-thrust-test")]),
asset_ids: Some(vec![String::from("asset-id")]),
run_ids: Some(vec![String::from("run-id")]),
channels: Some(vec![String::from("temperature"), String::from("pressure")]),
panel_type: Some(String::from("scatter-plot")),
start_time_unix_nanos: Some(0),
end_time_unix_nanos: Some(1_700_000_000_000_000_000),
include_assets_and_runs: true,
})
.unwrap();
assert_eq!(
url,
"https://app.siftstack.com/explore?method=single\
&assets=Engine-7\
&runs=2025-thrust-test\
&assets=asset-id\
&runs=run-id\
&channels=temperature,pressure\
&panelType=scatter-plot\
&startTime=1970-01-01T00:00:00.000Z\
&endTime=2023-11-14T22:13:20.000Z"
);
}

#[test]
fn assets_and_runs_together_are_rejected_by_default() {
let err = service()
.build_explore_url(ExploreUrlRequest {
asset_ids: Some(vec![String::from("asset-id")]),
run_ids: Some(vec![String::from("run-id")]),
..Default::default()
})
.unwrap_err();
assert_eq!(err.code.0, -32602);
assert!(
err.message.contains("include_assets_and_runs"),
"the error should name the opt-in, got `{}`",
err.message
);
}

#[test]
fn assets_and_runs_together_are_allowed_when_requested() {
let url = service()
.build_explore_url(ExploreUrlRequest {
asset_ids: Some(vec![String::from("asset-id")]),
run_ids: Some(vec![String::from("run-id")]),
include_assets_and_runs: true,
..Default::default()
})
.unwrap();
assert_eq!(
url,
"https://app.siftstack.com/explore?method=single\
&assets=asset-id\
&runs=run-id"
);
}

#[test]
fn an_empty_asset_list_does_not_conflict_with_runs() {
let url = service()
.build_explore_url(ExploreUrlRequest {
asset_ids: Some(vec![]),
run_ids: Some(vec![String::from("run-id")]),
..Default::default()
})
.unwrap();
assert_eq!(
url,
"https://app.siftstack.com/explore?method=single&runs=run-id"
);
}

#[test]
fn the_opt_in_is_ignored_for_a_single_source_type() {
let url = service()
.build_explore_url(ExploreUrlRequest {
run_ids: Some(vec![String::from("run-id")]),
include_assets_and_runs: true,
..Default::default()
})
.unwrap();
assert_eq!(
url,
"https://app.siftstack.com/explore?method=single&runs=run-id"
);
}

#[test]
fn axis_prefix_colon_is_preserved() {
let url = service()
Expand Down Expand Up @@ -62,7 +128,7 @@ fn comma_inside_single_value_is_encoded() {
fn unknown_panel_type_is_rejected() {
let err = service()
.build_explore_url(ExploreUrlRequest {
assets: Some(vec![String::from("a")]),
asset_ids: Some(vec![String::from("asset-id")]),
panel_type: Some(String::from("bogus")),
..Default::default()
})
Expand All @@ -83,8 +149,8 @@ fn empty_request_is_rejected() {
fn empty_vecs_are_treated_as_missing() {
let err = service()
.build_explore_url(ExploreUrlRequest {
assets: Some(vec![]),
runs: Some(vec![]),
asset_ids: Some(vec![]),
run_ids: Some(vec![]),
channels: Some(vec![]),
..Default::default()
})
Expand All @@ -97,7 +163,7 @@ fn configured_app_uri_trims_a_trailing_slash() {
let svc = UrlService::new(String::from("https://sift.example.net/"));
let url = svc
.build_explore_url(ExploreUrlRequest {
assets: Some(vec![String::from("a")]),
asset_ids: Some(vec![String::from("asset-id")]),
..Default::default()
})
.unwrap();
Expand Down
35 changes: 23 additions & 12 deletions rust/crates/sift_mcp/src/tool/explore/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,13 @@ mod test;

#[derive(Debug, Deserialize, JsonSchema)]
pub struct ExploreUrlParams {
assets: Option<Vec<String>>,
runs: Option<Vec<String>>,
asset_ids: Option<Vec<String>>,
run_ids: Option<Vec<String>>,
channels: Option<Vec<String>>,
panel_type: Option<String>,
start_time_unix_nanos: Option<i64>,
end_time_unix_nanos: Option<i64>,
include_assets_and_runs: Option<bool>,
}

#[tool_router(router = explore_router, vis = "pub(crate)")]
Expand All @@ -35,8 +36,12 @@ impl SiftMcpServer {
instructs you on how to surface it.

Parameters:
- `assets`: optional list of asset names or UUIDs. The Explore service resolves either form.
- `runs`: optional list of run names or UUIDs. Same resolution rules as `assets`.
- `asset_ids`: optional list of asset IDs returned by `list_assets`.
- `run_ids`: optional list of run IDs returned by `list_runs`.
- `include_assets_and_runs`: optional, defaults to false. A link carries one source type by
default: pass `run_ids` when the request names a run, `asset_ids` otherwise. Setting both
`asset_ids` and `run_ids` without this flag is rejected. Set it to true only when the user
explicitly asked to see runs and assets together in one view. Ignored unless both are set.
- `channels`: optional list of channel names, UUIDs, or prefixed forms. Axis prefixes (`L1:foo`,
`L2:bar`) bind a channel to a Y-axis for multi-axis plots. Role prefixes (`x:foo`, `y:foo`,
`color:foo` for scatter; `lat:foo`, `lon:foo`, `color:foo` for geo-map) bind a channel to a panel
Expand All @@ -46,35 +51,41 @@ impl SiftMcpServer {
- `start_time_unix_nanos`, `end_time_unix_nanos`: optional time window. Provided as Unix nanoseconds
for parity with `get_data`; the tool converts to ISO 8601 UTC for the URL.
Errors:
- `INVALID_PARAMS` if no selection or time parameter is set (the URL would be useless), if
`panel_type` is not in the known set, or if `end_time_unix_nanos < start_time_unix_nanos`.
- `INVALID_PARAMS` if no selection or time parameter is set (the URL would be useless), if both
`asset_ids` and `run_ids` are set without `include_assets_and_runs`, if `panel_type` is not in the
known set, or if `end_time_unix_nanos < start_time_unix_nanos`.

Guidance:
- Reach for this tool when the user asks to \"see\", \"view\", \"graph\", \"plot\", \"visualize\", or
\"open\" data in Sift. Pair it with `get_data` only when the user also wants the data locally for
SQL or further processing.
- The tool does not validate that the named asset/run/channel exists — Explore resolves at page
load. Use names you have already retrieved from `list_*` tools to avoid 404s on click.
- Do not add the asset alongside a run to be thorough. A run is already scoped to its asset, so
the asset adds a second, wider source to the view. Send both only on an explicit request for
both.
- The tool does not validate that the provided asset/run/channel exists — Explore resolves at page
load. Use IDs and channel names you have already retrieved from `list_*` tools to avoid 404s.
",
annotations(title = "explore/explore_url", read_only_hint = true)
)]
pub async fn explore_url(&self, params: Parameters<ExploreUrlParams>) -> error::McpResult {
let Parameters(ExploreUrlParams {
assets,
runs,
asset_ids,
run_ids,
channels,
panel_type,
start_time_unix_nanos,
end_time_unix_nanos,
include_assets_and_runs,
}) = params;

let url = self.url_service.build_explore_url(ExploreUrlRequest {
assets,
runs,
asset_ids,
run_ids,
channels,
panel_type,
start_time_unix_nanos,
end_time_unix_nanos,
include_assets_and_runs: include_assets_and_runs.unwrap_or(false),
})?;

let next_step = format!(
Expand Down
Loading
Loading