use std::backtrace::Backtrace;
use std::fmt;
use std::time::SystemTime;
use http::header::{COOKIE, HOST, SET_COOKIE};
use http::request::Parts;
use http::uri::PathAndQuery;
use http::{HeaderMap, HeaderName};
use rmcp::RoleServer;
use rmcp::model::{CallToolRequestParams, Implementation, JsonObject};
use rmcp::service::RequestContext;
use serde_json::{Map, Value};
use tokio::runtime::Handle;
use crate::constants::{META_CLIENT_INFO_KEY, META_PROTOCOL_VERSION_KEY};
use crate::handles::HandleResolution;
use crate::hooks::PendingHooks;
use crate::wire;
pub(crate) mod assemble;
mod public;
#[cfg(test)]
mod tests;
pub use public::Event;
const CALL_TOOL_METHOD: &str = "tools/call";
const JSONRPC_VERSION: &str = "2.0";
const META_KEY: &str = "_meta";
const SESSION_ID_HEADER: &str = "mcp-session-id";
const CREDENTIAL_HEADERS: [&str; 4] = [
"authorization",
"proxy-authorization",
"cookie",
"set-cookie",
];
const REDACTED: &str = "<redacted>";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ClientIdentity {
pub(crate) name: Option<String>,
pub(crate) version: Option<String>,
}
impl From<&Implementation> for ClientIdentity {
fn from(client: &Implementation) -> Self {
Self {
name: Some(client.name.clone()),
version: Some(client.version.clone()),
}
}
}
pub(crate) struct CallRecord {
pub(crate) started_at: SystemTime,
pub(crate) duration_ms: u64,
pub(crate) resolution: HandleResolution,
pub(crate) resource_name: String,
pub(crate) request: Value,
pub(crate) extra: Value,
pub(crate) input_tokens: Option<i32>,
pub(crate) output_tokens: Option<i32>,
pub(crate) tags: Map<String, Value>,
pub(crate) user_intent: Option<Value>,
pub(crate) response: Option<Value>,
pub(crate) is_error: bool,
pub(crate) error: Option<Map<String, Value>>,
pub(crate) server_name: Option<String>,
pub(crate) server_version: Option<String>,
pub(crate) client: Option<ClientIdentity>,
pub(crate) pending: PendingHooks,
pub(crate) runtime: Option<Handle>,
pub(crate) backtrace: Option<Backtrace>,
}
impl fmt::Debug for CallRecord {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CallRecord")
.field("started_at", &self.started_at)
.field("duration_ms", &self.duration_ms)
.field("resolution", &self.resolution)
.field("resource_name", &self.resource_name)
.field("request", &self.request)
.field("extra", &redact_extra(&self.extra))
.field("input_tokens", &self.input_tokens)
.field("output_tokens", &self.output_tokens)
.field("tags", &self.tags)
.field("user_intent", &self.user_intent)
.field("response", &self.response)
.field("is_error", &self.is_error)
.field("error", &self.error)
.field("server_name", &self.server_name)
.field("server_version", &self.server_version)
.field("client", &self.client)
.field("pending", &self.pending)
.field("runtime", &self.runtime.is_some())
.field("backtrace", &self.backtrace.as_ref().map(Backtrace::status))
.finish()
}
}
pub(crate) fn request_value(
params: &CallToolRequestParams,
context: &RequestContext<RoleServer>,
) -> Value {
let mut fields: Map<String, Value> = match serde_json::to_value(params) {
Ok(Value::Object(fields)) => fields
.into_iter()
.filter(|(key, _)| key != META_KEY)
.collect(),
_ => Map::new(),
};
if let Some(meta) = request_meta(context) {
fields.insert(META_KEY.to_owned(), meta);
}
let mut request = Map::new();
request.insert(
"method".to_owned(),
Value::String(CALL_TOOL_METHOD.to_owned()),
);
request.insert("params".to_owned(), Value::Object(fields));
request.insert(
"jsonrpc".to_owned(),
Value::String(JSONRPC_VERSION.to_owned()),
);
request.insert("id".to_owned(), context.id.clone().into_json_value());
Value::Object(request)
}
pub(crate) fn extra_value(context: &RequestContext<RoleServer>) -> Value {
let parts = context.extensions.get::<Parts>();
let mut extra = Map::new();
if let Some(session) = transport_session_id(context) {
extra.insert("sessionId".to_owned(), Value::String(session));
}
if let Some(meta) = request_meta(context) {
extra.insert(META_KEY.to_owned(), meta);
}
extra.insert("requestId".to_owned(), context.id.clone().into_json_value());
if let Some(parts) = parts {
extra.insert("requestInfo".to_owned(), Value::Object(request_info(parts)));
}
Value::Object(extra)
}
pub(crate) fn transport_session_id(context: &RequestContext<RoleServer>) -> Option<String> {
let parts = context.extensions.get::<Parts>()?;
let session = parts.headers.get(SESSION_ID_HEADER)?;
Some(lossy(session.as_bytes()))
}
fn request_info(parts: &Parts) -> Map<String, Value> {
let mut info = Map::new();
info.insert(
"headers".to_owned(),
Value::Object(project_headers(&parts.headers)),
);
if let Some(url) = request_url(parts) {
info.insert("url".to_owned(), Value::String(url));
}
info
}
pub(crate) fn project_headers(headers: &HeaderMap) -> Map<String, Value> {
let mut names: Vec<&HeaderName> = headers.keys().collect();
names.sort_unstable_by(|a, b| a.as_str().cmp(b.as_str()));
let sorted: Map<String, Value> = names
.into_iter()
.map(|name| {
let mut values = headers
.get_all(name)
.iter()
.map(|value| trim_http_whitespace(&lossy(value.as_bytes())).to_owned());
let value = if name == SET_COOKIE {
values.next_back().unwrap_or_default()
} else {
let separator = if name == COOKIE { "; " } else { ", " };
values.collect::<Vec<_>>().join(separator)
};
(name.as_str().to_owned(), Value::String(value))
})
.collect();
wire::own_entries(&sorted)
.map(|(name, value)| (name.clone(), value.clone()))
.collect()
}
pub(crate) fn redact_headers(headers: &Map<String, Value>) -> Map<String, Value> {
headers
.iter()
.map(|(name, value)| {
let credential = CREDENTIAL_HEADERS
.iter()
.any(|credential| name.eq_ignore_ascii_case(credential));
let value = if credential {
Value::String(REDACTED.to_owned())
} else {
value.clone()
};
(name.clone(), value)
})
.collect()
}
pub(crate) fn redact_extra(extra: &Value) -> Value {
let mut extra = extra.clone();
if let Some(Value::Object(headers)) = extra.pointer_mut("/requestInfo/headers") {
*headers = redact_headers(headers);
}
extra
}
fn trim_http_whitespace(text: &str) -> &str {
text.trim_matches(['\t', '\n', '\r', ' '])
}
pub(crate) fn request_url(parts: &Parts) -> Option<String> {
let uri = &parts.uri;
if uri.scheme().is_some() && uri.authority().is_some() {
return Some(uri.to_string());
}
let host = parts.headers.get(HOST)?;
let path = uri.path_and_query().map_or("/", PathAndQuery::as_str);
Some(format!("http://{}{path}", lossy(host.as_bytes())))
}
pub(crate) fn narrow_client_info(value: Option<&Value>) -> Option<ClientIdentity> {
let Some(Value::Object(client)) = value else {
return None;
};
let text = |key: &str| client.get(key).and_then(Value::as_str).map(str::to_owned);
let (name, version) = (text("name"), text("version"));
(name.is_some() || version.is_some()).then_some(ClientIdentity { name, version })
}
pub(crate) fn client_identity(context: &RequestContext<RoleServer>) -> Option<ClientIdentity> {
if let Some(client) = narrow_client_info(context.meta.get(META_CLIENT_INFO_KEY)) {
return Some(client);
}
let stateless = context
.extensions
.get::<Parts>()
.is_some_and(|parts| !parts.headers.contains_key(SESSION_ID_HEADER));
if stateless {
return None;
}
let info = context.peer.peer_info()?;
Some(ClientIdentity::from(&info.client_info))
}
pub(crate) fn protocol_version(meta: &JsonObject) -> Option<String> {
match meta.get(META_PROTOCOL_VERSION_KEY) {
Some(Value::String(version)) if !version.is_empty() => Some(version.clone()),
_ => None,
}
}
fn request_meta(context: &RequestContext<RoleServer>) -> Option<Value> {
let meta: &JsonObject = &context.meta;
(!meta.is_empty()).then(|| Value::Object(meta.clone()))
}
fn lossy(bytes: &[u8]) -> String {
String::from_utf8_lossy(bytes).into_owned()
}