mcp_tool_bridge.rs 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912
  1. //! Bridge between MCP tool surface (ListMcpResources, ReadMcpResource, McpAuth, MCP)
  2. //! and the existing McpServerManager runtime.
  3. //!
  4. //! Provides a stateful client registry that tool handlers can use to
  5. //! connect to MCP servers and invoke their capabilities.
  6. use std::collections::HashMap;
  7. use std::sync::{Arc, Mutex, OnceLock};
  8. use crate::mcp::mcp_tool_name;
  9. use crate::mcp_stdio::McpServerManager;
  10. use serde::{Deserialize, Serialize};
  11. /// Status of a managed MCP server connection.
  12. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
  13. #[serde(rename_all = "snake_case")]
  14. pub enum McpConnectionStatus {
  15. Disconnected,
  16. Connecting,
  17. Connected,
  18. AuthRequired,
  19. Error,
  20. }
  21. impl std::fmt::Display for McpConnectionStatus {
  22. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
  23. match self {
  24. Self::Disconnected => write!(f, "disconnected"),
  25. Self::Connecting => write!(f, "connecting"),
  26. Self::Connected => write!(f, "connected"),
  27. Self::AuthRequired => write!(f, "auth_required"),
  28. Self::Error => write!(f, "error"),
  29. }
  30. }
  31. }
  32. /// Metadata about an MCP resource.
  33. #[derive(Debug, Clone, Serialize, Deserialize)]
  34. pub struct McpResourceInfo {
  35. pub uri: String,
  36. pub name: String,
  37. pub description: Option<String>,
  38. pub mime_type: Option<String>,
  39. }
  40. /// Metadata about an MCP tool exposed by a server.
  41. #[derive(Debug, Clone, Serialize, Deserialize)]
  42. pub struct McpToolInfo {
  43. pub name: String,
  44. pub description: Option<String>,
  45. pub input_schema: Option<serde_json::Value>,
  46. }
  47. /// Tracked state of an MCP server connection.
  48. #[derive(Debug, Clone, Serialize, Deserialize)]
  49. pub struct McpServerState {
  50. pub server_name: String,
  51. pub status: McpConnectionStatus,
  52. pub tools: Vec<McpToolInfo>,
  53. pub resources: Vec<McpResourceInfo>,
  54. pub server_info: Option<String>,
  55. pub error_message: Option<String>,
  56. }
  57. #[derive(Debug, Clone, Default)]
  58. pub struct McpToolRegistry {
  59. inner: Arc<Mutex<HashMap<String, McpServerState>>>,
  60. manager: Arc<OnceLock<Arc<Mutex<McpServerManager>>>>,
  61. }
  62. impl McpToolRegistry {
  63. #[must_use]
  64. pub fn new() -> Self {
  65. Self::default()
  66. }
  67. pub fn set_manager(
  68. &self,
  69. manager: Arc<Mutex<McpServerManager>>,
  70. ) -> Result<(), Arc<Mutex<McpServerManager>>> {
  71. self.manager.set(manager)
  72. }
  73. pub fn register_server(
  74. &self,
  75. server_name: &str,
  76. status: McpConnectionStatus,
  77. tools: Vec<McpToolInfo>,
  78. resources: Vec<McpResourceInfo>,
  79. server_info: Option<String>,
  80. ) {
  81. let mut inner = self.inner.lock().expect("mcp registry lock poisoned");
  82. inner.insert(
  83. server_name.to_owned(),
  84. McpServerState {
  85. server_name: server_name.to_owned(),
  86. status,
  87. tools,
  88. resources,
  89. server_info,
  90. error_message: None,
  91. },
  92. );
  93. }
  94. pub fn get_server(&self, server_name: &str) -> Option<McpServerState> {
  95. let inner = self.inner.lock().expect("mcp registry lock poisoned");
  96. inner.get(server_name).cloned()
  97. }
  98. pub fn list_servers(&self) -> Vec<McpServerState> {
  99. let inner = self.inner.lock().expect("mcp registry lock poisoned");
  100. inner.values().cloned().collect()
  101. }
  102. pub fn list_resources(&self, server_name: &str) -> Result<Vec<McpResourceInfo>, String> {
  103. let inner = self.inner.lock().expect("mcp registry lock poisoned");
  104. match inner.get(server_name) {
  105. Some(state) => {
  106. if state.status != McpConnectionStatus::Connected {
  107. return Err(format!(
  108. "server '{}' is not connected (status: {})",
  109. server_name, state.status
  110. ));
  111. }
  112. Ok(state.resources.clone())
  113. }
  114. None => Err(format!("server '{}' not found", server_name)),
  115. }
  116. }
  117. pub fn read_resource(&self, server_name: &str, uri: &str) -> Result<McpResourceInfo, String> {
  118. let inner = self.inner.lock().expect("mcp registry lock poisoned");
  119. let state = inner
  120. .get(server_name)
  121. .ok_or_else(|| format!("server '{}' not found", server_name))?;
  122. if state.status != McpConnectionStatus::Connected {
  123. return Err(format!(
  124. "server '{}' is not connected (status: {})",
  125. server_name, state.status
  126. ));
  127. }
  128. state
  129. .resources
  130. .iter()
  131. .find(|r| r.uri == uri)
  132. .cloned()
  133. .ok_or_else(|| format!("resource '{}' not found on server '{}'", uri, server_name))
  134. }
  135. pub fn list_tools(&self, server_name: &str) -> Result<Vec<McpToolInfo>, String> {
  136. let inner = self.inner.lock().expect("mcp registry lock poisoned");
  137. match inner.get(server_name) {
  138. Some(state) => {
  139. if state.status != McpConnectionStatus::Connected {
  140. return Err(format!(
  141. "server '{}' is not connected (status: {})",
  142. server_name, state.status
  143. ));
  144. }
  145. Ok(state.tools.clone())
  146. }
  147. None => Err(format!("server '{}' not found", server_name)),
  148. }
  149. }
  150. fn spawn_tool_call(
  151. manager: Arc<Mutex<McpServerManager>>,
  152. qualified_tool_name: String,
  153. arguments: Option<serde_json::Value>,
  154. ) -> Result<serde_json::Value, String> {
  155. let join_handle = std::thread::Builder::new()
  156. .name(format!("mcp-tool-call-{qualified_tool_name}"))
  157. .spawn(move || {
  158. let runtime = tokio::runtime::Builder::new_current_thread()
  159. .enable_all()
  160. .build()
  161. .map_err(|error| format!("failed to create MCP tool runtime: {error}"))?;
  162. runtime.block_on(async move {
  163. let response = {
  164. let mut manager = manager
  165. .lock()
  166. .map_err(|_| "mcp server manager lock poisoned".to_string())?;
  167. manager
  168. .discover_tools()
  169. .await
  170. .map_err(|error| error.to_string())?;
  171. let response = manager
  172. .call_tool(&qualified_tool_name, arguments)
  173. .await
  174. .map_err(|error| error.to_string());
  175. let shutdown = manager.shutdown().await.map_err(|error| error.to_string());
  176. match (response, shutdown) {
  177. (Ok(response), Ok(())) => Ok(response),
  178. (Err(error), Ok(())) | (Err(error), Err(_)) => Err(error),
  179. (Ok(_), Err(error)) => Err(error),
  180. }
  181. }?;
  182. if let Some(error) = response.error {
  183. return Err(format!(
  184. "MCP server returned JSON-RPC error for tools/call: {} ({})",
  185. error.message, error.code
  186. ));
  187. }
  188. let result = response.result.ok_or_else(|| {
  189. "MCP server returned no result for tools/call".to_string()
  190. })?;
  191. serde_json::to_value(result)
  192. .map_err(|error| format!("failed to serialize MCP tool result: {error}"))
  193. })
  194. })
  195. .map_err(|error| format!("failed to spawn MCP tool call thread: {error}"))?;
  196. join_handle.join().map_err(|panic_payload| {
  197. if let Some(message) = panic_payload.downcast_ref::<&str>() {
  198. format!("MCP tool call thread panicked: {message}")
  199. } else if let Some(message) = panic_payload.downcast_ref::<String>() {
  200. format!("MCP tool call thread panicked: {message}")
  201. } else {
  202. "MCP tool call thread panicked".to_string()
  203. }
  204. })?
  205. }
  206. pub fn call_tool(
  207. &self,
  208. server_name: &str,
  209. tool_name: &str,
  210. arguments: &serde_json::Value,
  211. ) -> Result<serde_json::Value, String> {
  212. let inner = self.inner.lock().expect("mcp registry lock poisoned");
  213. let state = inner
  214. .get(server_name)
  215. .ok_or_else(|| format!("server '{}' not found", server_name))?;
  216. if state.status != McpConnectionStatus::Connected {
  217. return Err(format!(
  218. "server '{}' is not connected (status: {})",
  219. server_name, state.status
  220. ));
  221. }
  222. if !state.tools.iter().any(|t| t.name == tool_name) {
  223. return Err(format!(
  224. "tool '{}' not found on server '{}'",
  225. tool_name, server_name
  226. ));
  227. }
  228. drop(inner);
  229. let manager = self
  230. .manager
  231. .get()
  232. .cloned()
  233. .ok_or_else(|| "MCP server manager is not configured".to_string())?;
  234. Self::spawn_tool_call(
  235. manager,
  236. mcp_tool_name(server_name, tool_name),
  237. (!arguments.is_null()).then(|| arguments.clone()),
  238. )
  239. }
  240. /// Set auth status for a server.
  241. pub fn set_auth_status(
  242. &self,
  243. server_name: &str,
  244. status: McpConnectionStatus,
  245. ) -> Result<(), String> {
  246. let mut inner = self.inner.lock().expect("mcp registry lock poisoned");
  247. let state = inner
  248. .get_mut(server_name)
  249. .ok_or_else(|| format!("server '{}' not found", server_name))?;
  250. state.status = status;
  251. Ok(())
  252. }
  253. /// Disconnect / remove a server.
  254. pub fn disconnect(&self, server_name: &str) -> Option<McpServerState> {
  255. let mut inner = self.inner.lock().expect("mcp registry lock poisoned");
  256. inner.remove(server_name)
  257. }
  258. /// Number of registered servers.
  259. #[must_use]
  260. pub fn len(&self) -> usize {
  261. let inner = self.inner.lock().expect("mcp registry lock poisoned");
  262. inner.len()
  263. }
  264. #[must_use]
  265. pub fn is_empty(&self) -> bool {
  266. self.len() == 0
  267. }
  268. }
  269. #[cfg(test)]
  270. mod tests {
  271. use std::collections::BTreeMap;
  272. use std::fs;
  273. use std::os::unix::fs::PermissionsExt;
  274. use std::path::{Path, PathBuf};
  275. use std::sync::atomic::{AtomicU64, Ordering};
  276. use std::time::{SystemTime, UNIX_EPOCH};
  277. use super::*;
  278. use crate::config::{
  279. ConfigSource, McpServerConfig, McpStdioServerConfig, ScopedMcpServerConfig,
  280. };
  281. fn temp_dir() -> PathBuf {
  282. static NEXT_TEMP_DIR_ID: AtomicU64 = AtomicU64::new(0);
  283. let nanos = SystemTime::now()
  284. .duration_since(UNIX_EPOCH)
  285. .expect("time should be after epoch")
  286. .as_nanos();
  287. let unique_id = NEXT_TEMP_DIR_ID.fetch_add(1, Ordering::Relaxed);
  288. std::env::temp_dir().join(format!("runtime-mcp-tool-bridge-{nanos}-{unique_id}"))
  289. }
  290. fn cleanup_script(script_path: &Path) {
  291. if let Some(root) = script_path.parent() {
  292. let _ = fs::remove_dir_all(root);
  293. }
  294. }
  295. fn write_bridge_mcp_server_script() -> PathBuf {
  296. let root = temp_dir();
  297. fs::create_dir_all(&root).expect("temp dir");
  298. let script_path = root.join("bridge-mcp-server.py");
  299. let script = [
  300. "#!/usr/bin/env python3",
  301. "import json, os, sys",
  302. "LABEL = os.environ.get('MCP_SERVER_LABEL', 'server')",
  303. "LOG_PATH = os.environ.get('MCP_LOG_PATH')",
  304. "",
  305. "def log(method):",
  306. " if LOG_PATH:",
  307. " with open(LOG_PATH, 'a', encoding='utf-8') as handle:",
  308. " handle.write(f'{method}\\n')",
  309. "",
  310. "def read_message():",
  311. " header = b''",
  312. r" while not header.endswith(b'\r\n\r\n'):",
  313. " chunk = sys.stdin.buffer.read(1)",
  314. " if not chunk:",
  315. " return None",
  316. " header += chunk",
  317. " length = 0",
  318. r" for line in header.decode().split('\r\n'):",
  319. r" if line.lower().startswith('content-length:'):",
  320. r" length = int(line.split(':', 1)[1].strip())",
  321. " payload = sys.stdin.buffer.read(length)",
  322. " return json.loads(payload.decode())",
  323. "",
  324. "def send_message(message):",
  325. " payload = json.dumps(message).encode()",
  326. r" sys.stdout.buffer.write(f'Content-Length: {len(payload)}\r\n\r\n'.encode() + payload)",
  327. " sys.stdout.buffer.flush()",
  328. "",
  329. "while True:",
  330. " request = read_message()",
  331. " if request is None:",
  332. " break",
  333. " method = request['method']",
  334. " log(method)",
  335. " if method == 'initialize':",
  336. " send_message({",
  337. " 'jsonrpc': '2.0',",
  338. " 'id': request['id'],",
  339. " 'result': {",
  340. " 'protocolVersion': request['params']['protocolVersion'],",
  341. " 'capabilities': {'tools': {}},",
  342. " 'serverInfo': {'name': LABEL, 'version': '1.0.0'}",
  343. " }",
  344. " })",
  345. " elif method == 'tools/list':",
  346. " send_message({",
  347. " 'jsonrpc': '2.0',",
  348. " 'id': request['id'],",
  349. " 'result': {",
  350. " 'tools': [",
  351. " {",
  352. " 'name': 'echo',",
  353. " 'description': f'Echo tool for {LABEL}',",
  354. " 'inputSchema': {",
  355. " 'type': 'object',",
  356. " 'properties': {'text': {'type': 'string'}},",
  357. " 'required': ['text']",
  358. " }",
  359. " }",
  360. " ]",
  361. " }",
  362. " })",
  363. " elif method == 'tools/call':",
  364. " args = request['params'].get('arguments') or {}",
  365. " text = args.get('text', '')",
  366. " send_message({",
  367. " 'jsonrpc': '2.0',",
  368. " 'id': request['id'],",
  369. " 'result': {",
  370. " 'content': [{'type': 'text', 'text': f'{LABEL}:{text}'}],",
  371. " 'structuredContent': {'server': LABEL, 'echoed': text},",
  372. " 'isError': False",
  373. " }",
  374. " })",
  375. " else:",
  376. " send_message({",
  377. " 'jsonrpc': '2.0',",
  378. " 'id': request['id'],",
  379. " 'error': {'code': -32601, 'message': f'unknown method: {method}'},",
  380. " })",
  381. "",
  382. ]
  383. .join("\n");
  384. fs::write(&script_path, script).expect("write script");
  385. let mut permissions = fs::metadata(&script_path).expect("metadata").permissions();
  386. permissions.set_mode(0o755);
  387. fs::set_permissions(&script_path, permissions).expect("chmod");
  388. script_path
  389. }
  390. fn manager_server_config(
  391. script_path: &Path,
  392. server_name: &str,
  393. log_path: &Path,
  394. ) -> ScopedMcpServerConfig {
  395. ScopedMcpServerConfig {
  396. scope: ConfigSource::Local,
  397. config: McpServerConfig::Stdio(McpStdioServerConfig {
  398. command: "python3".to_string(),
  399. args: vec![script_path.to_string_lossy().into_owned()],
  400. env: BTreeMap::from([
  401. ("MCP_SERVER_LABEL".to_string(), server_name.to_string()),
  402. (
  403. "MCP_LOG_PATH".to_string(),
  404. log_path.to_string_lossy().into_owned(),
  405. ),
  406. ]),
  407. tool_call_timeout_ms: Some(1_000),
  408. }),
  409. }
  410. }
  411. #[test]
  412. fn registers_and_retrieves_server() {
  413. let registry = McpToolRegistry::new();
  414. registry.register_server(
  415. "test-server",
  416. McpConnectionStatus::Connected,
  417. vec![McpToolInfo {
  418. name: "greet".into(),
  419. description: Some("Greet someone".into()),
  420. input_schema: None,
  421. }],
  422. vec![McpResourceInfo {
  423. uri: "res://data".into(),
  424. name: "Data".into(),
  425. description: None,
  426. mime_type: Some("application/json".into()),
  427. }],
  428. Some("TestServer v1.0".into()),
  429. );
  430. let server = registry.get_server("test-server").expect("should exist");
  431. assert_eq!(server.status, McpConnectionStatus::Connected);
  432. assert_eq!(server.tools.len(), 1);
  433. assert_eq!(server.resources.len(), 1);
  434. }
  435. #[test]
  436. fn lists_resources_from_connected_server() {
  437. let registry = McpToolRegistry::new();
  438. registry.register_server(
  439. "srv",
  440. McpConnectionStatus::Connected,
  441. vec![],
  442. vec![McpResourceInfo {
  443. uri: "res://alpha".into(),
  444. name: "Alpha".into(),
  445. description: None,
  446. mime_type: None,
  447. }],
  448. None,
  449. );
  450. let resources = registry.list_resources("srv").expect("should succeed");
  451. assert_eq!(resources.len(), 1);
  452. assert_eq!(resources[0].uri, "res://alpha");
  453. }
  454. #[test]
  455. fn rejects_resource_listing_for_disconnected_server() {
  456. let registry = McpToolRegistry::new();
  457. registry.register_server(
  458. "srv",
  459. McpConnectionStatus::Disconnected,
  460. vec![],
  461. vec![],
  462. None,
  463. );
  464. assert!(registry.list_resources("srv").is_err());
  465. }
  466. #[test]
  467. fn reads_specific_resource() {
  468. let registry = McpToolRegistry::new();
  469. registry.register_server(
  470. "srv",
  471. McpConnectionStatus::Connected,
  472. vec![],
  473. vec![McpResourceInfo {
  474. uri: "res://data".into(),
  475. name: "Data".into(),
  476. description: Some("Test data".into()),
  477. mime_type: Some("text/plain".into()),
  478. }],
  479. None,
  480. );
  481. let resource = registry
  482. .read_resource("srv", "res://data")
  483. .expect("should find");
  484. assert_eq!(resource.name, "Data");
  485. assert!(registry.read_resource("srv", "res://missing").is_err());
  486. }
  487. #[test]
  488. fn given_connected_server_without_manager_when_calling_tool_then_it_errors() {
  489. let registry = McpToolRegistry::new();
  490. registry.register_server(
  491. "srv",
  492. McpConnectionStatus::Connected,
  493. vec![McpToolInfo {
  494. name: "greet".into(),
  495. description: None,
  496. input_schema: None,
  497. }],
  498. vec![],
  499. None,
  500. );
  501. let error = registry
  502. .call_tool("srv", "greet", &serde_json::json!({"name": "world"}))
  503. .expect_err("should require a configured manager");
  504. assert!(error.contains("MCP server manager is not configured"));
  505. // Unknown tool should fail
  506. assert!(registry
  507. .call_tool("srv", "missing", &serde_json::json!({}))
  508. .is_err());
  509. }
  510. #[test]
  511. fn given_connected_server_with_manager_when_calling_tool_then_it_returns_live_result() {
  512. let script_path = write_bridge_mcp_server_script();
  513. let root = script_path.parent().expect("script parent");
  514. let log_path = root.join("bridge.log");
  515. let servers = BTreeMap::from([(
  516. "alpha".to_string(),
  517. manager_server_config(&script_path, "alpha", &log_path),
  518. )]);
  519. let manager = Arc::new(Mutex::new(McpServerManager::from_servers(&servers)));
  520. let registry = McpToolRegistry::new();
  521. registry.register_server(
  522. "alpha",
  523. McpConnectionStatus::Connected,
  524. vec![McpToolInfo {
  525. name: "echo".into(),
  526. description: Some("Echo tool for alpha".into()),
  527. input_schema: Some(serde_json::json!({
  528. "type": "object",
  529. "properties": {"text": {"type": "string"}},
  530. "required": ["text"]
  531. })),
  532. }],
  533. vec![],
  534. Some("bridge test server".into()),
  535. );
  536. registry
  537. .set_manager(Arc::clone(&manager))
  538. .expect("manager should only be set once");
  539. let result = registry
  540. .call_tool("alpha", "echo", &serde_json::json!({"text": "hello"}))
  541. .expect("should return live MCP result");
  542. assert_eq!(
  543. result["structuredContent"]["server"],
  544. serde_json::json!("alpha")
  545. );
  546. assert_eq!(
  547. result["structuredContent"]["echoed"],
  548. serde_json::json!("hello")
  549. );
  550. assert_eq!(
  551. result["content"][0]["text"],
  552. serde_json::json!("alpha:hello")
  553. );
  554. let log = fs::read_to_string(&log_path).expect("read log");
  555. assert_eq!(
  556. log.lines().collect::<Vec<_>>(),
  557. vec!["initialize", "tools/list", "tools/call"]
  558. );
  559. cleanup_script(&script_path);
  560. }
  561. #[test]
  562. fn rejects_tool_call_on_disconnected_server() {
  563. let registry = McpToolRegistry::new();
  564. registry.register_server(
  565. "srv",
  566. McpConnectionStatus::AuthRequired,
  567. vec![McpToolInfo {
  568. name: "greet".into(),
  569. description: None,
  570. input_schema: None,
  571. }],
  572. vec![],
  573. None,
  574. );
  575. assert!(registry
  576. .call_tool("srv", "greet", &serde_json::json!({}))
  577. .is_err());
  578. }
  579. #[test]
  580. fn sets_auth_and_disconnects() {
  581. let registry = McpToolRegistry::new();
  582. registry.register_server(
  583. "srv",
  584. McpConnectionStatus::AuthRequired,
  585. vec![],
  586. vec![],
  587. None,
  588. );
  589. registry
  590. .set_auth_status("srv", McpConnectionStatus::Connected)
  591. .expect("should succeed");
  592. let state = registry.get_server("srv").unwrap();
  593. assert_eq!(state.status, McpConnectionStatus::Connected);
  594. let removed = registry.disconnect("srv");
  595. assert!(removed.is_some());
  596. assert!(registry.is_empty());
  597. }
  598. #[test]
  599. fn rejects_operations_on_missing_server() {
  600. let registry = McpToolRegistry::new();
  601. assert!(registry.list_resources("missing").is_err());
  602. assert!(registry.read_resource("missing", "uri").is_err());
  603. assert!(registry.list_tools("missing").is_err());
  604. assert!(registry
  605. .call_tool("missing", "tool", &serde_json::json!({}))
  606. .is_err());
  607. assert!(registry
  608. .set_auth_status("missing", McpConnectionStatus::Connected)
  609. .is_err());
  610. }
  611. #[test]
  612. fn mcp_connection_status_display_all_variants() {
  613. // given
  614. let cases = [
  615. (McpConnectionStatus::Disconnected, "disconnected"),
  616. (McpConnectionStatus::Connecting, "connecting"),
  617. (McpConnectionStatus::Connected, "connected"),
  618. (McpConnectionStatus::AuthRequired, "auth_required"),
  619. (McpConnectionStatus::Error, "error"),
  620. ];
  621. // when
  622. let rendered: Vec<_> = cases
  623. .into_iter()
  624. .map(|(status, expected)| (status.to_string(), expected))
  625. .collect();
  626. // then
  627. assert_eq!(
  628. rendered,
  629. vec![
  630. ("disconnected".to_string(), "disconnected"),
  631. ("connecting".to_string(), "connecting"),
  632. ("connected".to_string(), "connected"),
  633. ("auth_required".to_string(), "auth_required"),
  634. ("error".to_string(), "error"),
  635. ]
  636. );
  637. }
  638. #[test]
  639. fn list_servers_returns_all_registered() {
  640. // given
  641. let registry = McpToolRegistry::new();
  642. registry.register_server(
  643. "alpha",
  644. McpConnectionStatus::Connected,
  645. vec![],
  646. vec![],
  647. None,
  648. );
  649. registry.register_server(
  650. "beta",
  651. McpConnectionStatus::Connecting,
  652. vec![],
  653. vec![],
  654. None,
  655. );
  656. // when
  657. let servers = registry.list_servers();
  658. // then
  659. assert_eq!(servers.len(), 2);
  660. assert!(servers.iter().any(|server| server.server_name == "alpha"));
  661. assert!(servers.iter().any(|server| server.server_name == "beta"));
  662. }
  663. #[test]
  664. fn list_tools_from_connected_server() {
  665. // given
  666. let registry = McpToolRegistry::new();
  667. registry.register_server(
  668. "srv",
  669. McpConnectionStatus::Connected,
  670. vec![McpToolInfo {
  671. name: "inspect".into(),
  672. description: Some("Inspect data".into()),
  673. input_schema: Some(serde_json::json!({"type": "object"})),
  674. }],
  675. vec![],
  676. None,
  677. );
  678. // when
  679. let tools = registry.list_tools("srv").expect("tools should list");
  680. // then
  681. assert_eq!(tools.len(), 1);
  682. assert_eq!(tools[0].name, "inspect");
  683. }
  684. #[test]
  685. fn list_tools_rejects_disconnected_server() {
  686. // given
  687. let registry = McpToolRegistry::new();
  688. registry.register_server(
  689. "srv",
  690. McpConnectionStatus::AuthRequired,
  691. vec![],
  692. vec![],
  693. None,
  694. );
  695. // when
  696. let result = registry.list_tools("srv");
  697. // then
  698. let error = result.expect_err("non-connected server should fail");
  699. assert!(error.contains("not connected"));
  700. assert!(error.contains("auth_required"));
  701. }
  702. #[test]
  703. fn list_tools_rejects_missing_server() {
  704. // given
  705. let registry = McpToolRegistry::new();
  706. // when
  707. let result = registry.list_tools("missing");
  708. // then
  709. assert_eq!(
  710. result.expect_err("missing server should fail"),
  711. "server 'missing' not found"
  712. );
  713. }
  714. #[test]
  715. fn get_server_returns_none_for_missing() {
  716. // given
  717. let registry = McpToolRegistry::new();
  718. // when
  719. let server = registry.get_server("missing");
  720. // then
  721. assert!(server.is_none());
  722. }
  723. #[test]
  724. fn call_tool_payload_structure() {
  725. let script_path = write_bridge_mcp_server_script();
  726. let root = script_path.parent().expect("script parent");
  727. let log_path = root.join("payload.log");
  728. let servers = BTreeMap::from([(
  729. "srv".to_string(),
  730. manager_server_config(&script_path, "srv", &log_path),
  731. )]);
  732. let registry = McpToolRegistry::new();
  733. let arguments = serde_json::json!({"text": "world"});
  734. registry.register_server(
  735. "srv",
  736. McpConnectionStatus::Connected,
  737. vec![McpToolInfo {
  738. name: "echo".into(),
  739. description: Some("Echo tool for srv".into()),
  740. input_schema: Some(serde_json::json!({
  741. "type": "object",
  742. "properties": {"text": {"type": "string"}},
  743. "required": ["text"]
  744. })),
  745. }],
  746. vec![],
  747. None,
  748. );
  749. registry
  750. .set_manager(Arc::new(Mutex::new(McpServerManager::from_servers(
  751. &servers,
  752. ))))
  753. .expect("manager should only be set once");
  754. let result = registry
  755. .call_tool("srv", "echo", &arguments)
  756. .expect("tool should return live payload");
  757. assert_eq!(result["structuredContent"]["server"], "srv");
  758. assert_eq!(result["structuredContent"]["echoed"], "world");
  759. assert_eq!(result["content"][0]["text"], "srv:world");
  760. cleanup_script(&script_path);
  761. }
  762. #[test]
  763. fn upsert_overwrites_existing_server() {
  764. // given
  765. let registry = McpToolRegistry::new();
  766. registry.register_server("srv", McpConnectionStatus::Connecting, vec![], vec![], None);
  767. // when
  768. registry.register_server(
  769. "srv",
  770. McpConnectionStatus::Connected,
  771. vec![McpToolInfo {
  772. name: "inspect".into(),
  773. description: None,
  774. input_schema: None,
  775. }],
  776. vec![],
  777. Some("Inspector".into()),
  778. );
  779. let state = registry.get_server("srv").expect("server should exist");
  780. // then
  781. assert_eq!(state.status, McpConnectionStatus::Connected);
  782. assert_eq!(state.tools.len(), 1);
  783. assert_eq!(state.server_info.as_deref(), Some("Inspector"));
  784. }
  785. #[test]
  786. fn disconnect_missing_returns_none() {
  787. // given
  788. let registry = McpToolRegistry::new();
  789. // when
  790. let removed = registry.disconnect("missing");
  791. // then
  792. assert!(removed.is_none());
  793. }
  794. #[test]
  795. fn len_and_is_empty_transitions() {
  796. // given
  797. let registry = McpToolRegistry::new();
  798. // when
  799. registry.register_server(
  800. "alpha",
  801. McpConnectionStatus::Connected,
  802. vec![],
  803. vec![],
  804. None,
  805. );
  806. registry.register_server("beta", McpConnectionStatus::Connected, vec![], vec![], None);
  807. let after_create = registry.len();
  808. registry.disconnect("alpha");
  809. let after_first_remove = registry.len();
  810. registry.disconnect("beta");
  811. // then
  812. assert_eq!(after_create, 2);
  813. assert_eq!(after_first_remove, 1);
  814. assert_eq!(registry.len(), 0);
  815. assert!(registry.is_empty());
  816. }
  817. }