Skip to content
Open
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
6 changes: 6 additions & 0 deletions rust/crates/sift_cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ This project adheres to [Semantic Versioning](http://semver.org/).

### What's New

- New `list_file_attachments` and `download_file_attachment` tools let agents
find the files attached to runs, assets, annotations and their comments, test
reports, and test steps, and fetch each one through a short-lived download
URL. Agents used to
send users to the run page to download these by hand.

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

### What's New
Expand Down
11 changes: 10 additions & 1 deletion rust/crates/sift_cli/assets/skills/sift/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,22 @@ tool.
connectivity check; when it fails, expect every other Sift tool to fail too.
- **Discovery:** `list_assets`, `list_runs`, `list_channels`, `list_reports`,
`list_report_templates`, `list_rules`, `list_rule_versions`, `list_annotations`,
`list_artifacts`, `list_artifact_versions`.
`list_artifacts`, `list_artifact_versions`, `list_file_attachments`.
- **Artifacts:** `download_artifact` (latest version, or pin `artifact_version_id`),
`list_artifact_versions` for one artifact's history, newest first.
The artifact tools, including `list_artifacts` and the artifact writes below,
are enabled per account by the agents feature flag resolved when the MCP
server starts, so they may be absent from the tool list. Enabling them
requires an account setting and an MCP restart.
- **File attachments:** `list_file_attachments` lists the files attached to
runs, assets, annotations and their comments, test reports, and test steps;
scope it with `entity_ids`. `download_file_attachment` returns a short-lived signed URL
for one file that you can fetch without Sift credentials. Use these when a
user asks for a file attached to a run or asset, instead of sending them to
the run page to download it. Artifact files never appear there, so when the
user wants everything on a run or asset, also call `list_artifacts` with
`links.exists(l, l.entity_type == "RUN" && l.entity_id == "<run id>")`
(`"ASSET"` for an asset).
- **Derived channels:** `list_calculated_channels`,
`list_calculated_channel_versions`, `list_user_defined_functions`,
`list_user_defined_function_versions`.
Expand Down
6 changes: 5 additions & 1 deletion rust/crates/sift_mcp/src/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ pub(crate) const BASE_INSTRUCTIONS: &str = concat!(
use crate::service::{
annotations::AnnotationService, artifacts::ArtifactService, assets::AssetService,
calculated_channels::CalculatedChannelService, channels::ChannelService, data::DataService,
docs::DocsService, ingest::IngestService, ping::PingService,
docs::DocsService, ingest::IngestService, ping::PingService, remote_files::RemoteFileService,
report_templates::ReportTemplateService, reports::ReportService,
rule_evaluation::RuleEvaluationService, rules::RuleService, runs::RunService,
test_reports::TestReportService, url::UrlService,
Expand All @@ -69,6 +69,7 @@ pub struct SiftMcpServer {
pub url_service: UrlService,
pub ingest_service: IngestService,
pub ping_service: PingService,
pub remote_file_service: RemoteFileService,
pub run_service: RunService,
pub report_service: ReportService,
pub report_template_service: ReportTemplateService,
Expand Down Expand Up @@ -230,6 +231,7 @@ impl SiftMcpServer {
tool_router.merge(Self::rule_evaluation_router());
tool_router.merge(Self::annotations_router());
tool_router.merge(Self::artifacts_router());
tool_router.merge(Self::file_attachments_router());
tool_router.merge(Self::test_reports_router());
tool_router.merge(Self::docs_router());
tool_router.merge(Self::user_defined_functions_router());
Expand Down Expand Up @@ -289,6 +291,7 @@ impl SiftMcpServer {
let url_service = UrlService::new(app_uri);
let ingest_service = IngestService::new(channel.clone());
let ping_service = PingService::new(channel.clone(), retry_policy.clone());
let remote_file_service = RemoteFileService::new(channel.clone(), retry_policy.clone());
let run_service = RunService::new(channel.clone(), retry_policy.clone());
let report_service = ReportService::new(channel.clone(), retry_policy.clone());
let report_template_service =
Expand All @@ -312,6 +315,7 @@ impl SiftMcpServer {
url_service,
ingest_service,
ping_service,
remote_file_service,
run_service,
report_service,
report_template_service,
Expand Down
41 changes: 11 additions & 30 deletions rust/crates/sift_mcp/src/service/artifacts/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use anyhow::{Context, Result, anyhow, bail};
use anyhow::{Context, Result, anyhow};
use sift_rs::{
SiftChannel,
artifacts::v1::{
Expand All @@ -9,16 +9,13 @@ use sift_rs::{
artifact_service_client::ArtifactServiceClient,
},
metadata::v1::MetadataValue,
remote_files::v1::{
GetRemoteFileDownloadUrlRequest, remote_file_service_client::RemoteFileServiceClient,
},
};

use std::path::Path;

use crate::policy::{RetryPolicy, once, with_retry};
use crate::service::common;
use crate::service::remote_files::RemoteFileUploader;
use crate::service::remote_files::{RemoteFileService, RemoteFileUploader};

#[cfg(test)]
mod test;
Expand Down Expand Up @@ -103,6 +100,7 @@ impl UpdatePath {
pub struct ArtifactService {
channel: SiftChannel,
policy: RetryPolicy,
remote_files: RemoteFileService,
// Absent only when the server runs without a REST endpoint (some tests);
// creating an artifact with a file requires it.
uploader: Option<RemoteFileUploader>,
Expand All @@ -111,6 +109,7 @@ pub struct ArtifactService {
impl ArtifactService {
pub fn new(channel: SiftChannel, policy: RetryPolicy) -> Self {
Self {
remote_files: RemoteFileService::new(channel.clone(), policy.clone()),
channel,
policy,
uploader: None,
Expand Down Expand Up @@ -259,7 +258,12 @@ impl ArtifactService {
.as_ref()
.and_then(|version| version.remote_file_id.clone());
let download_url = match remote_file_id {
Some(remote_file_id) => Some(self.download_url(remote_file_id).await?),
Some(remote_file_id) => Some(
self.remote_files
.download_url(remote_file_id)
.await
.context("failed to get artifact download url")?,
),
None => None,
};
Ok(ArtifactView {
Expand Down Expand Up @@ -451,7 +455,7 @@ impl ArtifactService {
.as_ref()
.and_then(|version| version.remote_file_id.clone());
let download_url = match remote_file_id {
Some(remote_file_id) => self.download_url(remote_file_id).await.ok(),
Some(remote_file_id) => self.remote_files.download_url(remote_file_id).await.ok(),
None => None,
};
Ok(ArtifactView {
Expand Down Expand Up @@ -486,27 +490,4 @@ impl ArtifactService {
.artifact
.ok_or_else(|| anyhow!("get artifact response missing artifact"))
}

async fn download_url(&self, remote_file_id: String) -> Result<String> {
let channel = self.channel.clone();
let resp = with_retry(&self.policy, move || {
let channel = channel.clone();
let remote_file_id = remote_file_id.clone();
async move {
let mut client = RemoteFileServiceClient::new(channel);
client
.get_remote_file_download_url(GetRemoteFileDownloadUrlRequest {
remote_file_id,
})
.await
.map(|resp| resp.into_inner())
}
})
.await
.context("failed to get artifact download url")?;
if resp.download_url.is_empty() {
bail!("download url response was empty");
}
Ok(resp.download_url)
}
}
10 changes: 9 additions & 1 deletion rust/crates/sift_mcp/src/service/artifacts/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,15 @@ async fn download_artifact_rejects_empty_download_url() {
.download_artifact("art-1".into(), None)
.await
.expect_err("empty url is an error");
assert!(err.to_string().contains("download url response was empty"));
let message = format!("{err:#}");
assert!(
message.contains("failed to get artifact download url"),
"{message}"
);
assert!(
message.contains("download url response was empty"),
"{message}"
);
}

#[tokio::test]
Expand Down
183 changes: 183 additions & 0 deletions rust/crates/sift_mcp/src/service/remote_files/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,17 @@ use std::time::Duration;

use anyhow::{Context, Result, bail};
use reqwest::header::USER_AGENT;
use sift_rs::{
SiftChannel,
remote_files::v1::{
GetRemoteFileDownloadUrlRequest, ListRemoteFilesRequest, ListRemoteFilesResponse,
RemoteFile, remote_file_service_client::RemoteFileServiceClient,
},
};

use crate::ClientName;
use crate::policy::{RetryPolicy, with_retry};
use crate::service::common;

const UPLOAD_PATH: &str = "/api/v0/remote-files/upload";
/// Client-side cap on one uploaded file. The server allows more, but an
Expand All @@ -21,6 +30,180 @@ const FALLBACK_MIME: &str = "application/octet-stream";
/// looser than the interactive-call default.
const UPLOAD_TIMEOUT: Duration = Duration::from_secs(10 * 60);

/// The entity types users attach files to. Remote files on other entities
/// (artifact versions, chat uploads, canvas code) are not attachments and stay
/// out of every listing.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AttachmentEntityType {
Run,
Asset,
Annotation,
/// A comment on an annotation.
AnnotationLog,
TestReport,
TestStep,
}

impl AttachmentEntityType {
pub const ALL: [Self; 6] = [
Self::Run,
Self::Asset,
Self::Annotation,
Self::AnnotationLog,
Self::TestReport,
Self::TestStep,
];

/// The singular name callers use.
pub fn name(self) -> &'static str {
match self {
Self::Run => "run",
Self::Asset => "asset",
Self::Annotation => "annotation",
Self::AnnotationLog => "annotation_log",
Self::TestReport => "test_report",
Self::TestStep => "test_step",
}
}

/// The value the `ListRemoteFiles` filter compares `entity_type` against:
/// the backend's table name, not the `ENTITY_TYPE_*` name listed rows carry.
pub fn filter_value(self) -> &'static str {
match self {
Self::Run => "runs",
Self::Asset => "assets",
Self::Annotation => "annotations",
Self::AnnotationLog => "annotation_logs",
Self::TestReport => "test_reports",
Self::TestStep => "test_steps",
}
}
}

/// ANDs the scope onto the caller's CEL `filter`. With no `entity_type`, the
/// listing is held to every [`AttachmentEntityType`].
fn attachment_filter(
entity_ids: &[String],
entity_type: Option<AttachmentEntityType>,
filter: &str,
) -> String {
let mut clauses = Vec::new();
if !entity_ids.is_empty() {
let ids = entity_ids
.iter()
.map(|id| format!("\"{}\"", common::cel_escape(id.trim())))
.collect::<Vec<_>>()
.join(", ");
clauses.push(format!("entity_id in [{ids}]"));
}
match entity_type {
Some(entity_type) => {
clauses.push(format!("entity_type == \"{}\"", entity_type.filter_value()))
}
// The backend fails `entity_type in [...]` with an internal error, so
// the type set is spelled as `==` alternatives.
None => clauses.push(format!(
"({})",
AttachmentEntityType::ALL
.iter()
.map(|entity_type| format!("entity_type == \"{}\"", entity_type.filter_value()))
.collect::<Vec<_>>()
.join(" || ")
)),
}
let filter = filter.trim();
if !filter.is_empty() {
clauses.push(format!("({filter})"));
}
clauses.join(" && ")
}

/// Reads remote files over gRPC: the files attached to runs, assets, and other
/// entities, and the bytes behind artifact versions.
#[derive(Clone)]
pub struct RemoteFileService {
channel: SiftChannel,
policy: RetryPolicy,
}

impl RemoteFileService {
pub fn new(channel: SiftChannel, policy: RetryPolicy) -> Self {
Self { channel, policy }
}

/// Lists the attachments matching the scope and the caller's CEL `filter`,
/// held to [`AttachmentEntityType`] even when no type is given.
///
/// Makes one request only. The backend's page tokens do not advance past
/// the first page, so following one returns the same rows again. A page can
/// also come back short, or empty, with a token when the caller can't view
/// some of its files, so `has_more` comes from the token, not the row count.
pub async fn list_attachments(
&self,
entity_ids: Vec<String>,
entity_type: Option<AttachmentEntityType>,
filter: String,
limit: Option<u32>,
) -> Result<common::Page<RemoteFile>> {
let (page_size, record_limit) = common::paging(limit);
let filter = attachment_filter(&entity_ids, entity_type, &filter);

let channel = self.channel.clone();
let resp = with_retry(&self.policy, move || {
let channel = channel.clone();
let filter = filter.clone();
async move {
let mut client = RemoteFileServiceClient::new(channel);
client
.list_remote_files(ListRemoteFilesRequest {
page_size,
filter,
..Default::default()
})
.await
.map(|resp| resp.into_inner())
}
})
.await
.context("failed to query file attachments")?;

let ListRemoteFilesResponse {
mut remote_files,
next_page_token,
} = resp;
let has_more = remote_files.len() > record_limit || !next_page_token.is_empty();
remote_files.truncate(record_limit);
Ok(common::Page {
items: remote_files,
has_more,
})
}

/// Mints a short-lived signed URL for one remote file's bytes.
pub async fn download_url(&self, remote_file_id: String) -> Result<String> {
let channel = self.channel.clone();
let resp = with_retry(&self.policy, move || {
let channel = channel.clone();
let remote_file_id = remote_file_id.clone();
async move {
let mut client = RemoteFileServiceClient::new(channel);
client
.get_remote_file_download_url(GetRemoteFileDownloadUrlRequest {
remote_file_id,
})
.await
.map(|resp| resp.into_inner())
}
})
.await
.context("failed to get remote file download url")?;
if resp.download_url.is_empty() {
bail!("download url response was empty");
}
Ok(resp.download_url)
}
}

/// REST endpoint and credential for calls the gRPC surface does not offer.
/// Today that is one thing: the multipart remote-file upload that gives an
/// artifact version its bytes.
Expand Down
Loading
Loading