/// Manages MCP sessions across multiple `(user, server)` pairs.
///
/// Server names are typed via [`McpServerName`] so a free-form string can't
/// bypass allowlist validation at the boundary. Callers convert raw strings
/// via `McpServerName::new` (validating) or `McpServerName::from_trusted`
/// (for names the caller already validated). This makes identity-confusion
/// bugs — matching the shape described in `.claude/rules/types.md` — a
/// compile error rather than a runtime surprise.
pub struct McpSessionManager {
/// Active sessions keyed by `(user_id, server_name)`.
sessions: RwLock<HashMap<McpSessionKey, McpSession>>,
/// Maximum idle time before a session is considered stale (in seconds).
max_idle_secs: u64,30min
}
/// Session state for a single `(user, server)` MCP connection.
#[derive(Debug, Clone)]
pub struct McpSession {
/// Session ID returned by the server (via Mcp-Session-Id header).
pub session_id: Option<String>,
/// Last activity timestamp for this session.
pub last_activity: Instant,
/// Server URL this session is connected to.
pub server_url: String,
/// Whether initialization has completed.
pub initialized: bool,
}
2. MCPProcessManager(管理本地server)
/// Manages stdio MCP server processes.
///
/// Handles spawning, tracking, and shutdown of child processes. Keyed
/// by `(user_id, server_name)` so that multiple tenants activating
/// the same server name end up with distinct, independently tracked
/// child processes — see `McpProcessKey` for the rationale.
pub struct McpProcessManager {
transports: RwLock<HashMap<McpProcessKey, Arc<StdioMcpTransport>>>,
configs: RwLock<HashMap<McpProcessKey, StdioSpawnConfig>>,
}
/// Composite key for a stdio MCP child process: the activating user
/// plus the server name. Both fields participate in `Hash` / `Eq` so
/// two users activating the same server name each get — and keep —
/// their own child process instead of one silently overwriting the
/// other's transport handle.
///
/// Stdio MCP servers receive credentials via their spawn `env` map, so
/// sharing a single child across users would leak one tenant's
/// credentials to the other's dispatches. Per-user children are
/// required; the process manager must track them independently.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct McpProcessKey {
pub user_id: String,
pub server_name: String,
}
1. "复合键"为啥是 (user, server_name) 而不是 server_name
直觉上 stdio MCP 是个全局的本地服务,似乎一个 server 跑一个进程就够了。但 stdio 模式下,每个用户激活时,凭据是通过 env 注入到子进程的环境变量里的(而不是像 HTTP 那样每次请求带 header)。
所以会出现这种场景:
┌───────┬─────────────────────────────────────────┐
│ 用户 │ 同一个 MCP server github-mcp │
├───────┼─────────────────────────────────────────┤
│ Alice │ spawn 时 env: { GH_TOKEN: alice_token } │
├───────┼─────────────────────────────────────────┤
│ Bob │ spawn 时 env: { GH_TOKEN: bob_token } │
└───────┴─────────────────────────────────────────┘
如果用 server_name 当 key,后到的 Bob 会覆盖 Alice 的子进程:
- Alice 的 child 被 kill
- Bob 的 child 占位
- 之后 Alice 的请求走到 Bob 的进程 → Alice 的请求用 Bob 的 token 调 GitHub
→ 凭据跨租户泄露,而且因为是静默覆盖,审计日志里也看不出来。
2. 为什么不通过请求时切换 env 复用同一个子进程
stdio 是单向字节流(stdin 写、stdout 读),不是按"租户"分流的。一个 child 进程内部,server 端的 process.env.GH_TOKEN 在启动那一刻就固定了——不是按请求切。
要支持多租户,只能多 child。
3. 这跟 HTTP / SSE 模式形成对比
┌───────────┬─────────────────────────────────┬─────────────────────────────────┐
│ Transport │ 凭据怎么传 │ 是否需要每用户一个 child │
├───────────┼─────────────────────────────────┼─────────────────────────────────┤
│ stdio │ spawn 时 env 注入,固定在进程里 │ ✅ 需要 │
├───────────┼─────────────────────────────────┼─────────────────────────────────┤
│ http │ 每次请求带 Authorization header │ ❌ 一个 server 进程服务所有用户 │
├───────────┼─────────────────────────────────┼─────────────────────────────────┤
│ sse │ 同 http │ ❌ │
└───────────┴─────────────────────────────────┴─────────────────────────────────┘
这是为啥前面讲 McpProcessManager 在 stdio 模式才用、http/sse 模式闲置——只有 stdio 有"凭据绑死进程"这个问题。
4. Hash / Eq 参与的意义
McpProcessManager 内部应该是 HashMap<ProcessKey, ChildHandle> 之类:
struct ProcessKey {
owner_id: String,
server_name: String,
}
只有两个字段都参与哈希,两个用户激活同名 server时才会被识别为不同的 key,各自拿到独立的 child,互不污染。
如果 Hash / Eq 只看 server_name(字段存在但没参与哈希),HashMap 行为就 undefined——这是 Rust 里经典的"字段没参与等价性"陷阱,注释把这点点出来是为了显式约束而不是默认行为。
5. 一句话
▎ stdio 模式下,凭据绑死子进程的 env 启动参数,所以必须按 (user, server_name) 复合键管理子进程——否则会跨用户泄露凭据,而且是静默的。HTTP/SSE 模式没这个问题,所以那个模式根本不需要这个 manager。