sentry-mcp 0.2.5

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

#[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(tx: &TraceTransaction, ops: &mut HashMap<String, (i32, f64)>) {
    if let Some(op) = &tx.span_op {
        let duration = tx.span_duration.unwrap_or(0.0);
        let entry = ops.entry(op.clone()).or_insert((0, 0.0));
        entry.0 += 1;
        entry.1 += duration;
    }
    for child in &tx.children {
        collect_operations(child, ops);
    }
}

pub fn format_span_tree(tx: &TraceTransaction, depth: usize, output: &mut String) {
    let indent = "  ".repeat(depth);
    let duration = tx.span_duration.map(format_duration).unwrap_or_default();
    let op = tx.span_op.as_deref().unwrap_or("unknown");
    let desc = tx.span_description.as_deref().unwrap_or(&tx.transaction);
    let status = tx.span_status.as_deref().unwrap_or("ok");
    let status_icon = if status == "ok" || status == "unknown" {
        ""
    } else {
        ""
    };
    output.push_str(&format!(
        "{}{} [{}] {} ({}) {}\n",
        indent, status_icon, op, desc, duration, tx.project_slug
    ));
    for child in &tx.children {
        format_span_tree(child, depth + 1, output);
    }
}

pub fn format_trace_output(trace_id: &str, trace: &TraceResponse) -> String {
    let mut output = String::new();
    output.push_str("# Trace Details\n\n");
    output.push_str(&format!("**Trace ID:** {}\n", trace_id));
    output.push_str(&format!("**Transactions:** {}\n", trace.transactions.len()));
    output.push_str(&format!(
        "**Orphan Errors:** {}\n",
        trace.orphan_errors.len()
    ));
    if let Some(root) = trace.transactions.first() {
        let start = root.start_timestamp;
        let end = trace
            .transactions
            .iter()
            .map(|t| t.timestamp)
            .max_by(|a, b| a.partial_cmp(b).unwrap())
            .unwrap_or(root.timestamp);
        let total_duration_ms = (end - start) * 1000.0;
        output.push_str(&format!(
            "**Total Duration:** {}\n",
            format_duration(total_duration_ms)
        ));
    }
    let mut ops: HashMap<String, (i32, f64)> = HashMap::new();
    for tx in &trace.transactions {
        collect_operations(tx, &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)
            ));
        }
    }
    output.push_str("\n## Span Tree\n\n```\n");
    for tx in &trace.transactions {
        format_span_tree(tx, 0, &mut output);
    }
    output.push_str("```\n");
    if !trace.orphan_errors.is_empty() {
        output.push_str("\n## Orphan Errors\n\n");
        for (i, err) in trace.orphan_errors.iter().take(5).enumerate() {
            let title = err
                .get("title")
                .and_then(|v| v.as_str())
                .unwrap_or("Unknown error");
            let project = err
                .get("project_slug")
                .and_then(|v| v.as_str())
                .unwrap_or("?");
            output.push_str(&format!("{}. {} ({})\n", i + 1, title, project));
        }
    }
    output
}

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 output = format_trace_output(&input.trace_id, &trace);
    Ok(CallToolResult::success(vec![rmcp::model::Content::text(
        output,
    )]))
}