diff --git a/rust/crates/sift_cli/CHANGELOG.md b/rust/crates/sift_cli/CHANGELOG.md index 59dea0a8a2..77d3f0f027 100644 --- a/rust/crates/sift_cli/CHANGELOG.md +++ b/rust/crates/sift_cli/CHANGELOG.md @@ -11,6 +11,10 @@ This project adheres to [Semantic Versioning](http://semver.org/). `source` for the run or asset an artifact was built from, and `derived_from` only for another artifact. Agents no longer send `derived_from` for a run and get the call rejected. +- `create_report_template` and `update_report_template` now return a + `report_template_url` link to the template, and `list_report_templates` adds a + `url` to each template. Agents no longer link a new template to a report's + page. ## [v0.7.3] - October 7, 2026 diff --git a/rust/crates/sift_mcp/src/service/url/mod.rs b/rust/crates/sift_mcp/src/service/url/mod.rs index e6a21bf692..51738a7d50 100644 --- a/rust/crates/sift_mcp/src/service/url/mod.rs +++ b/rust/crates/sift_mcp/src/service/url/mod.rs @@ -143,6 +143,14 @@ impl UrlService { Ok(format!("{host}/reports/{}", encode_value(report_id))) } + pub fn build_report_template_url(&self, report_template_id: &str) -> Result { + let host = self.app_host()?; + Ok(format!( + "{host}/report-templates/{}", + encode_value(report_template_id) + )) + } + pub fn build_test_report_url(&self, test_report_id: &str) -> Result { let host = self.app_host()?; Ok(format!( diff --git a/rust/crates/sift_mcp/src/service/url/test.rs b/rust/crates/sift_mcp/src/service/url/test.rs index eb7c247c0d..4d5efb1a39 100644 --- a/rust/crates/sift_mcp/src/service/url/test.rs +++ b/rust/crates/sift_mcp/src/service/url/test.rs @@ -186,6 +186,12 @@ fn report_url_uses_configured_app_uri() { assert_eq!(url, "https://app.siftstack.com/reports/rep-123"); } +#[test] +fn report_template_url_uses_configured_app_uri() { + let url = service().build_report_template_url("tmpl-123").unwrap(); + assert_eq!(url, "https://app.siftstack.com/report-templates/tmpl-123"); +} + #[test] fn rule_url_uses_configured_app_uri() { let url = service().build_rule_url("rule-123").unwrap(); diff --git a/rust/crates/sift_mcp/src/tool/report_templates/mod.rs b/rust/crates/sift_mcp/src/tool/report_templates/mod.rs index 3eacf0f958..5d08f2aa80 100644 --- a/rust/crates/sift_mcp/src/tool/report_templates/mod.rs +++ b/rust/crates/sift_mcp/src/tool/report_templates/mod.rs @@ -12,7 +12,7 @@ use crate::{ error::{self, from_anyhow}, server::SiftMcpServer, service::report_templates::{ReportTemplateUpdate, TemplateRuleIdentifier}, - tool::common::MetadataEntry, + tool::common::{MetadataEntry, url_clause, with_urls}, }; #[cfg(test)] @@ -65,7 +65,10 @@ impl SiftMcpServer { `organization_id`, `client_key`, `name`, `description`, `created_date`, `modified_date`, `created_by_user_id`, `modified_by_user_id`, `is_archived`, `archived_date`, `tags`, `metadata`, and the ordered `rules` list. Each rule includes `rule_id`, `rule_version_id`, - `rule_version_number`, `client_key`, and `display_order`. + `rule_version_number`, `client_key`, and `display_order`. Each item also carries an added `url` + field with the template's Sift web link (`/report-templates/`), omitted + when the host can't be derived. Present each template to the user as a Markdown link with its + name as the text and `url` as the target. Parameters: - `filter`: CEL expression. Pass an empty string to list everything. Filterable fields: @@ -113,6 +116,12 @@ impl SiftMcpServer { .await .map_err(from_anyhow)?; + let templates = with_urls(&templates, |t| { + self.url_service + .build_report_template_url(&t.report_template_id) + .ok() + })?; + Ok(CallToolResult::structured( serde_json::json!({ "report_templates": templates }), )) @@ -127,9 +136,11 @@ impl SiftMcpServer { rejected by the server. Wraps `report_templates/v1 CreateReportTemplate`. Output: - - `{ \"report_template\": ReportTemplate, \"next_step\": string }`. The returned template is the - server-assigned state including its new `report_template_id`, resolved `rules` (with - `display_order` set), and timestamps. + - `{ \"report_template\": ReportTemplate, \"report_template_url\": string|null, \"next_step\": string }`. + The returned template is the server-assigned state including its new `report_template_id`, + resolved `rules` (with `display_order` set), and timestamps. `report_template_url` is the + template's Sift web link, or null when the host can't be derived. Link the template only to this + URL, never to a report's page. Parameters: - `name`: required; the template name. @@ -220,17 +231,27 @@ impl SiftMcpServer { .await .map_err(from_anyhow)?; + let report_template_url = self + .url_service + .build_report_template_url(&template.report_template_id) + .ok(); let next_step = format!( - "Created report template `{}` ({}) with {} rule(s). Surface it to the user. To run the template \ - over a run, call `create_report` with `report_template_id = \"{}\"`.", + "Created report template `{}` ({}) with {} rule(s).{} Surface it to the user. To run the \ + template over a run, call `create_report` with `report_template_id = \"{}\"`.", template.name, template.report_template_id, template.rules.len(), + url_clause( + "report template", + Some(&template.name), + report_template_url.as_deref() + ), template.report_template_id, ); let mut result = CallToolResult::structured(serde_json::json!({ "report_template": template, + "report_template_url": report_template_url, "next_step": next_step, })); result.content = vec![ContentBlock::text(next_step)]; @@ -243,8 +264,10 @@ impl SiftMcpServer { Update selected fields on an existing report template. Wraps `report_templates/v1 UpdateReportTemplate`. Output: - - `{ \"report_template\": ReportTemplate, \"next_step\": string }`. The returned template reflects - the post-update server state. + - `{ \"report_template\": ReportTemplate, \"report_template_url\": string|null, \"next_step\": string }`. + The returned template reflects the post-update server state. `report_template_url` is the + template's Sift web link, or null when the host can't be derived. Link the template only to this + URL, never to a report's page. Parameters: - `report_template_id`: required; the id of the template to update. @@ -357,14 +380,26 @@ impl SiftMcpServer { } else { "" }; + let report_template_url = self + .url_service + .build_report_template_url(&template.report_template_id) + .ok(); let next_step = format!( - "Updated report template `{}` ({}).{} Surface the new state to the user and confirm nothing was \ - unintentionally dropped — `tags`, `rules`, and `metadata` use REPLACE semantics.", - template.name, template.report_template_id, archive_clause, + "Updated report template `{}` ({}).{}{} Surface the new state to the user and confirm nothing \ + was unintentionally dropped — `tags`, `rules`, and `metadata` use REPLACE semantics.", + template.name, + template.report_template_id, + archive_clause, + url_clause( + "report template", + Some(&template.name), + report_template_url.as_deref() + ), ); let mut result = CallToolResult::structured(serde_json::json!({ "report_template": template, + "report_template_url": report_template_url, "next_step": next_step, })); result.content = vec![ContentBlock::text(next_step)]; diff --git a/rust/crates/sift_mcp/src/tool/report_templates/test.rs b/rust/crates/sift_mcp/src/tool/report_templates/test.rs index 97719ee845..21dcc2bdcd 100644 --- a/rust/crates/sift_mcp/src/tool/report_templates/test.rs +++ b/rust/crates/sift_mcp/src/tool/report_templates/test.rs @@ -101,6 +101,10 @@ async fn list_report_templates_returns_single_page() { let arr = templates.as_array().unwrap(); assert_eq!(arr.len(), 1); assert_eq!(arr[0]["reportTemplateId"], "tmpl-1"); + assert_eq!( + arr[0]["url"], + "https://app.test.local/report-templates/tmpl-1" + ); } #[tokio::test] @@ -176,6 +180,16 @@ async fn create_report_template_happy_path() { .await .expect("create_report_template failed"); + let url = structured_field(resp.clone(), "report_template_url"); + assert_eq!(url, "https://app.test.local/report-templates/tmpl-new"); + let next_step = structured_field(resp.clone(), "next_step"); + assert!( + next_step + .as_str() + .unwrap() + .contains("[safety](https://app.test.local/report-templates/tmpl-new)"), + "got {next_step}" + ); let template = structured_field(resp, "report_template"); assert_eq!(template["reportTemplateId"], "tmpl-new"); } @@ -247,6 +261,16 @@ async fn update_report_template_happy_path() { .await .expect("update_report_template failed"); + let url = structured_field(resp.clone(), "report_template_url"); + assert_eq!(url, "https://app.test.local/report-templates/tmpl-1"); + let next_step = structured_field(resp.clone(), "next_step"); + assert!( + next_step + .as_str() + .unwrap() + .contains("[renamed](https://app.test.local/report-templates/tmpl-1)"), + "got {next_step}" + ); let template = structured_field(resp, "report_template"); assert_eq!(template["name"], "renamed"); }