use std::fmt;
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use http::request::Parts;
use rmcp::RoleServer;
use rmcp::model::{CallToolRequestParams, JsonObject};
use rmcp::service::RequestContext;
use serde_json::{Map, Value};
use tokio::runtime::Handle;
use tokio::task::{JoinError, JoinHandle};
use crate::config::{Hook, HookError, Options, UserIdentity};
use crate::event::{
ClientIdentity, project_headers, protocol_version, redact_headers, transport_session_id,
};
use crate::sink::Sink;
#[cfg(test)]
mod tests;
#[cfg(test)]
thread_local! {
static SNAPSHOTS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[cfg(test)]
pub(crate) fn snapshots_built() -> usize {
SNAPSHOTS.get()
}
#[derive(Clone)]
pub struct ToolCall {
tool_name: String,
arguments: Option<JsonObject>,
meta: JsonObject,
headers: Option<Map<String, Value>>,
client: Option<ClientIdentity>,
protocol_version: Option<String>,
transport_session_id: Option<String>,
request_context: RequestContext<RoleServer>,
}
impl ToolCall {
pub(crate) fn new(
request: &CallToolRequestParams,
context: &RequestContext<RoleServer>,
client: Option<&ClientIdentity>,
) -> Self {
#[cfg(test)]
SNAPSHOTS.set(SNAPSHOTS.get() + 1);
let meta: &JsonObject = &context.meta;
Self {
tool_name: request.name.to_string(),
arguments: request.arguments.clone(),
meta: meta.clone(),
headers: context
.extensions
.get::<Parts>()
.map(|parts| project_headers(&parts.headers)),
client: client.cloned(),
protocol_version: protocol_version(meta),
transport_session_id: transport_session_id(context),
request_context: context.clone(),
}
}
#[must_use]
pub fn tool_name(&self) -> &str {
&self.tool_name
}
#[must_use]
pub fn arguments(&self) -> Option<&JsonObject> {
self.arguments.as_ref()
}
#[must_use]
pub fn meta(&self) -> &JsonObject {
&self.meta
}
#[must_use]
pub fn headers(&self) -> Option<&Map<String, Value>> {
self.headers.as_ref()
}
#[must_use]
pub fn client_name(&self) -> Option<&str> {
self.client.as_ref()?.name.as_deref()
}
#[must_use]
pub fn client_version(&self) -> Option<&str> {
self.client.as_ref()?.version.as_deref()
}
#[must_use]
pub fn protocol_version(&self) -> Option<&str> {
self.protocol_version.as_deref()
}
#[must_use]
pub fn transport_session_id(&self) -> Option<&str> {
self.transport_session_id.as_deref()
}
#[must_use]
pub fn request_context(&self) -> &RequestContext<RoleServer> {
&self.request_context
}
}
impl fmt::Debug for ToolCall {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ToolCall")
.field("tool_name", &self.tool_name)
.field("arguments", &self.arguments)
.field("meta", &self.meta)
.field("headers", &self.headers.as_ref().map(redact_headers))
.field("client", &self.client)
.field("protocol_version", &self.protocol_version)
.field("transport_session_id", &self.transport_session_id)
.field("request_context", &ContextSummary(&self.request_context))
.finish()
}
}
struct ContextSummary<'a>(&'a RequestContext<RoleServer>);
impl fmt::Debug for ContextSummary<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RequestContext")
.field("id", &self.0.id)
.field("http_parts", &self.0.extensions.get::<Parts>().is_some())
.finish_non_exhaustive()
}
}
type Settled<T> = Result<Option<T>, HookError>;
#[derive(Debug, Default)]
pub(crate) struct PendingHooks {
pub(crate) resolve_session_id: Option<JoinHandle<Settled<String>>>,
pub(crate) identify: Option<JoinHandle<Settled<UserIdentity>>>,
pub(crate) event_tags: Option<JoinHandle<Settled<Map<String, Value>>>>,
pub(crate) event_properties: Option<JoinHandle<Settled<Map<String, Value>>>>,
}
impl PendingHooks {
pub(crate) fn any(&self) -> bool {
self.resolve_session_id.is_some()
|| self.identify.is_some()
|| self.event_tags.is_some()
|| self.event_properties.is_some()
}
}
impl Drop for PendingHooks {
fn drop(&mut self) {
if let Some(task) = &self.resolve_session_id {
task.abort();
}
if let Some(task) = &self.identify {
task.abort();
}
if let Some(task) = &self.event_tags {
task.abort();
}
if let Some(task) = &self.event_properties {
task.abort();
}
}
}
#[derive(Debug)]
pub(crate) struct AbortOnDrop<T>(JoinHandle<T>);
impl<T> AbortOnDrop<T> {
pub(crate) fn new(task: JoinHandle<T>) -> Self {
Self(task)
}
}
impl<T> Future for AbortOnDrop<T> {
type Output = Result<T, JoinError>;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
Pin::new(&mut self.0).poll(cx)
}
}
impl<T> Drop for AbortOnDrop<T> {
fn drop(&mut self) {
self.0.abort();
}
}
pub(crate) fn fire(
options: &Options,
hook_mode: bool,
sink: &dyn Sink,
snapshot: impl FnOnce() -> ToolCall,
) -> PendingHooks {
let resolve_session_id = options.resolve_session_id_hook().filter(|_| hook_mode);
let identify = options.identify_hook();
let event_tags = options.event_tags_hook();
let event_properties = options.event_properties_hook();
let set = [
("resolve_session_id", resolve_session_id.is_some()),
("identify", identify.is_some()),
("event_tags", event_tags.is_some()),
("event_properties", event_properties.is_some()),
];
if !set.iter().any(|&(_, is_set)| is_set) {
return PendingHooks::default();
}
let Ok(handle) = Handle::try_current() else {
for (label, _) in set.into_iter().filter(|&(_, is_set)| is_set) {
sink.log(format!(
"Warning: {label} hook not started - no tokio runtime"
));
}
return PendingHooks::default();
};
let call = snapshot();
PendingHooks {
resolve_session_id: resolve_session_id.map(|hook| spawn(&handle, hook, &call)),
identify: identify.map(|hook| spawn(&handle, hook, &call)),
event_tags: event_tags.map(|hook| spawn(&handle, hook, &call)),
event_properties: event_properties.map(|hook| spawn(&handle, hook, &call)),
}
}
fn spawn<T: Send + 'static>(
handle: &Handle,
hook: &Hook<T>,
call: &ToolCall,
) -> JoinHandle<Settled<T>> {
handle.spawn(hook.call(call.clone()))
}