sentry_mcp/tools/
search_issue_events.rs1use crate::api_client::{Event, EventsQuery, SentryApi};
2use rmcp::{ErrorData as McpError, model::CallToolResult};
3use schemars::JsonSchema;
4use serde::Deserialize;
5
6#[derive(Debug, Deserialize, JsonSchema)]
7pub struct SearchIssueEventsInput {
8 #[schemars(description = "Organization slug")]
9 pub organization_slug: String,
10 #[schemars(description = "Issue ID like 'PROJECT-123' or numeric ID")]
11 pub issue_id: String,
12 #[schemars(description = "Sentry search query. Syntax: key:value pairs with optional raw text. \
13 Operators: > < >= <= for numbers, ! for negation, * for wildcard, OR/AND for logic. \
14 Event properties: environment, release, platform, message, user.id, user.email, \
15 device.family, browser.name, os.name, server_name, transaction. \
16 Examples: 'server_name:web-1', 'environment:production', '!user.email:*@test.com', \
17 'browser.name:Chrome OR browser.name:Firefox'")]
18 pub query: Option<String>,
19 #[schemars(description = "Maximum number of events to return (default: 10, max: 100)")]
20 pub limit: Option<i32>,
21 #[schemars(description = "Sort order: 'newest' (default) or 'oldest'")]
22 pub sort: Option<String>,
23}
24
25pub fn format_events_output(issue_id: &str, query: Option<&str>, events: &[Event]) -> String {
26 let mut output = String::new();
27 output.push_str("# Issue Events\n\n");
28 output.push_str(&format!("**Issue:** {}\n", issue_id));
29 if let Some(q) = query {
30 output.push_str(&format!("**Query:** {}\n", q));
31 }
32 output.push_str(&format!("**Found:** {} events\n\n", events.len()));
33 for (i, event) in events.iter().enumerate() {
34 output.push_str(&format!("## Event {} - {}\n\n", i + 1, event.event_id));
35 if let Some(date) = &event.date_created {
36 output.push_str(&format!("**Date:** {}\n", date));
37 }
38 if let Some(platform) = &event.platform {
39 output.push_str(&format!("**Platform:** {}\n", platform));
40 }
41 if let Some(msg) = &event.message
42 && !msg.is_empty()
43 {
44 output.push_str(&format!("**Message:** {}\n", msg));
45 }
46 if !event.tags.is_empty() {
47 output.push_str("**Tags:** ");
48 let tags: Vec<String> = event
49 .tags
50 .iter()
51 .map(|t| format!("{}={}", t.key, t.value))
52 .collect();
53 output.push_str(&tags.join(", "));
54 output.push('\n');
55 }
56 for entry in &event.entries {
57 if entry.entry_type == "exception"
58 && let Some(values) = entry.data.get("values").and_then(|v| v.as_array())
59 {
60 for exc in values {
61 let exc_type = exc.get("type").and_then(|v| v.as_str()).unwrap_or("?");
62 let exc_value = exc.get("value").and_then(|v| v.as_str()).unwrap_or("?");
63 output.push_str(&format!("**Exception:** {} - {}\n", exc_type, exc_value));
64 }
65 }
66 }
67 output.push('\n');
68 }
69 if events.is_empty() {
70 output.push_str("No events found matching the query.\n");
71 }
72 output
73}
74
75pub async fn execute(
76 client: &impl SentryApi,
77 input: SearchIssueEventsInput,
78) -> Result<CallToolResult, McpError> {
79 let limit = input.limit.unwrap_or(10).min(100);
80 let sort = input.sort.unwrap_or_else(|| "newest".to_string());
81 let query = EventsQuery {
82 query: input.query.clone(),
83 limit: Some(limit),
84 sort: Some(sort),
85 };
86 let events = client
87 .list_events_for_issue(&input.organization_slug, &input.issue_id, &query)
88 .await
89 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
90 let output = format_events_output(&input.issue_id, input.query.as_deref(), &events);
91 Ok(CallToolResult::success(vec![rmcp::model::Content::text(output)]))
92}