use std::sync::Arc;
use fraiseql_auth::session_state::SessionState;
use fraiseql_core::security::SecurityContext;
use serde_json::json;
use uuid::Uuid;
const SESSION_HEADER: &str = "mcp-session-id";
const CONTEXT_KEY: &str = "_context";
const MAX_REMEMBERED_CALLS: usize = 20;
const PRINCIPAL_NAMESPACE: Uuid = Uuid::from_bytes([
0x96, 0x70, 0xf7, 0xa1, 0x4e, 0x2b, 0x5c, 0x8d, 0x9a, 0x03, 0x1f, 0x6e, 0x77, 0xb4, 0x22, 0x0e,
]);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ThreadKey {
pub session_id: Uuid,
pub thread_id: String,
}
#[must_use]
pub fn thread_key(
security_context: Option<&SecurityContext>,
headers: &axum::http::HeaderMap,
) -> Option<ThreadKey> {
let ctx = security_context?;
let thread_id = headers.get(SESSION_HEADER)?.to_str().ok()?.trim();
if thread_id.is_empty() {
return None;
}
Some(ThreadKey {
session_id: Uuid::new_v5(&PRINCIPAL_NAMESPACE, ctx.user_id.0.as_bytes()),
thread_id: thread_id.to_string(),
})
}
pub async fn read_context(store: &Arc<SessionState>, key: &ThreadKey) -> Vec<serde_json::Value> {
match store.get(key.session_id, &key.thread_id, CONTEXT_KEY).await {
Ok(Some(entry)) => {
entry.value.get("calls").and_then(|c| c.as_array().cloned()).unwrap_or_default()
},
Ok(None) => Vec::new(),
Err(e) => {
tracing::warn!(error = %e, "MCP session state unreadable; continuing without it");
Vec::new()
},
}
}
pub async fn record_call(
store: &Arc<SessionState>,
key: &ThreadKey,
tool_name: &str,
arguments: Option<&serde_json::Map<String, serde_json::Value>>,
prior: Vec<serde_json::Value>,
) {
let mut argument_names: Vec<&String> =
arguments.map(|a| a.keys().collect()).unwrap_or_default();
argument_names.sort();
let mut calls = prior;
calls.push(json!({
"tool": tool_name,
"arguments": argument_names,
"at": chrono::Utc::now().to_rfc3339(),
}));
if calls.len() > MAX_REMEMBERED_CALLS {
let drop = calls.len() - MAX_REMEMBERED_CALLS;
calls.drain(..drop);
}
if let Err(e) = store
.set(key.session_id, &key.thread_id, CONTEXT_KEY, json!({ "calls": calls }))
.await
{
tracing::warn!(error = %e, "MCP session state not written; the tool call itself succeeded");
}
}
pub fn attach_context(
result: &mut rmcp::model::CallToolResult,
key: &ThreadKey,
calls: &[serde_json::Value],
) {
let mut meta = result.meta.take().unwrap_or_default();
meta.insert(
"fraiseql/session".to_string(),
json!({
"threadId": key.thread_id,
"calls": calls,
}),
);
result.meta = Some(meta);
}
#[cfg(test)]
mod tests;