use std::collections::HashSet;
use std::panic::{AssertUnwindSafe, catch_unwind, resume_unwind};
use std::time::{Duration, SystemTime};
use futures::FutureExt;
use rmcp::model::{
CallToolRequestParams, CallToolResponse, CallToolResult, Implementation,
PaginatedRequestParams, ServerResult, Tool,
};
use rmcp::service::RequestContext;
use rmcp::{ErrorData, RoleServer, ServerHandler};
use serde_json::{Map, Value};
use tokio::runtime::Handle;
use tokio::time::Instant;
use super::{
cancelled_error, error_data_error, mirror_mint_back, prepend_mint_back, report_missing,
result_error, strip_arguments,
};
use crate::capture::{CaptureSlot, Captured, panic_error, panic_message, scope};
use crate::client::Inner;
use crate::config::Options;
use crate::constants::{AGENTCAT_TAG_MRTR, GET_MORE_TOOLS_NAME};
use crate::context::CallContext;
use crate::event::{
CallRecord, ClientIdentity, client_identity, extra_value, protocol_version, request_value,
};
use crate::handles::{
HandleResolution, build_handle_tags, build_mint_back_text, build_structured_mint_back,
resolve_handles,
};
use crate::hooks::{PendingHooks, ToolCall, fire};
use crate::inject::{CONTEXT_PARAM, InjectConfig, ToolEntry, build_injected_list};
use crate::sink::Sink;
use crate::tokens::{estimate_input_tokens, estimate_output_tokens};
use crate::tracked::Tracked;
#[cfg(test)]
mod tests;
pub(crate) const REBUILD_TIMEOUT: Duration = Duration::from_secs(5);
const UNKNOWN_TOOL: &str = "Unknown Tool";
const REBUILT: &str =
"Rebuilt injection registries on demand (tools/call before tools/list on this instance)";
const MRTR_CONTINUATION: &str = "continuation";
const MRTR_INPUT_REQUIRED: &str = "input_required";
struct Schema {
entry: Option<ToolEntry>,
foreign: bool,
mirror: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SchemaLogs {
Written,
Silent,
}
impl SchemaLogs {
fn log(self, shared: &Inner, line: impl FnOnce() -> String) {
if self == Self::Written {
shared.sink.log(line());
}
}
}
pub(crate) async fn run_call<H: ServerHandler>(
tracked: &Tracked<H>,
request: CallToolRequestParams,
mut context: RequestContext<RoleServer>,
) -> Result<CallToolResponse, ErrorData> {
let shared = tracked.agentcat().inner();
if !shared.enabled {
return forward(tracked, request, context).await;
}
let options = &shared.options;
let intercept = options.report_missing() && request.name == GET_MORE_TOOLS_NAME;
if !options.tracing() {
return unrecorded(tracked, request, context, intercept, Unrecorded::Untraced).await;
}
let started_at = SystemTime::now();
let started = Instant::now();
let runtime = Handle::try_current().ok();
let schema = learn_schema(tracked, &request.name, &context, SchemaLogs::Written).await;
let Some(resolution) = resolve_handles(
request.arguments.as_ref(),
options.hook_mode(),
options.agent_tracking(),
!schema.foreign,
) else {
shared.sink.log(format!(
"Warning: AgentCat tracing failed for tool {}, falling back to original handler - Error: could not generate a session ID",
request.name
));
return unrecorded(tracked, request, context, intercept, Unrecorded::Degraded).await;
};
let client = client_identity(&context);
let pending = start_hooks(shared, &request, &context, client.as_ref()).await;
let request_json = request_value(&request, &context);
let input_tokens = estimate_input_tokens(request_json.pointer("/params/arguments"));
let tags = call_tags(&request, &resolution, &context);
let server = server_info(shared, tracked.inner());
let mint_back = MintBack::new(&resolution, schema.mirror);
let call_context = CallContext::new(
resolution.session_id.clone(),
resolution.agent_id.clone(),
tracked.agentcat().clone(),
);
let intent = if intercept {
context_argument(&request).cloned()
} else {
user_intent(options, &request)
};
let record = CallRecord {
started_at,
duration_ms: 0,
resolution,
resource_name: resource_name(&request.name),
request: request_json,
extra: extra_value(&context),
input_tokens,
output_tokens: None,
tags,
user_intent: intent,
response: None,
is_error: false,
error: None,
server_name: server.as_ref().map(|server| server.name.clone()),
server_version: server.as_ref().map(|server| server.version.clone()),
client: client.clone(),
pending,
runtime,
backtrace: None,
};
let guard = RecordGuard::new(record, shared.sink.as_ref(), started);
let name = request.name.to_string();
if intercept {
let answer = answer_report_missing(shared, &request);
return Ok(finish(guard, &name, mint_back, answer.into(), None));
}
let forwarded = stripped(request, schema.entry.as_ref());
context
.extensions
.insert(call_context.with_identity(client, server));
let slot = CaptureSlot::default();
let call = async move { tracked.inner().call_tool(forwarded, context).await };
let outcome = match scope(slot.clone(), AssertUnwindSafe(call).catch_unwind()).await {
Ok(outcome) => outcome,
Err(payload) => {
guard.send(|record| {
record.is_error = true;
record.error = Some(panic_error(&*payload));
});
resume_unwind(payload);
}
};
let captured = slot.take();
match outcome {
Ok(response) => Ok(finish(guard, &name, mint_back, response, captured)),
Err(error) => {
guard.send(|record| set_error(record, captured, || error_data_error(&error)));
Err(error)
}
}
}
struct RecordGuard<'a> {
record: Option<CallRecord>,
sink: &'a dyn Sink,
started: Instant,
}
impl<'a> RecordGuard<'a> {
fn new(record: CallRecord, sink: &'a dyn Sink, started: Instant) -> Self {
Self {
record: Some(record),
sink,
started,
}
}
fn send(mut self, complete: impl FnOnce(&mut CallRecord)) {
if let Some(mut record) = self.record.take() {
complete(&mut record);
record.duration_ms = elapsed_ms(self.started);
self.sink.record(record);
}
}
fn discard(mut self, line: String) {
self.record = None;
self.sink.log(line);
}
}
impl Drop for RecordGuard<'_> {
fn drop(&mut self) {
if let Some(mut record) = self.record.take() {
record.response = None;
record.output_tokens = None;
record.is_error = true;
record.error = Some(cancelled_error());
record.duration_ms = elapsed_ms(self.started);
self.sink.record(record);
}
}
}
struct MintBack {
text: Option<String>,
mirror: Option<Map<String, Value>>,
}
impl MintBack {
fn new(resolution: &HandleResolution, mirror: bool) -> Self {
Self {
text: build_mint_back_text(resolution),
mirror: mirror
.then(|| build_structured_mint_back(resolution))
.flatten(),
}
}
}
fn call_tags(
request: &CallToolRequestParams,
resolution: &HandleResolution,
context: &RequestContext<RoleServer>,
) -> Map<String, Value> {
let mut tags = Map::new();
if request.input_responses.is_some() {
tags.insert(AGENTCAT_TAG_MRTR.to_owned(), MRTR_CONTINUATION.into());
}
tags.extend(build_handle_tags(
resolution,
protocol_version(&context.meta).as_deref(),
));
tags
}
async fn start_hooks(
shared: &Inner,
request: &CallToolRequestParams,
context: &RequestContext<RoleServer>,
client: Option<&ClientIdentity>,
) -> PendingHooks {
let options = &shared.options;
let pending = fire(options, options.hook_mode(), shared.sink.as_ref(), || {
ToolCall::new(request, context, client)
});
if pending.any() {
tokio::task::yield_now().await;
}
pending
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Unrecorded {
Untraced,
Degraded,
}
async fn unrecorded<H: ServerHandler>(
tracked: &Tracked<H>,
request: CallToolRequestParams,
context: RequestContext<RoleServer>,
intercept: bool,
path: Unrecorded,
) -> Result<CallToolResponse, ErrorData> {
if intercept {
let shared = tracked.agentcat().inner();
return Ok(answer_report_missing(shared, &request).into());
}
let request = match path {
Unrecorded::Untraced => {
let schema = learn_schema(tracked, &request.name, &context, SchemaLogs::Silent).await;
stripped(request, schema.entry.as_ref())
}
Unrecorded::Degraded => request,
};
forward(tracked, request, context).await
}
async fn forward<H: ServerHandler>(
tracked: &Tracked<H>,
request: CallToolRequestParams,
mut context: RequestContext<RoleServer>,
) -> Result<CallToolResponse, ErrorData> {
let call = CallContext::new(String::new(), None, tracked.agentcat().clone());
context.extensions.insert(call);
tracked.inner().call_tool(request, context).await
}
fn stripped(
mut request: CallToolRequestParams,
entry: Option<&ToolEntry>,
) -> CallToolRequestParams {
let arguments = request
.arguments
.take()
.map(|arguments| strip_arguments(&arguments, entry, &request.name));
request.arguments = arguments;
request
}
fn answer_report_missing(shared: &Inner, request: &CallToolRequestParams) -> CallToolResult {
let (result, line) = report_missing(context_argument(request));
shared.sink.log(line);
result
}
fn context_argument(request: &CallToolRequestParams) -> Option<&Value> {
request.arguments.as_ref()?.get(CONTEXT_PARAM)
}
fn resource_name(name: &str) -> String {
if name.is_empty() {
UNKNOWN_TOOL.to_owned()
} else {
name.to_owned()
}
}
pub(crate) fn server_info<H: ServerHandler>(shared: &Inner, inner: &H) -> Option<Implementation> {
match catch_unwind(AssertUnwindSafe(|| inner.get_info().server_info)) {
Ok(server) => Some(server),
Err(payload) => {
let message = panic_message(&*payload);
shared
.sink
.log(format!("Warning: get_info panicked: {message}"));
None
}
}
}
fn user_intent(options: &Options, request: &CallToolRequestParams) -> Option<Value> {
if !options.tool_call_context() || request.name == GET_MORE_TOOLS_NAME {
return None;
}
context_argument(request).cloned()
}
async fn learn_schema<H: ServerHandler>(
tracked: &Tracked<H>,
name: &str,
context: &RequestContext<RoleServer>,
logs: SchemaLogs,
) -> Schema {
let shared = tracked.agentcat().inner();
let looked_up = match catch_unwind(AssertUnwindSafe(|| tracked.lookup_tool(name))) {
Ok(found) => found.map(|(_tool, entry)| entry),
Err(payload) => {
let message = panic_message(&*payload);
logs.log(shared, || {
format!("Warning: Tool lookup for {name} panicked: {message}")
});
None
}
};
let mut known = registry_view::<H>(shared, name);
if looked_up.is_none() && known.entry.is_none() && !known.listed {
rebuild(tracked, context, logs).await;
known = registry_view::<H>(shared, name);
}
let foreign = known.declares_session
|| looked_up
.as_ref()
.is_some_and(|entry| entry.declares_session);
let entry = looked_up.or(known.entry);
let mirror = entry
.as_ref()
.map_or(!known.listed, |entry| entry.output_injected);
Schema {
entry,
foreign,
mirror,
}
}
struct RegistryView {
entry: Option<ToolEntry>,
listed: bool,
declares_session: bool,
}
fn registry_view<H: 'static>(shared: &Inner, name: &str) -> RegistryView {
shared.with_registry::<H, _>(|registry| RegistryView {
entry: registry.entry(name).cloned(),
listed: registry.listed(),
declares_session: registry.declares_session(name),
})
}
async fn rebuild<H: ServerHandler>(
tracked: &Tracked<H>,
context: &RequestContext<RoleServer>,
logs: SchemaLogs,
) {
let shared = tracked.agentcat().inner();
let pages = AssertUnwindSafe(list_pages(tracked.inner(), context)).catch_unwind();
let failure = match tokio::time::timeout(REBUILD_TIMEOUT, pages).await {
Ok(Ok(Ok(pages))) => return record_pages::<H>(shared, pages, logs),
Ok(Ok(Err(error))) => error.message.into_owned(),
Ok(Err(payload)) => panic_message(&*payload),
Err(_elapsed) => {
let millis = REBUILD_TIMEOUT.as_millis();
format!("tools/list did not respond within {millis}ms")
}
};
logs.log(shared, || {
format!("Warning: registry rebuild-on-demand failed - Error: {failure}")
});
}
fn record_pages<H: 'static>(shared: &Inner, pages: Vec<Vec<Tool>>, logs: SchemaLogs) {
let config = InjectConfig::from_options(&shared.options);
let lines = shared.with_registry::<H, _>(|registry| {
let mut lines = Vec::new();
for tools in pages {
let list = build_injected_list(&config, tools, registry.state_mut());
registry.record(&list);
lines.extend(list.logs);
}
lines
});
for line in lines {
logs.log(shared, || line);
}
logs.log(shared, || REBUILT.to_owned());
}
async fn list_pages<H: ServerHandler>(
inner: &H,
context: &RequestContext<RoleServer>,
) -> Result<Vec<Vec<Tool>>, ErrorData> {
let mut pages = Vec::new();
let mut seen = HashSet::new();
let mut params = PaginatedRequestParams::default();
loop {
let page = inner.list_tools(Some(params), context.clone()).await?;
pages.push(page.tools);
match page.next_cursor {
Some(cursor) if seen.insert(cursor.clone()) => {
params = PaginatedRequestParams::default().with_cursor(Some(cursor));
tokio::task::yield_now().await;
}
_ => return Ok(pages),
}
}
}
fn finish(
guard: RecordGuard<'_>,
name: &str,
mint_back: MintBack,
response: CallToolResponse,
captured: Option<Captured>,
) -> CallToolResponse {
let original = match response_json(&response) {
Ok(original) => original,
Err(error) => {
guard.discard(format!(
"Warning: AgentCat post-handler processing failed for tool {name}; returning the customer's result untouched - {error}"
));
return response;
}
};
let mut result_failure = None;
let mut input_required = false;
let response = match response {
CallToolResponse::Complete(mut result) => {
if result.is_error == Some(true) {
result_failure = Some(result_error(&result));
}
if let Some(text) = mint_back.text {
prepend_mint_back(&mut result, text);
}
if let Some(mint) = mint_back.mirror {
mirror_mint_back(&mut result.structured_content, mint);
}
CallToolResponse::Complete(result)
}
CallToolResponse::InputRequired(result) => {
input_required = true;
CallToolResponse::InputRequired(result)
}
other => other,
};
guard.send(|record| {
if let Some(failure) = result_failure {
set_error(record, captured, || failure);
}
if input_required {
record
.tags
.insert(AGENTCAT_TAG_MRTR.to_owned(), MRTR_INPUT_REQUIRED.into());
}
record.output_tokens = estimate_output_tokens(Some(&original));
record.response = Some(original);
});
response
}
fn set_error(
record: &mut CallRecord,
captured: Option<Captured>,
fallback: impl FnOnce() -> Map<String, Value>,
) {
record.is_error = true;
let error = match captured {
Some((error, backtrace)) => {
record.backtrace = backtrace;
error
}
None => fallback(),
};
record.error = Some(error);
}
fn response_json(response: &CallToolResponse) -> serde_json::Result<Value> {
match response {
CallToolResponse::Complete(result) => serde_json::to_value(result),
CallToolResponse::InputRequired(result) => serde_json::to_value(result),
other => serde_json::to_value(ServerResult::from(other.clone())),
}
}
fn elapsed_ms(started: Instant) -> u64 {
u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
}