Skip to main content

tooltest_core/validation/
listing.rs

1use std::fmt;
2use std::future::Future;
3
4use rmcp::model::Tool;
5
6use crate::schema::parse_list_tools;
7use crate::{HttpConfig, SchemaConfig, SchemaError, SessionDriver, SessionError, StdioConfig};
8
9/// Errors emitted while listing tools.
10#[derive(Debug)]
11pub enum ListToolsError {
12    /// Failed to communicate with the MCP endpoint.
13    Session(SessionError),
14    /// MCP payload failed schema validation.
15    Schema(SchemaError),
16}
17
18impl fmt::Display for ListToolsError {
19    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
20        match self {
21            ListToolsError::Session(error) => write!(f, "session error: {error}"),
22            ListToolsError::Schema(error) => write!(f, "schema error: {error}"),
23        }
24    }
25}
26
27impl std::error::Error for ListToolsError {}
28
29impl From<SessionError> for ListToolsError {
30    fn from(error: SessionError) -> Self {
31        ListToolsError::Session(error)
32    }
33}
34
35impl From<SchemaError> for ListToolsError {
36    fn from(error: SchemaError) -> Self {
37        ListToolsError::Schema(error)
38    }
39}
40
41/// Lists tools from an HTTP MCP endpoint using the provided configuration.
42///
43/// ```no_run
44/// use tooltest_core::{list_tools_http, HttpConfig, SchemaConfig};
45///
46/// # async fn run() -> Result<(), String> {
47/// let config = HttpConfig::new("http://localhost:3000/mcp")?;
48/// let tools = list_tools_http(&config, &SchemaConfig::default())
49///     .await
50///     .map_err(|error| error.to_string())?;
51/// println!("found {} tools", tools.len());
52/// # Ok(())
53/// # }
54/// # tokio::runtime::Runtime::new().unwrap().block_on(run());
55/// ```
56pub async fn list_tools_http(
57    config: &HttpConfig,
58    schema: &SchemaConfig,
59) -> Result<Vec<Tool>, ListToolsError> {
60    list_tools_with_connector(config, schema, SessionDriver::connect_http).await
61}
62
63/// Lists tools from a stdio MCP endpoint using the provided configuration.
64///
65/// ```no_run
66/// use tooltest_core::{list_tools_stdio, SchemaConfig, StdioConfig};
67///
68/// # async fn run() -> Result<(), String> {
69/// let config = StdioConfig::new("./my-mcp-server")?;
70/// let tools = list_tools_stdio(&config, &SchemaConfig::default())
71///     .await
72///     .map_err(|error| error.to_string())?;
73/// assert!(!tools.is_empty());
74/// # Ok(())
75/// # }
76/// # tokio::runtime::Runtime::new().unwrap().block_on(run());
77/// ```
78pub async fn list_tools_stdio(
79    config: &StdioConfig,
80    schema: &SchemaConfig,
81) -> Result<Vec<Tool>, ListToolsError> {
82    list_tools_with_connector(config, schema, SessionDriver::connect_stdio).await
83}
84
85/// Lists tools from an active session using MCP schema validation.
86///
87/// ```no_run
88/// use tooltest_core::{list_tools_with_session, SchemaConfig, SessionDriver, StdioConfig};
89///
90/// # async fn run() {
91/// let config = StdioConfig::new("./my-mcp-server").expect("valid config");
92/// let session = SessionDriver::connect_stdio(&config)
93///     .await
94///     .expect("connect");
95/// let tools = list_tools_with_session(&session, &SchemaConfig::default())
96///     .await
97///     .expect("list tools");
98/// println!("tool names: {:?}", tools.iter().map(|tool| &tool.name).collect::<Vec<_>>());
99/// # }
100/// # tokio::runtime::Runtime::new().unwrap().block_on(run());
101/// ```
102pub async fn list_tools_with_session(
103    session: &SessionDriver,
104    schema: &SchemaConfig,
105) -> Result<Vec<Tool>, ListToolsError> {
106    let tools = session.list_tools().await?;
107    let payload = serde_json::to_value(&rmcp::model::ListToolsResult {
108        tools,
109        next_cursor: None,
110        meta: None,
111    })
112    .unwrap_or(serde_json::Value::Null);
113    let parsed = parse_list_tools(payload, schema)?;
114    Ok(parsed.tools)
115}
116
117async fn list_tools_with_connector<T, F, Fut>(
118    config: T,
119    schema: &SchemaConfig,
120    connector: F,
121) -> Result<Vec<Tool>, ListToolsError>
122where
123    F: FnOnce(T) -> Fut,
124    Fut: Future<Output = Result<SessionDriver, SessionError>>,
125{
126    let session = connector(config).await?;
127    list_tools_with_session(&session, schema).await
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use std::sync::Arc;
134    use tooltest_test_support::{stub_tool, FaultyListToolsTransport, ListToolsTransport};
135
136    #[test]
137    fn list_tools_error_display_formats() {
138        let error = ListToolsError::Schema(SchemaError::InvalidListTools("boom".to_string()));
139        assert!(format!("{error}").contains("schema error"));
140
141        let io_error = std::io::Error::other("nope");
142        let error = ListToolsError::Session(SessionError::from(io_error));
143        assert!(format!("{error}").contains("session error"));
144        assert!(format!("{error}").contains("transport error"));
145    }
146
147    #[tokio::test]
148    async fn list_tools_with_connector_returns_tools() {
149        let tool = stub_tool("echo");
150        let transport = ListToolsTransport::new(vec![tool.clone()]);
151        let tools = list_tools_with_connector((), &SchemaConfig::default(), move |_| async move {
152            SessionDriver::connect_with_transport(transport).await
153        })
154        .await
155        .expect("list tools");
156        assert_eq!(tools.len(), 1);
157        assert_eq!(tools[0].name.as_ref(), tool.name.as_ref());
158    }
159
160    #[tokio::test]
161    async fn list_tools_with_connector_propagates_session_error() {
162        let error = list_tools_with_connector((), &SchemaConfig::default(), |_| async {
163            Err(SessionError::from(std::io::Error::other("nope")))
164        })
165        .await
166        .expect_err("session error");
167        assert!(error.to_string().contains("session error"));
168    }
169
170    #[tokio::test]
171    async fn list_tools_with_session_propagates_list_error() {
172        let transport = FaultyListToolsTransport::default();
173        let session = SessionDriver::connect_with_transport(transport)
174            .await
175            .expect("connect");
176
177        let error = list_tools_with_session(&session, &SchemaConfig::default())
178            .await
179            .expect_err("list tools error");
180        assert!(error.to_string().contains("session error"));
181    }
182
183    #[tokio::test]
184    async fn list_tools_with_session_reports_schema_error() {
185        let mut tool = stub_tool("echo");
186        tool.output_schema = Some(Arc::new(
187            serde_json::json!({ "type": 5 })
188                .as_object()
189                .cloned()
190                .unwrap(),
191        ));
192        let transport = ListToolsTransport::new(vec![tool]);
193        let session = SessionDriver::connect_with_transport(transport)
194            .await
195            .expect("connect");
196
197        let error = list_tools_with_session(&session, &SchemaConfig::default())
198            .await
199            .expect_err("schema error");
200        assert!(error.to_string().contains("schema error"));
201    }
202
203    #[cfg(coverage)]
204    #[tokio::test]
205    async fn list_tools_http_reports_session_error() {
206        let config = HttpConfig::new("http://127.0.0.1:0/mcp").expect("http config");
207
208        let error = list_tools_http(&config, &SchemaConfig::default())
209            .await
210            .expect_err("list tools error");
211        assert!(error.to_string().contains("session error"));
212    }
213
214    #[cfg(coverage)]
215    #[tokio::test]
216    async fn list_tools_stdio_reports_session_error() {
217        let config = StdioConfig::new("/no/such/tooltest-binary").expect("stdio config");
218
219        let error = list_tools_stdio(&config, &SchemaConfig::default())
220            .await
221            .expect_err("list tools error");
222        assert!(error.to_string().contains("session error"));
223    }
224}