Skip to content

Commit 3312e02

Browse files
committed
add list_reports tool
1 parent 960fe17 commit 3312e02

12 files changed

Lines changed: 667 additions & 5 deletions

File tree

rust/crates/sift_cli/assets/docs/src/agents/mcp.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ not run interactively.
2727
| `list_assets` | List assets, with filtering and ordering. |
2828
| `list_runs` | List runs, with filtering and ordering. |
2929
| `list_channels` | List channels for an asset. |
30+
| `list_reports` | List reports, with filtering and ordering. |
3031
| `get_data` | Download channel data for an asset/run to a Parquet file, with optional decimation. |
3132
| `sql` | Run SQL over one or more Parquet files; chain after `get_data` for analysis. |
3233
| `upload_dataset` | Stream a Parquet dataset into Sift. |

rust/crates/sift_cli/assets/skills/agents-md/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ to combine them when working with Sift.
1616

1717
1. **Sift MCP server** — started by `sift-cli mcp`. The preferred surface for
1818
agents. Exposes structured, authenticated tools:
19-
- `list_assets`, `list_runs`, `list_channels`: discover what exists.
19+
- `list_assets`, `list_runs`, `list_channels`, `list_reports`: discover what exists.
2020
- `get_data`: download channel data for an asset/run to a Parquet file.
2121
- `sql`: run SQL over one or more Parquet files (chain after `get_data`).
2222
- `upload_dataset`: stream a Parquet dataset into Sift.

rust/crates/sift_cli/assets/skills/claude-code/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ to combine them when working with Sift.
2828

2929
1. **Sift MCP server** — started by `sift-cli mcp`. The preferred surface for
3030
agents. Exposes structured, authenticated tools:
31-
- `list_assets`, `list_runs`, `list_channels`: discover what exists.
31+
- `list_assets`, `list_runs`, `list_channels`, `list_reports`: discover what exists.
3232
- `get_data`: download channel data for an asset/run to a Parquet file.
3333
- `sql`: run SQL over one or more Parquet files (chain after `get_data`).
3434
- `upload_dataset`: stream a Parquet dataset into Sift.

rust/crates/sift_mcp/src/server/mod.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use sift_rs::SiftChannel;
1111

1212
use crate::service::{
1313
assets::AssetService, channels::ChannelService, data::DataService, ingest::IngestService,
14-
runs::RunService,
14+
reports::ReportService, runs::RunService,
1515
};
1616

1717
#[derive(Clone)]
@@ -24,6 +24,7 @@ pub struct SiftMcpServer {
2424
pub data_service: DataService,
2525
pub ingest_service: IngestService,
2626
pub run_service: RunService,
27+
pub report_service: ReportService,
2728
}
2829

2930
#[tool_handler(
@@ -49,13 +50,15 @@ impl SiftMcpServer {
4950
let channel_service = ChannelService::new(channel.clone());
5051
let ingest_service = IngestService::new(channel.clone());
5152
let run_service = RunService::new(channel.clone());
53+
let report_service = ReportService::new(channel.clone());
5254

5355
Self {
5456
asset_service,
5557
channel_service,
5658
data_service,
5759
ingest_service,
5860
run_service,
61+
report_service,
5962
tool_router,
6063
prompt_router,
6164
}

rust/crates/sift_mcp/src/service/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ pub mod assets;
22
pub mod channels;
33
pub mod data;
44
pub mod ingest;
5+
pub mod reports;
56
pub mod runs;
67

78
pub(crate) mod common;
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
use crate::service::common;
2+
use anyhow::{Context, Result};
3+
use sift_rs::{
4+
SiftChannel,
5+
reports::v1::{
6+
ListReportsRequest, ListReportsResponse, Report, report_service_client::ReportServiceClient,
7+
},
8+
};
9+
10+
#[cfg(test)]
11+
mod test;
12+
13+
#[derive(Clone)]
14+
pub struct ReportService {
15+
channel: SiftChannel,
16+
}
17+
18+
impl ReportService {
19+
pub fn new(channel: SiftChannel) -> Self {
20+
Self { channel }
21+
}
22+
23+
pub async fn list_reports(
24+
&self,
25+
filter: String,
26+
order_by: Option<String>,
27+
limit: Option<u32>,
28+
organization_id: Option<String>,
29+
) -> Result<Vec<Report>> {
30+
let (page_size, record_limit) = common::paging(limit);
31+
32+
let mut client = ReportServiceClient::new(self.channel.clone());
33+
let mut page_token = String::new();
34+
let mut results = Vec::new();
35+
36+
loop {
37+
let resp = client
38+
.list_reports(ListReportsRequest {
39+
filter: filter.clone(),
40+
page_size,
41+
page_token,
42+
order_by: order_by.clone().unwrap_or_default(),
43+
organization_id: organization_id.clone().unwrap_or_default(),
44+
})
45+
.await
46+
.context("failed to query reports")?;
47+
48+
let ListReportsResponse {
49+
reports,
50+
next_page_token,
51+
} = resp.into_inner();
52+
if reports.is_empty() {
53+
break;
54+
}
55+
results.extend(reports);
56+
57+
if results.len() >= record_limit || next_page_token.is_empty() {
58+
break;
59+
}
60+
page_token = next_page_token;
61+
}
62+
63+
results.truncate(record_limit);
64+
65+
Ok(results)
66+
}
67+
}
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
use sift_rs::reports::v1::{
2+
ListReportsResponse, Report, report_service_server::ReportServiceServer,
3+
};
4+
use sift_test_util::{grpc::memory_sift_channel, mock::reports::v1::MockReportServiceImpl};
5+
use tokio::task::JoinHandle;
6+
use tonic::{Response, Status, transport::Server};
7+
8+
use super::ReportService;
9+
use crate::service::common::PAGE_SIZE;
10+
11+
async fn service_with_mock(mock: MockReportServiceImpl) -> (ReportService, JoinHandle<()>) {
12+
let (client, server) = tokio::io::duplex(1024);
13+
let channel = memory_sift_channel(client).await;
14+
15+
let handle = tokio::spawn(async move {
16+
Server::builder()
17+
.add_service(ReportServiceServer::new(mock))
18+
.serve_with_incoming(tokio_stream::once(Ok::<_, std::io::Error>(server)))
19+
.await
20+
.unwrap();
21+
});
22+
23+
(ReportService::new(channel), handle)
24+
}
25+
26+
#[tokio::test]
27+
async fn list_reports_returns_single_page() {
28+
let mut mock = MockReportServiceImpl::new();
29+
mock.expect_list_reports()
30+
.withf(|req| req.get_ref().filter == "name == \"nightly\"")
31+
.returning(|_| {
32+
Ok(Response::new(ListReportsResponse {
33+
reports: vec![Report {
34+
report_id: "rep1".into(),
35+
name: "nightly".into(),
36+
..Default::default()
37+
}],
38+
next_page_token: String::new(),
39+
}))
40+
});
41+
42+
let (service, _h) = service_with_mock(mock).await;
43+
44+
let reports = service
45+
.list_reports("name == \"nightly\"".to_string(), None, None, None)
46+
.await
47+
.expect("list_reports failed");
48+
49+
assert_eq!(reports.len(), 1);
50+
assert_eq!(reports[0].report_id, "rep1");
51+
}
52+
53+
#[tokio::test]
54+
async fn list_reports_forwards_organization_id() {
55+
let mut mock = MockReportServiceImpl::new();
56+
mock.expect_list_reports()
57+
.withf(|req| req.get_ref().organization_id == "org-123")
58+
.returning(|_| {
59+
Ok(Response::new(ListReportsResponse {
60+
reports: vec![Report {
61+
report_id: "rep1".into(),
62+
..Default::default()
63+
}],
64+
next_page_token: String::new(),
65+
}))
66+
});
67+
68+
let (service, _h) = service_with_mock(mock).await;
69+
70+
let reports = service
71+
.list_reports(String::new(), None, None, Some("org-123".to_string()))
72+
.await
73+
.expect("list_reports failed");
74+
75+
assert_eq!(reports.len(), 1);
76+
}
77+
78+
#[tokio::test]
79+
async fn list_reports_paginates_until_token_empty() {
80+
let mut mock = MockReportServiceImpl::new();
81+
mock.expect_list_reports().returning(|req| {
82+
let req = req.into_inner();
83+
assert_eq!(req.page_size, PAGE_SIZE);
84+
let (reports, next) = match req.page_token.as_str() {
85+
"" => (
86+
vec![Report {
87+
report_id: "rep1".into(),
88+
..Default::default()
89+
}],
90+
"page-2".to_string(),
91+
),
92+
"page-2" => (
93+
vec![Report {
94+
report_id: "rep2".into(),
95+
..Default::default()
96+
}],
97+
String::new(),
98+
),
99+
other => return Err(Status::invalid_argument(format!("bad token: {other}"))),
100+
};
101+
Ok(Response::new(ListReportsResponse {
102+
reports,
103+
next_page_token: next,
104+
}))
105+
});
106+
107+
let (service, _h) = service_with_mock(mock).await;
108+
109+
let reports = service
110+
.list_reports(String::new(), None, None, None)
111+
.await
112+
.expect("list_reports failed");
113+
114+
let ids: Vec<&str> = reports.iter().map(|r| r.report_id.as_str()).collect();
115+
assert_eq!(ids, vec!["rep1", "rep2"]);
116+
}
117+
118+
#[tokio::test]
119+
async fn list_reports_respects_limit() {
120+
let mut mock = MockReportServiceImpl::new();
121+
mock.expect_list_reports().times(1).returning(|req| {
122+
let req = req.into_inner();
123+
assert_eq!(req.page_size, 2);
124+
Ok(Response::new(ListReportsResponse {
125+
reports: vec![
126+
Report {
127+
report_id: "rep1".into(),
128+
..Default::default()
129+
},
130+
Report {
131+
report_id: "rep2".into(),
132+
..Default::default()
133+
},
134+
],
135+
next_page_token: "page-2".into(),
136+
}))
137+
});
138+
139+
let (service, _h) = service_with_mock(mock).await;
140+
141+
let reports = service
142+
.list_reports(String::new(), None, Some(2), None)
143+
.await
144+
.expect("list_reports failed");
145+
146+
assert_eq!(reports.len(), 2);
147+
}
148+
149+
#[tokio::test]
150+
async fn list_reports_truncates_to_limit_across_pages() {
151+
let mut mock = MockReportServiceImpl::new();
152+
mock.expect_list_reports().returning(|req| {
153+
let req = req.into_inner();
154+
assert_eq!(req.page_size, 3);
155+
let (reports, next) = match req.page_token.as_str() {
156+
"" => (
157+
vec![
158+
Report {
159+
report_id: "rep1".into(),
160+
..Default::default()
161+
},
162+
Report {
163+
report_id: "rep2".into(),
164+
..Default::default()
165+
},
166+
],
167+
"page-2".to_string(),
168+
),
169+
"page-2" => (
170+
vec![
171+
Report {
172+
report_id: "rep3".into(),
173+
..Default::default()
174+
},
175+
Report {
176+
report_id: "rep4".into(),
177+
..Default::default()
178+
},
179+
],
180+
String::new(),
181+
),
182+
other => return Err(Status::invalid_argument(format!("bad token: {other}"))),
183+
};
184+
Ok(Response::new(ListReportsResponse {
185+
reports,
186+
next_page_token: next,
187+
}))
188+
});
189+
190+
let (service, _h) = service_with_mock(mock).await;
191+
192+
let reports = service
193+
.list_reports(String::new(), None, Some(3), None)
194+
.await
195+
.expect("list_reports failed");
196+
197+
let ids: Vec<&str> = reports.iter().map(|r| r.report_id.as_str()).collect();
198+
assert_eq!(ids, vec!["rep1", "rep2", "rep3"]);
199+
}
200+
201+
#[tokio::test]
202+
async fn list_reports_breaks_on_empty_page() {
203+
let mut mock = MockReportServiceImpl::new();
204+
mock.expect_list_reports().times(1).returning(|_| {
205+
Ok(Response::new(ListReportsResponse {
206+
reports: vec![],
207+
next_page_token: "ignored".into(),
208+
}))
209+
});
210+
211+
let (service, _h) = service_with_mock(mock).await;
212+
213+
let reports = service
214+
.list_reports(String::new(), None, None, None)
215+
.await
216+
.expect("list_reports failed");
217+
218+
assert!(reports.is_empty());
219+
}
220+
221+
#[tokio::test]
222+
async fn list_reports_propagates_grpc_error() {
223+
let mut mock = MockReportServiceImpl::new();
224+
mock.expect_list_reports()
225+
.returning(|_| Err(Status::not_found("no such report")));
226+
227+
let (service, _h) = service_with_mock(mock).await;
228+
229+
let err = service
230+
.list_reports(String::new(), None, None, None)
231+
.await
232+
.expect_err("expected error");
233+
234+
assert!(err.to_string().contains("failed to query reports"));
235+
}

0 commit comments

Comments
 (0)