Skip to main content

brainos_mcphost/
lib.rs

1//! # Brain MCP Host
2//!
3//! Host-side integration for **external** Model Context Protocol servers.
4//! The sibling `brainos-mcp` crate is a *server* (Brain exposes its own
5//! tools); this crate is the *host* side — Brain mounts and routes
6//! through other people's tool servers.
7//!
8//! Supported transports (per MCP spec 2025-11-25):
9//! - **stdio** — child process speaking JSON-RPC on stdin/stdout
10//! - **Streamable HTTP** — current spec transport
11//! - **HTTP+SSE** — legacy transport, still spec-required for compatibility
12//!
13//! This crate currently provides the trait surfaces ([`MCPHost`],
14//! [`MCPClient`]), the [`ServerConfig`] / [`OAuthConfig`] / [`ToolDescriptor`]
15//! / [`CallOutcome`] types, the [`McpHostError`] taxonomy, and an
16//! [`InMemoryMcpHost`] no-transport stub so downstream wiring can be built
17//! against the trait before transports are implemented.
18
19use std::{
20    collections::{BTreeMap, HashMap},
21    path::PathBuf,
22    sync::Arc,
23};
24
25use async_trait::async_trait;
26use chrono::Utc;
27use tokio::sync::RwLock;
28
29pub mod aud_check;
30pub mod capability_index;
31pub mod error;
32pub mod oauth;
33pub mod resilient;
34pub mod rmcp_host;
35pub mod types;
36
37pub use aud_check::{validate_token_aud, AudCheckOutcome};
38pub use capability_index::{InMemoryToolCapabilityIndex, ToolCapabilityIndex};
39pub use error::McpHostError;
40pub use oauth::{manager_from_vault, VaultCredentialStore};
41pub use resilient::{ResilienceConfig, ResilientMcpHost};
42pub use rmcp_host::RmcpHost;
43pub use types::{
44    CallOutcome, MountedServer, OAuthConfig, ServerConfig, ServerInfo, ServerStatus, ToolDescriptor,
45};
46
47/// MCP protocol version Brain negotiates against. Per spec 2025-11-25.
48pub const MCP_PROTOCOL_VERSION: &str = "2025-11-25";
49
50/// The host: manages the lifecycle of mounted servers and routes tool calls.
51#[async_trait]
52pub trait MCPHost: Send + Sync {
53    /// Mount a new server under `name`. Idempotent: a name collision returns
54    /// [`McpHostError::AlreadyMounted`].
55    async fn mount(&self, name: String, cfg: ServerConfig) -> Result<(), McpHostError>;
56
57    /// Gracefully unmount a server (stdin EOF → SIGTERM ladder for stdio,
58    /// DELETE `Mcp-Session-Id` for HTTP transports).
59    async fn unmount(&self, name: &str) -> Result<(), McpHostError>;
60
61    /// Snapshot of currently-mounted servers.
62    async fn list_servers(&self) -> Vec<ServerStatus>;
63
64    /// Flattened tool catalog across all mounts. Raw enumeration only — a
65    /// scored capability index is the responsibility of the intent router.
66    async fn list_all_tools(&self) -> Vec<ToolDescriptor>;
67
68    /// Invoke `tool` on `server` with `args`. Returns a structured outcome
69    /// the caller (typically `SignalProcessor`) renders into an audit event.
70    async fn call(
71        &self,
72        server: &str,
73        tool: &str,
74        args: serde_json::Value,
75    ) -> Result<CallOutcome, McpHostError>;
76}
77
78/// A single transport-bound MCP client (one per mounted server).
79#[async_trait]
80pub trait MCPClient: Send + Sync {
81    async fn initialize(&self) -> Result<ServerInfo, McpHostError>;
82    async fn list_tools(&self) -> Result<Vec<ToolDescriptor>, McpHostError>;
83    async fn call_tool(
84        &self,
85        name: &str,
86        args: serde_json::Value,
87    ) -> Result<CallOutcome, McpHostError>;
88    async fn shutdown(&self) -> Result<(), McpHostError>;
89    fn server_info(&self) -> Option<ServerInfo>;
90}
91
92/// In-memory `MCPHost` with no transport — records mounts so downstream
93/// wiring (Signal, Thalamus intents, tests) can be built against the trait
94/// before the real stdio / HTTP clients are wired in.
95#[derive(Default)]
96pub struct InMemoryMcpHost {
97    mounted: RwLock<HashMap<String, MountedServer>>,
98}
99
100impl InMemoryMcpHost {
101    pub fn new() -> Self {
102        Self {
103            mounted: RwLock::new(HashMap::new()),
104        }
105    }
106
107    pub fn shared() -> Arc<dyn MCPHost> {
108        Arc::new(Self::new())
109    }
110}
111
112#[async_trait]
113impl MCPHost for InMemoryMcpHost {
114    async fn mount(&self, name: String, cfg: ServerConfig) -> Result<(), McpHostError> {
115        let mut guard = self.mounted.write().await;
116        if guard.contains_key(&name) {
117            return Err(McpHostError::AlreadyMounted(name));
118        }
119        guard.insert(
120            name.clone(),
121            MountedServer {
122                name,
123                config: cfg,
124                mounted_at: Utc::now(),
125                info: None,
126                tools: Vec::new(),
127            },
128        );
129        Ok(())
130    }
131
132    async fn unmount(&self, name: &str) -> Result<(), McpHostError> {
133        self.mounted
134            .write()
135            .await
136            .remove(name)
137            .map(|_| ())
138            .ok_or_else(|| McpHostError::NotMounted(name.to_string()))
139    }
140
141    async fn list_servers(&self) -> Vec<ServerStatus> {
142        self.mounted
143            .read()
144            .await
145            .values()
146            .map(|m| ServerStatus {
147                name: m.name.clone(),
148                mounted_at: m.mounted_at,
149                tool_count: m.tools.len(),
150                info: m.info.clone(),
151            })
152            .collect()
153    }
154
155    async fn list_all_tools(&self) -> Vec<ToolDescriptor> {
156        self.mounted
157            .read()
158            .await
159            .values()
160            .flat_map(|m| m.tools.clone())
161            .collect()
162    }
163
164    async fn call(
165        &self,
166        server: &str,
167        _tool: &str,
168        _args: serde_json::Value,
169    ) -> Result<CallOutcome, McpHostError> {
170        let guard = self.mounted.read().await;
171        if !guard.contains_key(server) {
172            return Err(McpHostError::NotMounted(server.to_string()));
173        }
174        // The in-memory host has no real transport — `call` is a stub so
175        // callers can detect the no-transport state and downstream wiring
176        // can be built against the trait surface.
177        Err(McpHostError::Transport(
178            "no transport configured for in-memory host".to_string(),
179        ))
180    }
181}
182
183/// Helper used by [`ServerConfig::Stdio`] to keep env maps deterministic.
184pub fn empty_env() -> BTreeMap<String, String> {
185    BTreeMap::new()
186}
187
188/// Helper for callers building stdio configs from path+args.
189pub fn stdio_cfg(command: impl Into<String>, args: Vec<String>) -> ServerConfig {
190    ServerConfig::Stdio {
191        command: command.into(),
192        args,
193        env: empty_env(),
194        cwd: None::<PathBuf>,
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[tokio::test]
203    async fn mount_and_list() {
204        let host = InMemoryMcpHost::new();
205        host.mount("fs".into(), stdio_cfg("mcp-fs", vec![]))
206            .await
207            .unwrap();
208        let servers = host.list_servers().await;
209        assert_eq!(servers.len(), 1);
210        assert_eq!(servers[0].name, "fs");
211        assert_eq!(servers[0].tool_count, 0);
212    }
213
214    #[tokio::test]
215    async fn double_mount_rejected() {
216        let host = InMemoryMcpHost::new();
217        host.mount("fs".into(), stdio_cfg("mcp-fs", vec![]))
218            .await
219            .unwrap();
220        let err = host
221            .mount("fs".into(), stdio_cfg("mcp-fs", vec![]))
222            .await
223            .unwrap_err();
224        assert!(matches!(err, McpHostError::AlreadyMounted(_)));
225    }
226
227    #[tokio::test]
228    async fn unmount_missing_errors() {
229        let host = InMemoryMcpHost::new();
230        let err = host.unmount("nope").await.unwrap_err();
231        assert!(matches!(err, McpHostError::NotMounted(_)));
232    }
233
234    #[tokio::test]
235    async fn call_without_transport_errors() {
236        let host = InMemoryMcpHost::new();
237        host.mount("fs".into(), stdio_cfg("mcp-fs", vec![]))
238            .await
239            .unwrap();
240        let err = host
241            .call("fs", "read_text_file", serde_json::json!({}))
242            .await
243            .unwrap_err();
244        assert!(matches!(err, McpHostError::Transport(_)));
245    }
246
247    #[test]
248    fn protocol_version_matches_spec() {
249        assert_eq!(MCP_PROTOCOL_VERSION, "2025-11-25");
250    }
251}