| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912 |
- //! Bridge between MCP tool surface (ListMcpResources, ReadMcpResource, McpAuth, MCP)
- //! and the existing McpServerManager runtime.
- //!
- //! Provides a stateful client registry that tool handlers can use to
- //! connect to MCP servers and invoke their capabilities.
- use std::collections::HashMap;
- use std::sync::{Arc, Mutex, OnceLock};
- use crate::mcp::mcp_tool_name;
- use crate::mcp_stdio::McpServerManager;
- use serde::{Deserialize, Serialize};
- /// Status of a managed MCP server connection.
- #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
- #[serde(rename_all = "snake_case")]
- pub enum McpConnectionStatus {
- Disconnected,
- Connecting,
- Connected,
- AuthRequired,
- Error,
- }
- impl std::fmt::Display for McpConnectionStatus {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- match self {
- Self::Disconnected => write!(f, "disconnected"),
- Self::Connecting => write!(f, "connecting"),
- Self::Connected => write!(f, "connected"),
- Self::AuthRequired => write!(f, "auth_required"),
- Self::Error => write!(f, "error"),
- }
- }
- }
- /// Metadata about an MCP resource.
- #[derive(Debug, Clone, Serialize, Deserialize)]
- pub struct McpResourceInfo {
- pub uri: String,
- pub name: String,
- pub description: Option<String>,
- pub mime_type: Option<String>,
- }
- /// Metadata about an MCP tool exposed by a server.
- #[derive(Debug, Clone, Serialize, Deserialize)]
- pub struct McpToolInfo {
- pub name: String,
- pub description: Option<String>,
- pub input_schema: Option<serde_json::Value>,
- }
- /// Tracked state of an MCP server connection.
- #[derive(Debug, Clone, Serialize, Deserialize)]
- pub struct McpServerState {
- pub server_name: String,
- pub status: McpConnectionStatus,
- pub tools: Vec<McpToolInfo>,
- pub resources: Vec<McpResourceInfo>,
- pub server_info: Option<String>,
- pub error_message: Option<String>,
- }
- #[derive(Debug, Clone, Default)]
- pub struct McpToolRegistry {
- inner: Arc<Mutex<HashMap<String, McpServerState>>>,
- manager: Arc<OnceLock<Arc<Mutex<McpServerManager>>>>,
- }
- impl McpToolRegistry {
- #[must_use]
- pub fn new() -> Self {
- Self::default()
- }
- pub fn set_manager(
- &self,
- manager: Arc<Mutex<McpServerManager>>,
- ) -> Result<(), Arc<Mutex<McpServerManager>>> {
- self.manager.set(manager)
- }
- pub fn register_server(
- &self,
- server_name: &str,
- status: McpConnectionStatus,
- tools: Vec<McpToolInfo>,
- resources: Vec<McpResourceInfo>,
- server_info: Option<String>,
- ) {
- let mut inner = self.inner.lock().expect("mcp registry lock poisoned");
- inner.insert(
- server_name.to_owned(),
- McpServerState {
- server_name: server_name.to_owned(),
- status,
- tools,
- resources,
- server_info,
- error_message: None,
- },
- );
- }
- pub fn get_server(&self, server_name: &str) -> Option<McpServerState> {
- let inner = self.inner.lock().expect("mcp registry lock poisoned");
- inner.get(server_name).cloned()
- }
- pub fn list_servers(&self) -> Vec<McpServerState> {
- let inner = self.inner.lock().expect("mcp registry lock poisoned");
- inner.values().cloned().collect()
- }
- pub fn list_resources(&self, server_name: &str) -> Result<Vec<McpResourceInfo>, String> {
- let inner = self.inner.lock().expect("mcp registry lock poisoned");
- match inner.get(server_name) {
- Some(state) => {
- if state.status != McpConnectionStatus::Connected {
- return Err(format!(
- "server '{}' is not connected (status: {})",
- server_name, state.status
- ));
- }
- Ok(state.resources.clone())
- }
- None => Err(format!("server '{}' not found", server_name)),
- }
- }
- pub fn read_resource(&self, server_name: &str, uri: &str) -> Result<McpResourceInfo, String> {
- let inner = self.inner.lock().expect("mcp registry lock poisoned");
- let state = inner
- .get(server_name)
- .ok_or_else(|| format!("server '{}' not found", server_name))?;
- if state.status != McpConnectionStatus::Connected {
- return Err(format!(
- "server '{}' is not connected (status: {})",
- server_name, state.status
- ));
- }
- state
- .resources
- .iter()
- .find(|r| r.uri == uri)
- .cloned()
- .ok_or_else(|| format!("resource '{}' not found on server '{}'", uri, server_name))
- }
- pub fn list_tools(&self, server_name: &str) -> Result<Vec<McpToolInfo>, String> {
- let inner = self.inner.lock().expect("mcp registry lock poisoned");
- match inner.get(server_name) {
- Some(state) => {
- if state.status != McpConnectionStatus::Connected {
- return Err(format!(
- "server '{}' is not connected (status: {})",
- server_name, state.status
- ));
- }
- Ok(state.tools.clone())
- }
- None => Err(format!("server '{}' not found", server_name)),
- }
- }
- fn spawn_tool_call(
- manager: Arc<Mutex<McpServerManager>>,
- qualified_tool_name: String,
- arguments: Option<serde_json::Value>,
- ) -> Result<serde_json::Value, String> {
- let join_handle = std::thread::Builder::new()
- .name(format!("mcp-tool-call-{qualified_tool_name}"))
- .spawn(move || {
- let runtime = tokio::runtime::Builder::new_current_thread()
- .enable_all()
- .build()
- .map_err(|error| format!("failed to create MCP tool runtime: {error}"))?;
- runtime.block_on(async move {
- let response = {
- let mut manager = manager
- .lock()
- .map_err(|_| "mcp server manager lock poisoned".to_string())?;
- manager
- .discover_tools()
- .await
- .map_err(|error| error.to_string())?;
- let response = manager
- .call_tool(&qualified_tool_name, arguments)
- .await
- .map_err(|error| error.to_string());
- let shutdown = manager.shutdown().await.map_err(|error| error.to_string());
- match (response, shutdown) {
- (Ok(response), Ok(())) => Ok(response),
- (Err(error), Ok(())) | (Err(error), Err(_)) => Err(error),
- (Ok(_), Err(error)) => Err(error),
- }
- }?;
- if let Some(error) = response.error {
- return Err(format!(
- "MCP server returned JSON-RPC error for tools/call: {} ({})",
- error.message, error.code
- ));
- }
- let result = response.result.ok_or_else(|| {
- "MCP server returned no result for tools/call".to_string()
- })?;
- serde_json::to_value(result)
- .map_err(|error| format!("failed to serialize MCP tool result: {error}"))
- })
- })
- .map_err(|error| format!("failed to spawn MCP tool call thread: {error}"))?;
- join_handle.join().map_err(|panic_payload| {
- if let Some(message) = panic_payload.downcast_ref::<&str>() {
- format!("MCP tool call thread panicked: {message}")
- } else if let Some(message) = panic_payload.downcast_ref::<String>() {
- format!("MCP tool call thread panicked: {message}")
- } else {
- "MCP tool call thread panicked".to_string()
- }
- })?
- }
- pub fn call_tool(
- &self,
- server_name: &str,
- tool_name: &str,
- arguments: &serde_json::Value,
- ) -> Result<serde_json::Value, String> {
- let inner = self.inner.lock().expect("mcp registry lock poisoned");
- let state = inner
- .get(server_name)
- .ok_or_else(|| format!("server '{}' not found", server_name))?;
- if state.status != McpConnectionStatus::Connected {
- return Err(format!(
- "server '{}' is not connected (status: {})",
- server_name, state.status
- ));
- }
- if !state.tools.iter().any(|t| t.name == tool_name) {
- return Err(format!(
- "tool '{}' not found on server '{}'",
- tool_name, server_name
- ));
- }
- drop(inner);
- let manager = self
- .manager
- .get()
- .cloned()
- .ok_or_else(|| "MCP server manager is not configured".to_string())?;
- Self::spawn_tool_call(
- manager,
- mcp_tool_name(server_name, tool_name),
- (!arguments.is_null()).then(|| arguments.clone()),
- )
- }
- /// Set auth status for a server.
- pub fn set_auth_status(
- &self,
- server_name: &str,
- status: McpConnectionStatus,
- ) -> Result<(), String> {
- let mut inner = self.inner.lock().expect("mcp registry lock poisoned");
- let state = inner
- .get_mut(server_name)
- .ok_or_else(|| format!("server '{}' not found", server_name))?;
- state.status = status;
- Ok(())
- }
- /// Disconnect / remove a server.
- pub fn disconnect(&self, server_name: &str) -> Option<McpServerState> {
- let mut inner = self.inner.lock().expect("mcp registry lock poisoned");
- inner.remove(server_name)
- }
- /// Number of registered servers.
- #[must_use]
- pub fn len(&self) -> usize {
- let inner = self.inner.lock().expect("mcp registry lock poisoned");
- inner.len()
- }
- #[must_use]
- pub fn is_empty(&self) -> bool {
- self.len() == 0
- }
- }
- #[cfg(test)]
- mod tests {
- use std::collections::BTreeMap;
- use std::fs;
- use std::os::unix::fs::PermissionsExt;
- use std::path::{Path, PathBuf};
- use std::sync::atomic::{AtomicU64, Ordering};
- use std::time::{SystemTime, UNIX_EPOCH};
- use super::*;
- use crate::config::{
- ConfigSource, McpServerConfig, McpStdioServerConfig, ScopedMcpServerConfig,
- };
- fn temp_dir() -> PathBuf {
- static NEXT_TEMP_DIR_ID: AtomicU64 = AtomicU64::new(0);
- let nanos = SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .expect("time should be after epoch")
- .as_nanos();
- let unique_id = NEXT_TEMP_DIR_ID.fetch_add(1, Ordering::Relaxed);
- std::env::temp_dir().join(format!("runtime-mcp-tool-bridge-{nanos}-{unique_id}"))
- }
- fn cleanup_script(script_path: &Path) {
- if let Some(root) = script_path.parent() {
- let _ = fs::remove_dir_all(root);
- }
- }
- fn write_bridge_mcp_server_script() -> PathBuf {
- let root = temp_dir();
- fs::create_dir_all(&root).expect("temp dir");
- let script_path = root.join("bridge-mcp-server.py");
- let script = [
- "#!/usr/bin/env python3",
- "import json, os, sys",
- "LABEL = os.environ.get('MCP_SERVER_LABEL', 'server')",
- "LOG_PATH = os.environ.get('MCP_LOG_PATH')",
- "",
- "def log(method):",
- " if LOG_PATH:",
- " with open(LOG_PATH, 'a', encoding='utf-8') as handle:",
- " handle.write(f'{method}\\n')",
- "",
- "def read_message():",
- " header = b''",
- r" while not header.endswith(b'\r\n\r\n'):",
- " chunk = sys.stdin.buffer.read(1)",
- " if not chunk:",
- " return None",
- " header += chunk",
- " length = 0",
- r" for line in header.decode().split('\r\n'):",
- r" if line.lower().startswith('content-length:'):",
- r" length = int(line.split(':', 1)[1].strip())",
- " payload = sys.stdin.buffer.read(length)",
- " return json.loads(payload.decode())",
- "",
- "def send_message(message):",
- " payload = json.dumps(message).encode()",
- r" sys.stdout.buffer.write(f'Content-Length: {len(payload)}\r\n\r\n'.encode() + payload)",
- " sys.stdout.buffer.flush()",
- "",
- "while True:",
- " request = read_message()",
- " if request is None:",
- " break",
- " method = request['method']",
- " log(method)",
- " if method == 'initialize':",
- " send_message({",
- " 'jsonrpc': '2.0',",
- " 'id': request['id'],",
- " 'result': {",
- " 'protocolVersion': request['params']['protocolVersion'],",
- " 'capabilities': {'tools': {}},",
- " 'serverInfo': {'name': LABEL, 'version': '1.0.0'}",
- " }",
- " })",
- " elif method == 'tools/list':",
- " send_message({",
- " 'jsonrpc': '2.0',",
- " 'id': request['id'],",
- " 'result': {",
- " 'tools': [",
- " {",
- " 'name': 'echo',",
- " 'description': f'Echo tool for {LABEL}',",
- " 'inputSchema': {",
- " 'type': 'object',",
- " 'properties': {'text': {'type': 'string'}},",
- " 'required': ['text']",
- " }",
- " }",
- " ]",
- " }",
- " })",
- " elif method == 'tools/call':",
- " args = request['params'].get('arguments') or {}",
- " text = args.get('text', '')",
- " send_message({",
- " 'jsonrpc': '2.0',",
- " 'id': request['id'],",
- " 'result': {",
- " 'content': [{'type': 'text', 'text': f'{LABEL}:{text}'}],",
- " 'structuredContent': {'server': LABEL, 'echoed': text},",
- " 'isError': False",
- " }",
- " })",
- " else:",
- " send_message({",
- " 'jsonrpc': '2.0',",
- " 'id': request['id'],",
- " 'error': {'code': -32601, 'message': f'unknown method: {method}'},",
- " })",
- "",
- ]
- .join("\n");
- fs::write(&script_path, script).expect("write script");
- let mut permissions = fs::metadata(&script_path).expect("metadata").permissions();
- permissions.set_mode(0o755);
- fs::set_permissions(&script_path, permissions).expect("chmod");
- script_path
- }
- fn manager_server_config(
- script_path: &Path,
- server_name: &str,
- log_path: &Path,
- ) -> ScopedMcpServerConfig {
- ScopedMcpServerConfig {
- scope: ConfigSource::Local,
- config: McpServerConfig::Stdio(McpStdioServerConfig {
- command: "python3".to_string(),
- args: vec![script_path.to_string_lossy().into_owned()],
- env: BTreeMap::from([
- ("MCP_SERVER_LABEL".to_string(), server_name.to_string()),
- (
- "MCP_LOG_PATH".to_string(),
- log_path.to_string_lossy().into_owned(),
- ),
- ]),
- tool_call_timeout_ms: Some(1_000),
- }),
- }
- }
- #[test]
- fn registers_and_retrieves_server() {
- let registry = McpToolRegistry::new();
- registry.register_server(
- "test-server",
- McpConnectionStatus::Connected,
- vec![McpToolInfo {
- name: "greet".into(),
- description: Some("Greet someone".into()),
- input_schema: None,
- }],
- vec![McpResourceInfo {
- uri: "res://data".into(),
- name: "Data".into(),
- description: None,
- mime_type: Some("application/json".into()),
- }],
- Some("TestServer v1.0".into()),
- );
- let server = registry.get_server("test-server").expect("should exist");
- assert_eq!(server.status, McpConnectionStatus::Connected);
- assert_eq!(server.tools.len(), 1);
- assert_eq!(server.resources.len(), 1);
- }
- #[test]
- fn lists_resources_from_connected_server() {
- let registry = McpToolRegistry::new();
- registry.register_server(
- "srv",
- McpConnectionStatus::Connected,
- vec![],
- vec![McpResourceInfo {
- uri: "res://alpha".into(),
- name: "Alpha".into(),
- description: None,
- mime_type: None,
- }],
- None,
- );
- let resources = registry.list_resources("srv").expect("should succeed");
- assert_eq!(resources.len(), 1);
- assert_eq!(resources[0].uri, "res://alpha");
- }
- #[test]
- fn rejects_resource_listing_for_disconnected_server() {
- let registry = McpToolRegistry::new();
- registry.register_server(
- "srv",
- McpConnectionStatus::Disconnected,
- vec![],
- vec![],
- None,
- );
- assert!(registry.list_resources("srv").is_err());
- }
- #[test]
- fn reads_specific_resource() {
- let registry = McpToolRegistry::new();
- registry.register_server(
- "srv",
- McpConnectionStatus::Connected,
- vec![],
- vec![McpResourceInfo {
- uri: "res://data".into(),
- name: "Data".into(),
- description: Some("Test data".into()),
- mime_type: Some("text/plain".into()),
- }],
- None,
- );
- let resource = registry
- .read_resource("srv", "res://data")
- .expect("should find");
- assert_eq!(resource.name, "Data");
- assert!(registry.read_resource("srv", "res://missing").is_err());
- }
- #[test]
- fn given_connected_server_without_manager_when_calling_tool_then_it_errors() {
- let registry = McpToolRegistry::new();
- registry.register_server(
- "srv",
- McpConnectionStatus::Connected,
- vec![McpToolInfo {
- name: "greet".into(),
- description: None,
- input_schema: None,
- }],
- vec![],
- None,
- );
- let error = registry
- .call_tool("srv", "greet", &serde_json::json!({"name": "world"}))
- .expect_err("should require a configured manager");
- assert!(error.contains("MCP server manager is not configured"));
- // Unknown tool should fail
- assert!(registry
- .call_tool("srv", "missing", &serde_json::json!({}))
- .is_err());
- }
- #[test]
- fn given_connected_server_with_manager_when_calling_tool_then_it_returns_live_result() {
- let script_path = write_bridge_mcp_server_script();
- let root = script_path.parent().expect("script parent");
- let log_path = root.join("bridge.log");
- let servers = BTreeMap::from([(
- "alpha".to_string(),
- manager_server_config(&script_path, "alpha", &log_path),
- )]);
- let manager = Arc::new(Mutex::new(McpServerManager::from_servers(&servers)));
- let registry = McpToolRegistry::new();
- registry.register_server(
- "alpha",
- McpConnectionStatus::Connected,
- vec![McpToolInfo {
- name: "echo".into(),
- description: Some("Echo tool for alpha".into()),
- input_schema: Some(serde_json::json!({
- "type": "object",
- "properties": {"text": {"type": "string"}},
- "required": ["text"]
- })),
- }],
- vec![],
- Some("bridge test server".into()),
- );
- registry
- .set_manager(Arc::clone(&manager))
- .expect("manager should only be set once");
- let result = registry
- .call_tool("alpha", "echo", &serde_json::json!({"text": "hello"}))
- .expect("should return live MCP result");
- assert_eq!(
- result["structuredContent"]["server"],
- serde_json::json!("alpha")
- );
- assert_eq!(
- result["structuredContent"]["echoed"],
- serde_json::json!("hello")
- );
- assert_eq!(
- result["content"][0]["text"],
- serde_json::json!("alpha:hello")
- );
- let log = fs::read_to_string(&log_path).expect("read log");
- assert_eq!(
- log.lines().collect::<Vec<_>>(),
- vec!["initialize", "tools/list", "tools/call"]
- );
- cleanup_script(&script_path);
- }
- #[test]
- fn rejects_tool_call_on_disconnected_server() {
- let registry = McpToolRegistry::new();
- registry.register_server(
- "srv",
- McpConnectionStatus::AuthRequired,
- vec![McpToolInfo {
- name: "greet".into(),
- description: None,
- input_schema: None,
- }],
- vec![],
- None,
- );
- assert!(registry
- .call_tool("srv", "greet", &serde_json::json!({}))
- .is_err());
- }
- #[test]
- fn sets_auth_and_disconnects() {
- let registry = McpToolRegistry::new();
- registry.register_server(
- "srv",
- McpConnectionStatus::AuthRequired,
- vec![],
- vec![],
- None,
- );
- registry
- .set_auth_status("srv", McpConnectionStatus::Connected)
- .expect("should succeed");
- let state = registry.get_server("srv").unwrap();
- assert_eq!(state.status, McpConnectionStatus::Connected);
- let removed = registry.disconnect("srv");
- assert!(removed.is_some());
- assert!(registry.is_empty());
- }
- #[test]
- fn rejects_operations_on_missing_server() {
- let registry = McpToolRegistry::new();
- assert!(registry.list_resources("missing").is_err());
- assert!(registry.read_resource("missing", "uri").is_err());
- assert!(registry.list_tools("missing").is_err());
- assert!(registry
- .call_tool("missing", "tool", &serde_json::json!({}))
- .is_err());
- assert!(registry
- .set_auth_status("missing", McpConnectionStatus::Connected)
- .is_err());
- }
- #[test]
- fn mcp_connection_status_display_all_variants() {
- // given
- let cases = [
- (McpConnectionStatus::Disconnected, "disconnected"),
- (McpConnectionStatus::Connecting, "connecting"),
- (McpConnectionStatus::Connected, "connected"),
- (McpConnectionStatus::AuthRequired, "auth_required"),
- (McpConnectionStatus::Error, "error"),
- ];
- // when
- let rendered: Vec<_> = cases
- .into_iter()
- .map(|(status, expected)| (status.to_string(), expected))
- .collect();
- // then
- assert_eq!(
- rendered,
- vec![
- ("disconnected".to_string(), "disconnected"),
- ("connecting".to_string(), "connecting"),
- ("connected".to_string(), "connected"),
- ("auth_required".to_string(), "auth_required"),
- ("error".to_string(), "error"),
- ]
- );
- }
- #[test]
- fn list_servers_returns_all_registered() {
- // given
- let registry = McpToolRegistry::new();
- registry.register_server(
- "alpha",
- McpConnectionStatus::Connected,
- vec![],
- vec![],
- None,
- );
- registry.register_server(
- "beta",
- McpConnectionStatus::Connecting,
- vec![],
- vec![],
- None,
- );
- // when
- let servers = registry.list_servers();
- // then
- assert_eq!(servers.len(), 2);
- assert!(servers.iter().any(|server| server.server_name == "alpha"));
- assert!(servers.iter().any(|server| server.server_name == "beta"));
- }
- #[test]
- fn list_tools_from_connected_server() {
- // given
- let registry = McpToolRegistry::new();
- registry.register_server(
- "srv",
- McpConnectionStatus::Connected,
- vec![McpToolInfo {
- name: "inspect".into(),
- description: Some("Inspect data".into()),
- input_schema: Some(serde_json::json!({"type": "object"})),
- }],
- vec![],
- None,
- );
- // when
- let tools = registry.list_tools("srv").expect("tools should list");
- // then
- assert_eq!(tools.len(), 1);
- assert_eq!(tools[0].name, "inspect");
- }
- #[test]
- fn list_tools_rejects_disconnected_server() {
- // given
- let registry = McpToolRegistry::new();
- registry.register_server(
- "srv",
- McpConnectionStatus::AuthRequired,
- vec![],
- vec![],
- None,
- );
- // when
- let result = registry.list_tools("srv");
- // then
- let error = result.expect_err("non-connected server should fail");
- assert!(error.contains("not connected"));
- assert!(error.contains("auth_required"));
- }
- #[test]
- fn list_tools_rejects_missing_server() {
- // given
- let registry = McpToolRegistry::new();
- // when
- let result = registry.list_tools("missing");
- // then
- assert_eq!(
- result.expect_err("missing server should fail"),
- "server 'missing' not found"
- );
- }
- #[test]
- fn get_server_returns_none_for_missing() {
- // given
- let registry = McpToolRegistry::new();
- // when
- let server = registry.get_server("missing");
- // then
- assert!(server.is_none());
- }
- #[test]
- fn call_tool_payload_structure() {
- let script_path = write_bridge_mcp_server_script();
- let root = script_path.parent().expect("script parent");
- let log_path = root.join("payload.log");
- let servers = BTreeMap::from([(
- "srv".to_string(),
- manager_server_config(&script_path, "srv", &log_path),
- )]);
- let registry = McpToolRegistry::new();
- let arguments = serde_json::json!({"text": "world"});
- registry.register_server(
- "srv",
- McpConnectionStatus::Connected,
- vec![McpToolInfo {
- name: "echo".into(),
- description: Some("Echo tool for srv".into()),
- input_schema: Some(serde_json::json!({
- "type": "object",
- "properties": {"text": {"type": "string"}},
- "required": ["text"]
- })),
- }],
- vec![],
- None,
- );
- registry
- .set_manager(Arc::new(Mutex::new(McpServerManager::from_servers(
- &servers,
- ))))
- .expect("manager should only be set once");
- let result = registry
- .call_tool("srv", "echo", &arguments)
- .expect("tool should return live payload");
- assert_eq!(result["structuredContent"]["server"], "srv");
- assert_eq!(result["structuredContent"]["echoed"], "world");
- assert_eq!(result["content"][0]["text"], "srv:world");
- cleanup_script(&script_path);
- }
- #[test]
- fn upsert_overwrites_existing_server() {
- // given
- let registry = McpToolRegistry::new();
- registry.register_server("srv", McpConnectionStatus::Connecting, vec![], vec![], None);
- // when
- registry.register_server(
- "srv",
- McpConnectionStatus::Connected,
- vec![McpToolInfo {
- name: "inspect".into(),
- description: None,
- input_schema: None,
- }],
- vec![],
- Some("Inspector".into()),
- );
- let state = registry.get_server("srv").expect("server should exist");
- // then
- assert_eq!(state.status, McpConnectionStatus::Connected);
- assert_eq!(state.tools.len(), 1);
- assert_eq!(state.server_info.as_deref(), Some("Inspector"));
- }
- #[test]
- fn disconnect_missing_returns_none() {
- // given
- let registry = McpToolRegistry::new();
- // when
- let removed = registry.disconnect("missing");
- // then
- assert!(removed.is_none());
- }
- #[test]
- fn len_and_is_empty_transitions() {
- // given
- let registry = McpToolRegistry::new();
- // when
- registry.register_server(
- "alpha",
- McpConnectionStatus::Connected,
- vec![],
- vec![],
- None,
- );
- registry.register_server("beta", McpConnectionStatus::Connected, vec![], vec![], None);
- let after_create = registry.len();
- registry.disconnect("alpha");
- let after_first_remove = registry.len();
- registry.disconnect("beta");
- // then
- assert_eq!(after_create, 2);
- assert_eq!(after_first_remove, 1);
- assert_eq!(registry.len(), 0);
- assert!(registry.is_empty());
- }
- }
|