use serde::{Deserialize, Serialize};
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TraceContext {
pub trace_id: String,
pub span_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_span_id: Option<String>,
pub depth: u32,
}
impl TraceContext {
pub fn new_root() -> Self {
Self {
trace_id: Uuid::new_v4().to_string(),
span_id: Uuid::new_v4().to_string(),
parent_span_id: None,
depth: 0,
}
}
pub fn child(&self) -> Self {
Self {
trace_id: self.trace_id.clone(),
span_id: Uuid::new_v4().to_string(),
parent_span_id: Some(self.span_id.clone()),
depth: self.depth + 1,
}
}
pub fn from_parent(trace_id: String, parent_span_id: Option<String>, depth: u32) -> Self {
Self {
trace_id,
span_id: Uuid::new_v4().to_string(),
parent_span_id,
depth,
}
}
pub fn short_trace_id(&self) -> &str {
&self.trace_id[..8.min(self.trace_id.len())]
}
}
impl Default for TraceContext {
fn default() -> Self {
Self::new_root()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct RequestMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub client_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_ip: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
}
impl RequestMetadata {
pub fn with_client_type(mut self, client_type: impl Into<String>) -> Self {
self.client_type = Some(client_type.into());
self
}
pub fn with_client_version(mut self, version: impl Into<String>) -> Self {
self.client_version = Some(version.into());
self
}
pub fn with_client_ip(mut self, ip: impl Into<String>) -> Self {
self.client_ip = Some(ip.into());
self
}
pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
self.session_id = Some(session_id.into());
self
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct McpOperationDetails {
pub method: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub arguments_hash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resource_uri: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_arguments_hash: Option<String>,
}
impl McpOperationDetails {
pub fn tool_call(tool_name: impl Into<String>) -> Self {
Self {
method: "tools/call".to_string(),
tool_name: Some(tool_name.into()),
..Default::default()
}
}
pub fn resource_read(uri: impl Into<String>) -> Self {
Self {
method: "resources/read".to_string(),
resource_uri: Some(uri.into()),
..Default::default()
}
}
pub fn prompt_get(name: impl Into<String>) -> Self {
Self {
method: "prompts/get".to_string(),
prompt_name: Some(name.into()),
..Default::default()
}
}
pub fn from_request(
method: &str,
params: Option<&serde_json::Value>,
capture_hash: bool,
) -> Self {
let mut details = Self {
method: method.to_string(),
..Default::default()
};
if let Some(params) = params {
match method {
"tools/call" => {
details.tool_name = params
.get("name")
.and_then(|v| v.as_str())
.map(String::from);
if capture_hash {
details.arguments_hash = params.get("arguments").map(hash_value);
}
},
"resources/read" => {
details.resource_uri =
params.get("uri").and_then(|v| v.as_str()).map(String::from);
},
"prompts/get" => {
details.prompt_name = params
.get("name")
.and_then(|v| v.as_str())
.map(String::from);
if capture_hash {
details.prompt_arguments_hash = params.get("arguments").map(hash_value);
}
},
_ => {},
}
}
details
}
pub fn operation_name(&self) -> Option<&str> {
self.tool_name
.as_deref()
.or(self.prompt_name.as_deref())
.or(self.resource_uri.as_deref())
}
pub fn with_arguments_hash(mut self, hash: impl Into<String>) -> Self {
self.arguments_hash = Some(hash.into());
self
}
}
pub fn hash_value(value: &serde_json::Value) -> String {
let mut hasher = DefaultHasher::new();
#[allow(clippy::collection_is_never_read)]
let json_str = serde_json::to_string(value).unwrap_or_default();
json_str.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_trace_context_new_root() {
let ctx = TraceContext::new_root();
assert!(!ctx.trace_id.is_empty());
assert!(!ctx.span_id.is_empty());
assert!(ctx.parent_span_id.is_none());
assert_eq!(ctx.depth, 0);
}
#[test]
fn test_trace_context_child() {
let parent = TraceContext::new_root();
let child = parent.child();
assert_eq!(parent.trace_id, child.trace_id);
assert_ne!(parent.span_id, child.span_id);
assert_eq!(child.parent_span_id, Some(parent.span_id.clone()));
assert_eq!(child.depth, 1);
}
#[test]
fn test_trace_context_chain() {
let root = TraceContext::new_root();
let child1 = root.child();
let child2 = child1.child();
assert_eq!(root.trace_id, child2.trace_id);
assert_eq!(child2.depth, 2);
assert_eq!(child2.parent_span_id, Some(child1.span_id));
}
#[test]
fn test_request_metadata_builder() {
let metadata = RequestMetadata::default()
.with_client_type("claude-desktop")
.with_client_version("1.2.3")
.with_session_id("session-123");
assert_eq!(metadata.client_type, Some("claude-desktop".to_string()));
assert_eq!(metadata.client_version, Some("1.2.3".to_string()));
assert_eq!(metadata.session_id, Some("session-123".to_string()));
}
#[test]
fn test_operation_details_tool_call() {
let params = json!({
"name": "get_weather",
"arguments": {"city": "NYC"}
});
let details = McpOperationDetails::from_request("tools/call", Some(¶ms), true);
assert_eq!(details.method, "tools/call");
assert_eq!(details.tool_name, Some("get_weather".to_string()));
assert!(details.arguments_hash.is_some());
assert_eq!(details.operation_name(), Some("get_weather"));
}
#[test]
fn test_operation_details_resource_read() {
let params = json!({
"uri": "file:///path/to/resource"
});
let details = McpOperationDetails::from_request("resources/read", Some(¶ms), false);
assert_eq!(details.method, "resources/read");
assert_eq!(
details.resource_uri,
Some("file:///path/to/resource".to_string())
);
assert_eq!(details.operation_name(), Some("file:///path/to/resource"));
}
#[test]
fn test_hash_value_deterministic() {
let value = json!({"city": "NYC", "country": "USA"});
let hash1 = hash_value(&value);
let hash2 = hash_value(&value);
assert_eq!(hash1, hash2);
assert_eq!(hash1.len(), 16); }
#[test]
fn test_hash_value_different_values() {
let value1 = json!({"city": "NYC"});
let value2 = json!({"city": "LA"});
assert_ne!(hash_value(&value1), hash_value(&value2));
}
#[test]
fn test_trace_context_serialization() {
let ctx = TraceContext::new_root();
let json = serde_json::to_string(&ctx).unwrap();
let deserialized: TraceContext = serde_json::from_str(&json).unwrap();
assert_eq!(ctx, deserialized);
}
#[test]
fn test_short_trace_id() {
let ctx = TraceContext::new_root();
let short = ctx.short_trace_id();
assert_eq!(short.len(), 8);
assert!(ctx.trace_id.starts_with(short));
}
}