objectiveai_mcp_proxy/session_manager.rs
1//! Session registry.
2//!
3//! Maps the **objectiveai response id** (`X-OBJECTIVEAI-RESPONSE-ID`) to
4//! the [`Session`] holding that response's live upstream MCP
5//! connections. Routing is keyed entirely by response id; the
6//! `Mcp-Session-Id` the proxy mints on `initialize` is a plain random
7//! UUID returned for MCP-spec compliance only (so 3rd-party clients like
8//! the Claude Agent SDK get a valid session header) — the proxy never
9//! routes on it.
10//!
11//! All per-session dispatch (list, call, read) lives on [`Session`]
12//! itself; this file only packs the opened connections into a [`Session`]
13//! under their response id and looks sessions back up.
14//!
15//! **The pending-initialization gate.** `initialize`'s fresh-connect
16//! path dials every upstream before registering the session, and client
17//! requests (`tools/list`, `tools/call`, `resources/*`, `servers/list`)
18//! can arrive INSIDE that window — notably from an upstream server that
19//! calls back into the proxy while it is itself being connected. Rather
20//! than 404ing, those requests use [`SessionManager::get_or_wait`]: a
21//! miss on a response id whose initial connect is in flight parks until
22//! the initializer finishes (success or failure), then resolves against
23//! the final state. Ids that are neither registered nor initializing
24//! still miss immediately.
25
26use std::sync::Arc;
27
28use dashmap::DashMap;
29use indexmap::IndexMap;
30
31use crate::reverse_channel::Upstream;
32use crate::session::Session;
33
34/// Maps an objectiveai response id to its [`Session`] state.
35#[derive(Debug, Default)]
36pub struct SessionManager {
37 sessions: DashMap<String, Arc<Session>>,
38 /// Response ids whose initial connect is in flight. The receiver
39 /// resolves (sender dropped) when the initializer finishes —
40 /// success or failure alike; the entry is removed by the
41 /// [`InitGuard`]'s `Drop`, so it can never outlive its
42 /// initializer.
43 pending: Arc<DashMap<String, tokio::sync::watch::Receiver<()>>>,
44}
45
46impl SessionManager {
47 pub fn new() -> Self {
48 Self::default()
49 }
50
51 /// Register the upstreams opened for one objectiveai response under
52 /// its response id. Builds the `prefix -> Upstream` routing map (see
53 /// [`build_prefix_map`]) and inserts a fresh [`Session`]. An existing
54 /// entry for the same response id is replaced.
55 pub fn add(&self, response_id: String, connections: Vec<Upstream>) {
56 let by_prefix = build_prefix_map(connections);
57 self.sessions
58 .insert(response_id, Arc::new(Session::new(by_prefix)));
59 }
60
61 /// Cheap clone-out of a [`Session`] by response id — never holds a
62 /// DashMap guard across the await boundary.
63 pub fn get(&self, response_id: &str) -> Option<Arc<Session>> {
64 self.sessions.get(response_id).map(|e| e.value().clone())
65 }
66
67 /// Mark `response_id`'s initial connect as in flight. Client-request
68 /// lookups via [`Self::get_or_wait`] park until the returned
69 /// [`InitGuard`] drops — which the initializer does on EVERY exit
70 /// path (registered, connect failure, ban teardown, panic), since
71 /// release lives in `Drop`.
72 pub fn begin_initializing(&self, response_id: &str) -> InitGuard {
73 let (tx, rx) = tokio::sync::watch::channel(());
74 self.pending.insert(response_id.to_string(), rx);
75 InitGuard {
76 pending: Arc::clone(&self.pending),
77 response_id: response_id.to_string(),
78 _tx: tx,
79 }
80 }
81
82 /// The in-flight marker for `response_id`, if its initial connect is
83 /// currently running. Clone-out — no guard held across awaits (same
84 /// discipline as [`Self::get`]).
85 pub fn initializing(
86 &self,
87 response_id: &str,
88 ) -> Option<tokio::sync::watch::Receiver<()>> {
89 self.pending.get(response_id).map(|e| e.value().clone())
90 }
91
92 /// [`Self::get`], but a miss on a response id whose initial connect
93 /// is in flight WAITS for the initializer to finish and resolves
94 /// against the final state: `Some` when the connect registered the
95 /// session, `None` when it failed (or the id was never
96 /// initializing). Boundedness is inherited from the initializer —
97 /// the guard drops when `initialize` returns, which is itself
98 /// bounded by the per-upstream connect/probe timeouts.
99 pub async fn get_or_wait(&self, response_id: &str) -> Option<Arc<Session>> {
100 loop {
101 if let Some(session) = self.get(response_id) {
102 return Some(session);
103 }
104 let Some(mut rx) = self.initializing(response_id) else {
105 // Close the get→pending gap: an initializer may have
106 // registered + released between our two probes.
107 return self.get(response_id);
108 };
109 // Sender dropped (Err) ⇒ the initializer finished; drain
110 // any intermediate change notifications until then.
111 while rx.changed().await.is_ok() {}
112 // Re-probe: session present on success. On failure the
113 // pending entry is gone too, so the next miss exits via
114 // the else branch above.
115 }
116 }
117
118 /// Remove a session from the registry by response id. Returns
119 /// `Some(_)` if a session was present, `None` if the id was unknown.
120 ///
121 /// Once every `Arc<Session>` to the removed session has dropped, the
122 /// session's `IndexMap<String, Connection>` drops, every `Connection`'s
123 /// `Drop` fires its upstream's wakeup signal, and each upstream's
124 /// listener task wakes to re-check liveness. The listener sees
125 /// `Arc::strong_count == 1` (only itself) and exits, which drops the
126 /// inner state and closes the upstream HTTP session.
127 pub fn remove(&self, response_id: &str) -> Option<Arc<Session>> {
128 self.sessions.remove(response_id).map(|(_, session)| session)
129 }
130}
131
132/// The initializer's hold on a response id's pending-initialization
133/// marker — see [`SessionManager::begin_initializing`]. Dropping it
134/// removes the marker and wakes every [`SessionManager::get_or_wait`]
135/// parked on the id.
136pub struct InitGuard {
137 pending: Arc<DashMap<String, tokio::sync::watch::Receiver<()>>>,
138 response_id: String,
139 /// Held only so its drop closes the watch channel.
140 _tx: tokio::sync::watch::Sender<()>,
141}
142
143impl Drop for InitGuard {
144 fn drop(&mut self) {
145 // Remove-then-close: a waiter that re-probes after waking must
146 // not find the stale marker.
147 self.pending.remove(&self.response_id);
148 }
149}
150
151/// Parse an `MCP_ENCRYPTION_KEY` env-var value: a single base64-encoded
152/// 32-byte key. Empty string → `None`. Malformed → `Err`.
153///
154/// Retained as a no-op compatibility shim: the proxy no longer encrypts
155/// session ids (they're plain UUIDs now), but the API crate still parses
156/// and forwards the configured key. Kept so that the env plumbing in the
157/// API/CLI compiles unchanged; the parsed value is ignored by the proxy.
158/// Removal is deferred to a later step of the session-id refactor.
159pub fn parse_key_env(s: &str) -> Result<Option<[u8; 32]>, String> {
160 use base64::Engine;
161 let trimmed = s.trim();
162 if trimmed.is_empty() {
163 return Ok(None);
164 }
165 let decoded = base64::engine::general_purpose::STANDARD
166 .decode(trimmed)
167 .map_err(|e| format!("MCP_ENCRYPTION_KEY: not valid base64: {e}"))?;
168 let key: [u8; 32] = decoded.try_into().map_err(|got: Vec<u8>| {
169 format!(
170 "MCP_ENCRYPTION_KEY: expected 32 bytes after base64-decode, got {}",
171 got.len(),
172 )
173 })?;
174 Ok(Some(key))
175}
176
177/// Normalize a server name or version into a routing-prefix-safe token:
178/// every `_` and `.` becomes `-`. The resulting prefix is free of both the
179/// split separator (`_`) and `.`, so the first `_` in a prefixed identifier
180/// is always the prefix/original-name boundary.
181fn normalize_prefix_token(s: &str) -> String {
182 s.replace(['_', '.'], "-")
183}
184
185/// Build the `prefix -> Connection` routing map.
186///
187/// Connections are sorted by `url` first so the prefixes and indices are
188/// stable across calls — a client that re-lists must see the exact tool
189/// names it already holds.
190///
191/// Each connection's prefix escalates only as far as needed for global
192/// uniqueness:
193/// 1. `normalize(server_info.name)`
194/// 2. on collision -> `{name}-{normalize(version)}` (all colliding members)
195/// 3. still colliding -> `{name}-{version}-{index}` (index = url-sorted
196/// position, globally unique, so this tier always resolves)
197/// Uniqueness is re-checked over the full set after each tier so a rare
198/// cross-tier collision escalates too.
199fn build_prefix_map(
200 mut connections: Vec<Upstream>,
201) -> IndexMap<String, Upstream> {
202 connections.sort_by(|a, b| a.url().cmp(b.url()));
203 let n = connections.len();
204
205 let names: Vec<String> = connections
206 .iter()
207 .map(|c| normalize_prefix_token(c.server_name()))
208 .collect();
209 let versions: Vec<String> = connections
210 .iter()
211 .map(|c| normalize_prefix_token(c.server_version()))
212 .collect();
213
214 // tier: 1 = name, 2 = name-version, 3 = name-version-index.
215 let prefix_at = |i: usize, tier: u8| -> String {
216 match tier {
217 1 => names[i].clone(),
218 2 => format!("{}-{}", names[i], versions[i]),
219 _ => format!("{}-{}-{}", names[i], versions[i], i),
220 }
221 };
222
223 let mut tier: Vec<u8> = vec![1; n];
224 loop {
225 let current: Vec<String> = (0..n).map(|i| prefix_at(i, tier[i])).collect();
226 let mut counts: std::collections::HashMap<&str, usize> =
227 std::collections::HashMap::new();
228 for p in ¤t {
229 *counts.entry(p.as_str()).or_insert(0) += 1;
230 }
231 let mut changed = false;
232 for i in 0..n {
233 if counts[current[i].as_str()] > 1 && tier[i] < 3 {
234 tier[i] += 1;
235 changed = true;
236 }
237 }
238 if !changed {
239 break;
240 }
241 }
242
243 let mut by_prefix: IndexMap<String, Upstream> = IndexMap::with_capacity(n);
244 for (i, connection) in connections.into_iter().enumerate() {
245 let key = prefix_at(i, tier[i]);
246 // The index tier guarantees uniqueness; a residual duplicate would
247 // be a logic bug rather than a real-world collision.
248 debug_assert!(
249 !by_prefix.contains_key(&key),
250 "duplicate routing prefix after escalation: {key}",
251 );
252 by_prefix.insert(key, connection);
253 }
254 by_prefix
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 #[tokio::test]
262 async fn get_or_wait_immediate_hit_and_miss() {
263 let manager = SessionManager::new();
264 manager.add("known".to_string(), Vec::new());
265 assert!(manager.get_or_wait("known").await.is_some());
266 // Neither registered nor initializing: misses without waiting.
267 assert!(manager.get_or_wait("unknown").await.is_none());
268 }
269
270 #[tokio::test]
271 async fn get_or_wait_parks_until_the_initializer_registers() {
272 let manager = Arc::new(SessionManager::new());
273 let guard = manager.begin_initializing("r1");
274
275 let waiter = {
276 let manager = Arc::clone(&manager);
277 tokio::spawn(async move { manager.get_or_wait("r1").await })
278 };
279 // Let the waiter park on the pending marker.
280 tokio::task::yield_now().await;
281 assert!(!waiter.is_finished());
282
283 manager.add("r1".to_string(), Vec::new());
284 drop(guard);
285 let session = waiter.await.expect("waiter task");
286 assert!(session.is_some());
287 }
288
289 #[tokio::test]
290 async fn get_or_wait_resolves_none_when_the_connect_fails() {
291 let manager = Arc::new(SessionManager::new());
292 let guard = manager.begin_initializing("r1");
293
294 let waiter = {
295 let manager = Arc::clone(&manager);
296 tokio::spawn(async move { manager.get_or_wait("r1").await })
297 };
298 tokio::task::yield_now().await;
299 assert!(!waiter.is_finished());
300
301 // The initializer bails without registering (connect failure /
302 // early return): the guard's Drop releases the waiters.
303 drop(guard);
304 assert!(waiter.await.expect("waiter task").is_none());
305 }
306
307 #[tokio::test]
308 async fn initializing_marker_lives_exactly_as_long_as_the_guard() {
309 let manager = SessionManager::new();
310 assert!(manager.initializing("r1").is_none());
311 let guard = manager.begin_initializing("r1");
312 assert!(manager.initializing("r1").is_some());
313 drop(guard);
314 assert!(manager.initializing("r1").is_none());
315 }
316
317 #[test]
318 fn parse_key_env_round_trip() {
319 use base64::Engine;
320 let key = [0xAAu8; 32];
321 let env = base64::engine::general_purpose::STANDARD.encode(key);
322 let parsed = parse_key_env(&env).expect("parse").expect("Some");
323 assert_eq!(parsed, key);
324
325 assert!(parse_key_env("").unwrap().is_none());
326 assert!(parse_key_env(" ").unwrap().is_none());
327 assert!(parse_key_env("not-base64!@#").is_err());
328 // Wrong-length payload (16 bytes after b64 decode):
329 let short =
330 base64::engine::general_purpose::STANDARD.encode(&[0u8; 16][..]);
331 assert!(parse_key_env(&short).is_err());
332 }
333}