Skip to main content

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
15use std::sync::Arc;
16
17use dashmap::DashMap;
18use indexmap::IndexMap;
19
20use crate::reverse_channel::Upstream;
21use crate::session::Session;
22
23/// Maps an objectiveai response id to its [`Session`] state.
24#[derive(Debug, Default)]
25pub struct SessionManager {
26    sessions: DashMap<String, Arc<Session>>,
27}
28
29impl SessionManager {
30    pub fn new() -> Self {
31        Self::default()
32    }
33
34    /// Register the upstreams opened for one objectiveai response under
35    /// its response id. Builds the `prefix -> Upstream` routing map (see
36    /// [`build_prefix_map`]) and inserts a fresh [`Session`]. An existing
37    /// entry for the same response id is replaced.
38    pub fn add(&self, response_id: String, connections: Vec<Upstream>) {
39        let by_prefix = build_prefix_map(connections);
40        self.sessions
41            .insert(response_id, Arc::new(Session::new(by_prefix)));
42    }
43
44    /// Cheap clone-out of a [`Session`] by response id — never holds a
45    /// DashMap guard across the await boundary.
46    pub fn get(&self, response_id: &str) -> Option<Arc<Session>> {
47        self.sessions.get(response_id).map(|e| e.value().clone())
48    }
49
50    /// Remove a session from the registry by response id. Returns
51    /// `Some(_)` if a session was present, `None` if the id was unknown.
52    ///
53    /// Once every `Arc<Session>` to the removed session has dropped, the
54    /// session's `IndexMap<String, Connection>` drops, every `Connection`'s
55    /// `Drop` fires its upstream's wakeup signal, and each upstream's
56    /// listener task wakes to re-check liveness. The listener sees
57    /// `Arc::strong_count == 1` (only itself) and exits, which drops the
58    /// inner state and closes the upstream HTTP session.
59    pub fn remove(&self, response_id: &str) -> Option<Arc<Session>> {
60        self.sessions.remove(response_id).map(|(_, session)| session)
61    }
62}
63
64/// Parse an `MCP_ENCRYPTION_KEY` env-var value: a single base64-encoded
65/// 32-byte key. Empty string → `None`. Malformed → `Err`.
66///
67/// Retained as a no-op compatibility shim: the proxy no longer encrypts
68/// session ids (they're plain UUIDs now), but the API crate still parses
69/// and forwards the configured key. Kept so that the env plumbing in the
70/// API/CLI compiles unchanged; the parsed value is ignored by the proxy.
71/// Removal is deferred to a later step of the session-id refactor.
72pub fn parse_key_env(s: &str) -> Result<Option<[u8; 32]>, String> {
73    use base64::Engine;
74    let trimmed = s.trim();
75    if trimmed.is_empty() {
76        return Ok(None);
77    }
78    let decoded = base64::engine::general_purpose::STANDARD
79        .decode(trimmed)
80        .map_err(|e| format!("MCP_ENCRYPTION_KEY: not valid base64: {e}"))?;
81    let key: [u8; 32] = decoded.try_into().map_err(|got: Vec<u8>| {
82        format!(
83            "MCP_ENCRYPTION_KEY: expected 32 bytes after base64-decode, got {}",
84            got.len(),
85        )
86    })?;
87    Ok(Some(key))
88}
89
90/// Normalize a server name or version into a routing-prefix-safe token:
91/// every `_` and `.` becomes `-`. The resulting prefix is free of both the
92/// split separator (`_`) and `.`, so the first `_` in a prefixed identifier
93/// is always the prefix/original-name boundary.
94fn normalize_prefix_token(s: &str) -> String {
95    s.replace(['_', '.'], "-")
96}
97
98/// Build the `prefix -> Connection` routing map.
99///
100/// Connections are sorted by `url` first so the prefixes and indices are
101/// stable across calls — a client that re-lists must see the exact tool
102/// names it already holds.
103///
104/// Each connection's prefix escalates only as far as needed for global
105/// uniqueness:
106///   1. `normalize(server_info.name)`
107///   2. on collision -> `{name}-{normalize(version)}` (all colliding members)
108///   3. still colliding -> `{name}-{version}-{index}` (index = url-sorted
109///      position, globally unique, so this tier always resolves)
110/// Uniqueness is re-checked over the full set after each tier so a rare
111/// cross-tier collision escalates too.
112fn build_prefix_map(
113    mut connections: Vec<Upstream>,
114) -> IndexMap<String, Upstream> {
115    connections.sort_by(|a, b| a.url().cmp(b.url()));
116    let n = connections.len();
117
118    let names: Vec<String> = connections
119        .iter()
120        .map(|c| normalize_prefix_token(c.server_name()))
121        .collect();
122    let versions: Vec<String> = connections
123        .iter()
124        .map(|c| normalize_prefix_token(c.server_version()))
125        .collect();
126
127    // tier: 1 = name, 2 = name-version, 3 = name-version-index.
128    let prefix_at = |i: usize, tier: u8| -> String {
129        match tier {
130            1 => names[i].clone(),
131            2 => format!("{}-{}", names[i], versions[i]),
132            _ => format!("{}-{}-{}", names[i], versions[i], i),
133        }
134    };
135
136    let mut tier: Vec<u8> = vec![1; n];
137    loop {
138        let current: Vec<String> = (0..n).map(|i| prefix_at(i, tier[i])).collect();
139        let mut counts: std::collections::HashMap<&str, usize> =
140            std::collections::HashMap::new();
141        for p in &current {
142            *counts.entry(p.as_str()).or_insert(0) += 1;
143        }
144        let mut changed = false;
145        for i in 0..n {
146            if counts[current[i].as_str()] > 1 && tier[i] < 3 {
147                tier[i] += 1;
148                changed = true;
149            }
150        }
151        if !changed {
152            break;
153        }
154    }
155
156    let mut by_prefix: IndexMap<String, Upstream> = IndexMap::with_capacity(n);
157    for (i, connection) in connections.into_iter().enumerate() {
158        let key = prefix_at(i, tier[i]);
159        // The index tier guarantees uniqueness; a residual duplicate would
160        // be a logic bug rather than a real-world collision.
161        debug_assert!(
162            !by_prefix.contains_key(&key),
163            "duplicate routing prefix after escalation: {key}",
164        );
165        by_prefix.insert(key, connection);
166    }
167    by_prefix
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn parse_key_env_round_trip() {
176        use base64::Engine;
177        let key = [0xAAu8; 32];
178        let env = base64::engine::general_purpose::STANDARD.encode(key);
179        let parsed = parse_key_env(&env).expect("parse").expect("Some");
180        assert_eq!(parsed, key);
181
182        assert!(parse_key_env("").unwrap().is_none());
183        assert!(parse_key_env("   ").unwrap().is_none());
184        assert!(parse_key_env("not-base64!@#").is_err());
185        // Wrong-length payload (16 bytes after b64 decode):
186        let short =
187            base64::engine::general_purpose::STANDARD.encode(&[0u8; 16][..]);
188        assert!(parse_key_env(&short).is_err());
189    }
190}