use std::any::type_name;
use std::backtrace::{Backtrace, BacktraceStatus};
use std::error::Error;
use std::fmt;
use std::future::ready;
use std::panic::AssertUnwindSafe;
use std::sync::Arc;
use std::time::Duration;
use futures::FutureExt;
use rmcp::model::{
CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, InitializeRequestParams,
InitializeResult, JsonObject, ListToolsResult, PaginatedRequestParams, ProtocolVersion,
ServerCapabilities, Tool,
};
use rmcp::service::{MaybeSendFuture, RequestContext, RoleClient, RunningService, ServiceError};
use rmcp::{ErrorData, RoleServer, ServerHandler, tool, tool_handler, tool_router};
use serde_json::{Map, Value, json};
use super::{CaptureSlot, capture_error, captured_backtrace, error_shape, panic_error, scope};
use crate::call::result_error;
use crate::client::AgentCat;
use crate::config::Options;
use crate::event::CallRecord;
use crate::test_support::rmcp::{RecordingSink, Sample, bare_context, connect};
type Client = RunningService<RoleClient, InitializeRequestParams>;
const NO_RESPONSE: Duration = Duration::from_millis(500);
const ANSWERED: Duration = Duration::from_secs(2);
const LIST_PANICKED: &str =
"Warning: registry rebuild-on-demand failed - Error: list_tools exploded";
const LOOKUP_PANICKED: &str = "Warning: Tool lookup for beta panicked: get_tool exploded";
const INFO_PANICKED: &str = "Warning: get_info panicked: get_info exploded";
fn recording() -> (AgentCat, Arc<RecordingSink>) {
let sink = Arc::new(RecordingSink::default());
let agentcat = AgentCat::with_sink("", Options::default(), sink.clone());
(agentcat, sink)
}
fn params(name: &str, arguments: Value) -> CallToolRequestParams {
let Value::Object(arguments) = arguments else {
panic!("{arguments} is not an object");
};
CallToolRequestParams::new(name.to_owned()).with_arguments(arguments)
}
async fn call(
client: &Client,
name: &str,
arguments: Value,
) -> Result<CallToolResult, ServiceError> {
tokio::time::timeout(ANSWERED, client.call_tool(params(name, arguments)))
.await
.unwrap_or_else(|_| panic!("{name}: no response within {ANSWERED:?}"))
}
async fn unanswered(client: &Client, name: &str) {
let call = tokio::time::timeout(NO_RESPONSE, client.call_tool(params(name, json!({}))));
let outcome = call.await;
assert!(
outcome.is_err(),
"{name}: expected no response, got {outcome:?}"
);
}
fn only_record(sink: &RecordingSink) -> CallRecord {
let mut records = sink.take_records();
assert_eq!(records.len(), 1, "one record: {records:?}");
records.remove(0)
}
fn keys(object: &Map<String, Value>) -> Vec<&str> {
object.keys().map(String::as_str).collect()
}
fn panic_shape(message: &str) -> Map<String, Value> {
let shape = json!({"message": message, "type": "panic", "platform": "rust"});
shape.as_object().cloned().expect("an object")
}
fn received(result: &CallToolResult) -> Value {
let text = result
.content
.last()
.and_then(|block| block.as_text())
.expect("the last block is text");
serde_json::from_str(&text.text).expect("the text is JSON")
}
fn echo_arguments(request: CallToolRequestParams) -> CallToolResponse {
let arguments = Value::Object(request.arguments.unwrap_or_default());
CallToolResult::success(vec![ContentBlock::text(arguments.to_string())]).into()
}
fn explode<T>(method: &str) -> T {
panic!("{method} exploded")
}
fn tools_info() -> InitializeResult {
InitializeResult::new(ServerCapabilities::builder().enable_tools().build())
}
#[derive(Debug)]
struct RootCause;
impl fmt::Display for RootCause {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("root cause")
}
}
impl Error for RootCause {}
#[derive(Debug)]
struct Wrapper(RootCause);
impl fmt::Display for Wrapper {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("wrapper failed")
}
}
impl Error for Wrapper {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(&self.0)
}
}
fn wrapper_shape() -> Value {
json!({
"message": "wrapper failed",
"type": type_name::<Wrapper>(),
"platform": "rust",
"chained_errors": [{"message": "root cause"}],
})
}
fn captured_error(record: CallRecord) -> Value {
if let Some(backtrace) = &record.backtrace {
assert_eq!(backtrace.status(), BacktraceStatus::Captured);
}
let error = record.error.expect("the record has an error");
assert!(!error.contains_key("stack"), "{error:?}");
Value::Object(error)
}
#[derive(Debug, Clone, Copy)]
struct Capturing;
#[tool_router]
impl Capturing {
#[tool(description = "Captures a two-level error, then fails with its text")]
async fn captured(&self) -> Result<String, String> {
let error = Wrapper(RootCause);
capture_error(&error);
Err(error.to_string())
}
#[tool(description = "Captures a boxed dyn Error, then fails with its text")]
async fn captured_dyn(&self) -> Result<String, String> {
let error: Box<dyn Error> = Box::new(RootCause);
capture_error(&*error);
Err(error.to_string())
}
#[tool(description = "Captures a two-level error, then succeeds")]
async fn captured_ok(&self) -> String {
capture_error(&Wrapper(RootCause));
"fine".to_owned()
}
#[tool(description = "Captures a two-level error in a task it spawns, then fails")]
async fn spawned(&self) -> Result<String, String> {
tokio::spawn(async { capture_error(&Wrapper(RootCause)) })
.await
.map_err(|error| error.to_string())?;
Err("spawned failed".to_owned())
}
}
#[expect(
clippy::unused_async_trait_impl,
reason = "the methods #[tool_handler] generates are async without an await"
)]
#[tool_handler]
impl ServerHandler for Capturing {}
#[derive(Debug, Clone, Copy)]
struct Eager;
impl ServerHandler for Eager {
fn call_tool(
&self,
request: CallToolRequestParams,
_context: RequestContext<RoleServer>,
) -> impl Future<Output = Result<CallToolResponse, ErrorData>> + MaybeSendFuture + '_ {
capture_error(&Wrapper(RootCause));
if request.name == "capture_then_panic" {
return ready(explode("call_tool"));
}
ready(Err(ErrorData::internal_error("data failed", None)))
}
fn get_info(&self) -> InitializeResult {
tools_info()
}
}
#[derive(Debug, Clone, Copy)]
struct PanickingList;
impl ServerHandler for PanickingList {
fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> impl Future<Output = Result<ListToolsResult, ErrorData>> + MaybeSendFuture + '_ {
ready(explode("list_tools"))
}
fn call_tool(
&self,
request: CallToolRequestParams,
_context: RequestContext<RoleServer>,
) -> impl Future<Output = Result<CallToolResponse, ErrorData>> + MaybeSendFuture + '_ {
ready(Ok(echo_arguments(request)))
}
fn get_info(&self) -> InitializeResult {
tools_info()
}
}
#[derive(Debug, Clone, Copy)]
struct PanickingGetTool;
impl ServerHandler for PanickingGetTool {
fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> impl Future<Output = Result<ListToolsResult, ErrorData>> + MaybeSendFuture + '_ {
let schema = json!({
"type": "object",
"properties": {"x": {"type": "string"}, "context": {"type": "string"}},
});
let schema: JsonObject = schema.as_object().cloned().unwrap_or_default();
let beta = Tool::new("beta", "declares its own context", schema);
ready(Ok(ListToolsResult::with_all_items(vec![beta])))
}
fn get_tool(&self, _name: &str) -> Option<Tool> {
explode("get_tool")
}
fn call_tool(
&self,
request: CallToolRequestParams,
_context: RequestContext<RoleServer>,
) -> impl Future<Output = Result<CallToolResponse, ErrorData>> + MaybeSendFuture + '_ {
ready(Ok(echo_arguments(request)))
}
fn get_info(&self) -> InitializeResult {
tools_info()
}
}
#[derive(Debug, Clone, Copy)]
struct PanickingInfo;
impl ServerHandler for PanickingInfo {
fn initialize(
&self,
request: InitializeRequestParams,
context: RequestContext<RoleServer>,
) -> impl Future<Output = Result<InitializeResult, ErrorData>> + MaybeSendFuture + '_ {
context.peer.set_peer_info(request);
let mut info = tools_info();
info.protocol_version = ProtocolVersion::V_2025_11_25;
ready(Ok(info))
}
fn call_tool(
&self,
request: CallToolRequestParams,
_context: RequestContext<RoleServer>,
) -> impl Future<Output = Result<CallToolResponse, ErrorData>> + MaybeSendFuture + '_ {
ready(Ok(echo_arguments(request)))
}
fn get_info(&self) -> InitializeResult {
explode("get_info")
}
}
#[tokio::test]
async fn panic_is_recorded_and_resumed() {
let plain = connect(Sample, "plain").await;
unanswered(&plain, "explode").await;
let (agentcat, sink) = recording();
let client = connect(agentcat.track(Sample), "tracked").await;
unanswered(&client, "explode").await;
let record = only_record(&sink);
assert_eq!(record.resource_name, "explode");
assert!(record.is_error);
assert_eq!(record.error, Some(panic_shape("explode")));
assert_eq!(record.response, None);
assert_eq!(record.output_tokens, None);
let tracked = agentcat.track(Sample);
let call = tracked.call_tool(params("explode", json!({})), bare_context(None));
let Err(payload) = AssertUnwindSafe(call).catch_unwind().await else {
panic!("the panic is resumed");
};
assert_eq!(
payload.downcast_ref::<&str>(),
Some(&"explode"),
"the tool's own payload"
);
assert_eq!(only_record(&sink).error, Some(panic_shape("explode")));
}
#[tokio::test]
async fn server_keeps_serving_after_panic() {
let (agentcat, sink) = recording();
let client = connect(agentcat.track(Sample), "serving").await;
unanswered(&client, "explode").await;
let result = call(&client, "echo", json!({"msg": "still here"}))
.await
.expect("echo answers");
let last = result.content.last().and_then(|block| block.as_text());
assert_eq!(last.map(|text| text.text.as_str()), Some("still here"));
let records = sink.take_records();
assert_eq!(records.len(), 2, "{records:?}");
assert_eq!(records[0].error, Some(panic_shape("explode")));
assert_eq!(records[1].resource_name, "echo");
assert!(!records[1].is_error);
assert_eq!(records[1].error, None);
}
#[test]
fn panic_payload_kinds() {
let panics: [(fn(), &str, &str); 3] = [
(|| panic!("a literal"), "a literal", "&str"),
(
|| panic!("{}", "with arguments"),
"with arguments",
"String",
),
(|| std::panic::panic_any(42_i32), "Box<dyn Any>", "i32"),
];
for (f, message, kind) in panics {
let payload = std::panic::catch_unwind(f).expect_err("f panics");
let is_kind = match kind {
"&str" => payload.is::<&str>(),
"String" => payload.is::<String>(),
_ => payload.is::<i32>(),
};
assert!(is_kind, "the payload of {message:?} is a {kind}");
assert_eq!(panic_error(&*payload), panic_shape(message), "{kind}");
}
}
#[tokio::test]
async fn capture_error_records_type_and_chain() {
let plain = connect(Capturing, "plain").await;
let expected = call(&plain, "captured", json!({})).await.expect("a result");
let (agentcat, sink) = recording();
let client = connect(agentcat.track(Capturing), "capturing").await;
let result = call(&client, "captured", json!({}))
.await
.expect("a result");
assert_eq!(result.is_error, Some(true));
assert_eq!(result.content.last(), expected.content.last());
let record = only_record(&sink);
assert!(record.is_error);
assert!(
record.runtime.is_some(),
"the record keeps the call's runtime"
);
let error = captured_error(record);
let order = ["message", "type", "platform", "chained_errors"];
assert_eq!(keys(error.as_object().expect("an object")), order);
assert_eq!(error, wrapper_shape());
assert!(
type_name::<Wrapper>().ends_with("capture::tests::Wrapper"),
"the type path"
);
}
#[tokio::test]
async fn capture_keeps_the_backtrace() {
let kept = captured_backtrace(Backtrace::force_capture()).expect("a captured backtrace");
assert_eq!(kept.status(), BacktraceStatus::Captured);
assert!(captured_backtrace(Backtrace::disabled()).is_none());
let slot = CaptureSlot::default();
scope(slot.clone(), async { capture_error(&Wrapper(RootCause)) }).await;
let (error, backtrace) = slot.take().expect("the slot holds the error");
assert_eq!(Value::Object(error), wrapper_shape());
assert!(
backtrace.is_none_or(|backtrace| backtrace.status() == BacktraceStatus::Captured),
"only a captured backtrace is kept"
);
assert!(slot.take().is_none(), "take empties the slot");
let with_chain = error_shape(&Wrapper(RootCause));
assert_eq!(
keys(&with_chain),
["message", "type", "platform", "chained_errors"]
);
let without = error_shape(&RootCause);
let expected =
json!({"message": "root cause", "type": type_name::<RootCause>(), "platform": "rust"});
assert_eq!(Value::Object(without), expected);
let looping = error_shape(&Looping);
let chain = looping["chained_errors"].as_array().expect("a chain");
assert_eq!(chain.len(), 10, "at most 10 entries");
assert!(
chain
.iter()
.all(|entry| *entry == json!({"message": "looping"}))
);
}
#[derive(Debug)]
struct Looping;
impl fmt::Display for Looping {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("looping")
}
}
impl Error for Looping {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(self)
}
}
#[tokio::test]
async fn capture_error_outside_a_call_does_nothing() {
capture_error(&Wrapper(RootCause));
let plain = connect(Capturing, "plain").await;
let failed = call(&plain, "spawned", json!({})).await.expect("a result");
let (agentcat, sink) = recording();
let client = connect(agentcat.track(Capturing), "outside").await;
capture_error(&Wrapper(RootCause));
call(&client, "captured", json!({}))
.await
.expect("a result");
assert_eq!(captured_error(only_record(&sink)), wrapper_shape());
let result = call(&client, "spawned", json!({})).await.expect("a result");
assert_eq!(result.is_error, Some(true));
let record = only_record(&sink);
assert_eq!(record.error, Some(result_error(&failed)));
}
#[tokio::test]
async fn captured_error_without_a_failure_is_dropped() {
let (agentcat, sink) = recording();
let client = connect(agentcat.track(Capturing), "ok").await;
let result = call(&client, "captured_ok", json!({}))
.await
.expect("a result");
assert_ne!(result.is_error, Some(true));
let record = only_record(&sink);
assert!(!record.is_error);
assert_eq!(record.error, None);
}
#[tokio::test]
async fn captured_error_wins_over_error_data() {
let plain = connect(Eager, "plain").await;
let Err(ServiceError::McpError(expected)) = call(&plain, "data", json!({})).await else {
panic!("a JSON-RPC error");
};
let (agentcat, sink) = recording();
let client = connect(agentcat.track(Eager), "tracked").await;
let Err(ServiceError::McpError(actual)) = call(&client, "data", json!({})).await else {
panic!("a JSON-RPC error");
};
assert_eq!(
serde_json::to_value(&actual).unwrap(),
serde_json::to_value(&expected).unwrap()
);
let record = only_record(&sink);
assert!(record.is_error);
assert_eq!(record.response, None);
assert_eq!(captured_error(record), wrapper_shape(), "not InternalError");
unanswered(&client, "capture_then_panic").await;
let record = only_record(&sink);
assert_eq!(record.error, Some(panic_shape("call_tool exploded")));
assert!(
record.backtrace.is_none(),
"a panic keeps no captured backtrace"
);
}
#[tokio::test]
async fn rebuild_panic_is_a_failed_rebuild() {
let (agentcat, sink) = recording();
let client = connect(agentcat.track(PanickingList), "rebuild").await;
for _ in 0..2 {
let arguments = json!({"x": "1", "session_id": "start", "context": "why"});
let result = call(&client, "anything", arguments)
.await
.expect("a result");
assert_eq!(received(&result), json!({"x": "1"}), "the heuristic strip");
}
assert_eq!(sink.logs(), [LIST_PANICKED, LIST_PANICKED]);
let listed = agentcat
.inner()
.with_registry::<PanickingList, _>(|registry| registry.listed());
assert!(!listed, "the failed rebuild recorded nothing");
let records = sink.take_records();
assert_eq!(records.len(), 2);
assert!(records.iter().all(|record| record.error.is_none()));
}
#[tokio::test]
async fn get_tool_panic_is_no_schema() {
let (agentcat, sink) = recording();
let client = connect(agentcat.track(PanickingGetTool), "get-tool").await;
for _ in 0..2 {
let arguments = json!({"x": "1", "context": "mine", "session_id": "start"});
let result = call(&client, "beta", arguments).await.expect("a result");
assert_eq!(received(&result), json!({"x": "1", "context": "mine"}));
}
let listed = agentcat
.inner()
.with_registry::<PanickingGetTool, _>(|registry| registry.listed());
assert!(listed, "the rebuild recorded the listing");
let records = sink.take_records();
assert_eq!(records.len(), 2);
assert!(records.iter().all(|record| record.error.is_none()));
let logs = sink.logs();
let lookups: Vec<&String> = logs.iter().filter(|line| line.contains("lookup")).collect();
assert_eq!(lookups, [LOOKUP_PANICKED, LOOKUP_PANICKED], "{logs:?}");
assert_eq!(
logs.first().map(String::as_str),
Some(LOOKUP_PANICKED),
"{logs:?}"
);
assert_eq!(
logs.last().map(String::as_str),
Some(LOOKUP_PANICKED),
"{logs:?}"
);
}
#[tokio::test]
async fn tracing_off_lookup_panics_log_nothing() {
let sink = Arc::new(RecordingSink::default());
let options = Options::default().enable_tracing(false);
let agentcat = AgentCat::with_sink("", options, sink.clone());
let client = connect(agentcat.track(PanickingGetTool), "get-tool-off").await;
let arguments = json!({"x": "1", "context": "mine"});
let result = call(&client, "beta", arguments.clone())
.await
.expect("a result");
assert_eq!(received(&result), arguments);
let client = connect(agentcat.track(PanickingList), "rebuild-off").await;
let arguments = json!({"x": "1", "context": "why", "session_id": "start"});
let result = call(&client, "anything", arguments)
.await
.expect("a result");
assert_eq!(received(&result), json!({"x": "1"}), "the heuristic strip");
assert!(sink.logs().is_empty(), "{:?}", sink.logs());
assert!(sink.take_records().is_empty());
}
#[tokio::test]
async fn get_info_panic_records_no_server() {
let (agentcat, sink) = recording();
let client = connect(agentcat.track(PanickingInfo), "get-info").await;
let result = call(&client, "anything", json!({"x": "1"}))
.await
.expect("a result");
assert_eq!(received(&result), json!({"x": "1"}));
let record = only_record(&sink);
assert_eq!(record.server_name, None);
assert_eq!(record.server_version, None);
assert!(record.error.is_none());
let logs = sink.logs();
let infos: Vec<&String> = logs
.iter()
.filter(|line| line.contains("get_info"))
.collect();
assert_eq!(infos, [INFO_PANICKED], "{logs:?}");
}
#[tokio::test]
async fn capture_error_accepts_dyn_error() {
let (agentcat, sink) = recording();
let client = connect(agentcat.track(Capturing), "dyn").await;
call(&client, "captured_dyn", json!({}))
.await
.expect("a result");
let error = only_record(&sink).error.expect("the record has an error");
assert_eq!(error["message"], json!("root cause"));
assert_eq!(error["type"], json!("dyn core::error::Error"));
assert_eq!(error["platform"], json!("rust"));
}