roder_api/mcp_auth.rs
1//! Per-thread MCP bearer-token registry.
2//!
3//! The remote app-server is a single process shared by many client sessions and
4//! configures its MCP servers once at startup (a process-wide token). To let a
5//! remote client scope a thread's MCP tool calls to a specific identity (for
6//! Vex: a per-user-and-organization capability token), the client forwards the
7//! token at `thread/start`; the app-server records it here keyed by thread id,
8//! and the MCP tool extension reads it during execution via
9//! [`crate::tools::ToolExecutionContext::thread_id`].
10//!
11//! Lives in `roder-api` so both the app-server (writer) and MCP tool extension
12//! (reader) can reach it without depending on one another. The map is in-memory
13//! only: tokens are re-supplied by the client on the next `thread/start`, and
14//! they are expected to be short-lived capability tokens.
15
16use std::collections::HashMap;
17use std::sync::{OnceLock, RwLock};
18
19fn registry() -> &'static RwLock<HashMap<String, String>> {
20 static REGISTRY: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
21 REGISTRY.get_or_init(|| RwLock::new(HashMap::new()))
22}
23
24/// Records the MCP bearer token a remote client supplied for `thread_id`. An
25/// empty token is ignored (treated as "use the process default").
26pub fn set_thread_token(thread_id: impl Into<String>, token: impl Into<String>) {
27 let token = token.into();
28 if token.trim().is_empty() {
29 return;
30 }
31 if let Ok(mut map) = registry().write() {
32 map.insert(thread_id.into(), token);
33 }
34}
35
36/// Forgets any token recorded for `thread_id` (e.g. on archive).
37pub fn clear_thread_token(thread_id: &str) {
38 if let Ok(mut map) = registry().write() {
39 map.remove(thread_id);
40 }
41}
42
43/// The MCP bearer token to use for `thread_id`, if one was registered.
44pub fn thread_token(thread_id: &str) -> Option<String> {
45 registry()
46 .read()
47 .ok()
48 .and_then(|map| map.get(thread_id).cloned())
49}