sentry-mcp 0.3.0

A minimal MCP server for Sentry
Documentation
use crate::api_client::{SentryApi, TraceMeta, TraceSpan};
use rmcp::{ErrorData as McpError, model::CallToolResult};
use schemars::JsonSchema;
use serde::Deserialize;
use std::collections::HashMap;

/// Minimum span duration in ms to be considered interesting.
const MIN_INTERESTING_DURATION_MS: f64 = 10.0;
/// Maximum number of interesting spans to display.
const MAX_INTERESTING_SPANS: usize = 20;
/// A span is "dominated" if its single child takes this fraction of its duration.
const DOMINATED_THRESHOLD: f64 = 0.9;

#[derive(Debug, Deserialize, JsonSchema)]
pub struct GetTraceDetailsInput {
    #[schemars(description = "Organization slug")]
    pub organization_slug: String,
    #[schemars(description = "Trace ID (32-character hex string)")]
    pub trace_id: String,
}

pub fn format_duration(ms: f64) -> String {
    if ms >= 1000.0 {
        format!("{:.2}s", ms / 1000.0)
    } else {
        format!("{:.2}ms", ms)
    }
}

pub fn collect_operations(span: &TraceSpan, ops: &mut HashMap<String, (i32, f64)>) {
    if let Some(op) = &span.op {
        let entry = ops.entry(op.clone()).or_insert((0, 0.0));
        entry.0 += 1;
        entry.1 += span.duration;
    }
    for child in &span.children {
        collect_operations(child, ops);
    }
}

pub fn format_span_tree(span: &TraceSpan, depth: usize, output: &mut String) {
    let indent = "  ".repeat(depth);
    let duration = format_duration(span.duration);
    let op = span.op.as_deref().unwrap_or("unknown");
    let desc = span
        .description
        .as_deref()
        .or(span.transaction.as_deref())
        .unwrap_or("(no description)");
    let has_errors = !span.errors.is_empty();
    let status_icon = if has_errors { "" } else { "" };
    let tx_marker = if span.is_transaction { " [tx]" } else { "" };
    output.push_str(&format!(
        "{}{} [{}] {} ({}) {}{}\n",
        indent, status_icon, op, desc, duration, span.project_slug, tx_marker
    ));
    for child in &span.children {
        format_span_tree(child, depth + 1, output);
    }
}

/// Filter spans to show only interesting ones for display.
/// Always includes transactions, spans with errors, and spans >= MIN_INTERESTING_DURATION_MS.
/// Sorted by duration, truncated to max_spans.
pub fn select_interesting_spans(spans: &[TraceSpan], max_spans: usize) -> Vec<TraceSpan> {
    let mut collected: Vec<TraceSpan> = Vec::new();
    for span in spans {
        collect_interesting(span, &mut collected);
    }
    collected.sort_by(|a, b| b.duration.partial_cmp(&a.duration).unwrap());
    collected.truncate(max_spans);
    collected
}

fn collect_interesting(span: &TraceSpan, out: &mut Vec<TraceSpan>) {
    let dominated_by_one_child = span.children.len() == 1
        && span.children[0].duration >= span.duration * DOMINATED_THRESHOLD;

    // Skip non-transaction spans that are dominated by a single child
    // (e.g., middleware chains where each middleware wraps the next)
    let dominated_skip = dominated_by_one_child && !span.is_transaction;

    let is_interesting = span.is_transaction || !span.errors.is_empty() || span.duration >= MIN_INTERESTING_DURATION_MS;

    if !dominated_skip && is_interesting {
        let mut filtered = span.clone();
        filtered.children = Vec::new();
        out.push(filtered);
    }

    for child in &span.children {
        collect_interesting(child, out);
    }
}

pub fn format_trace_output(
    trace_id: &str,
    spans: &[TraceSpan],
    meta: Option<&TraceMeta>,
) -> String {
    let mut output = String::new();
    output.push_str("# Trace Details\n\n");
    output.push_str(&format!("**Trace ID:** {}\n", trace_id));

    let tx_count = count_transactions(spans);
    output.push_str(&format!("**Transactions:** {}\n", tx_count));

    if let Some(meta) = meta {
        output.push_str(&format!("**Total Spans:** {}\n", meta.span_count as i64));
        output.push_str(&format!("**Errors:** {}\n", meta.errors));
        output.push_str(&format!(
            "**Performance Issues:** {}\n",
            meta.performance_issues
        ));
    }

    if let Some(root) = spans.first() {
        let (start, end) = compute_time_range(spans);
        let total_duration_ms = (end - start) * 1000.0;
        if total_duration_ms > 0.0 {
            output.push_str(&format!(
                "**Total Duration:** {}\n",
                format_duration(total_duration_ms)
            ));
        } else {
            output.push_str(&format!(
                "**Root Duration:** {}\n",
                format_duration(root.duration)
            ));
        }
    }

    if let Some(meta) = meta
        && !meta.span_count_map.is_empty()
    {
        output.push_str("\n## Operation Breakdown\n\n");
        let mut map_vec: Vec<_> = meta.span_count_map.iter().collect();
        map_vec.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap());
        for (op, count) in map_vec {
            output.push_str(&format!("- **{}**: {}\n", op, *count as i64));
        }
    } else {
        let mut ops: HashMap<String, (i32, f64)> = HashMap::new();
        for span in spans {
            collect_operations(span, &mut ops);
        }
        if !ops.is_empty() {
            output.push_str("\n## Operation Breakdown\n\n");
            let mut ops_vec: Vec<_> = ops.into_iter().collect();
            ops_vec.sort_by(|a, b| b.1 .1.partial_cmp(&a.1 .1).unwrap());
            for (op, (count, total_ms)) in ops_vec {
                output.push_str(&format!(
                    "- **{}**: {} occurrences, {} total\n",
                    op,
                    count,
                    format_duration(total_ms)
                ));
            }
        }
    }

    let interesting = select_interesting_spans(spans, MAX_INTERESTING_SPANS);
    output.push_str("\n## Span Tree\n\n```\n");
    for span in &interesting {
        format_span_tree(span, 0, &mut output);
    }
    output.push_str("```\n");

    output
}

fn count_transactions(spans: &[TraceSpan]) -> usize {
    let mut count = 0;
    for span in spans {
        if span.is_transaction {
            count += 1;
        }
        count += count_transactions(&span.children);
    }
    count
}

fn compute_time_range(spans: &[TraceSpan]) -> (f64, f64) {
    let mut min_start = f64::MAX;
    let mut max_end = f64::MIN;
    for span in spans {
        if span.start_timestamp > 0.0 && span.start_timestamp < min_start {
            min_start = span.start_timestamp;
        }
        if span.end_timestamp > 0.0 && span.end_timestamp > max_end {
            max_end = span.end_timestamp;
        }
        let (child_start, child_end) = compute_time_range(&span.children);
        if child_start < min_start {
            min_start = child_start;
        }
        if child_end > max_end {
            max_end = child_end;
        }
    }
    (min_start, max_end)
}

pub async fn execute(
    client: &impl SentryApi,
    input: GetTraceDetailsInput,
) -> Result<CallToolResult, McpError> {
    let trace = client
        .get_trace(&input.organization_slug, &input.trace_id)
        .await
        .map_err(|e| McpError::internal_error(e.to_string(), None))?;
    let meta = client
        .get_trace_meta(&input.organization_slug, &input.trace_id)
        .await
        .ok();
    let output = format_trace_output(&input.trace_id, &trace, meta.as_ref());
    Ok(CallToolResult::success(vec![rmcp::model::Content::text(
        output,
    )]))
}