@ondeinference / ed / commits / 31f893d

ed-mcp: Connection::tools and Connection::call_tool

A host that manages its own servers (siGit Code reads them from mcp.toml and names tools its own way) needs the tools a connection listed and a call that says why it failed. call_raw keeps its Option and now goes through call_tool. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Seto Elkahfi committed Oct 9, 2026 at 13:44 UTC 31f893d936295419d6b74dcc490c259fdf16b8f6
2 files changed +43 -5
crates/ed-mcp/src/lib.rs
+19 -5
@@ -312,18 +312,32 @@ impl Connection {
312 &self.name
313 }
314
315 + /// The tools the server listed when it connected, under their own names.
316 + pub fn tools(&self) -> &[Tool] {
317 + &self.tools
318 + }
319 +
320 /// Call a tool on this server directly, outside any turn (for example to build a
321 /// permission prompt). Answers `None` on any failure or after `timeout`.
322 pub async fn call_raw(&self, tool: &str, args: Value, timeout: Duration) -> Option<Value> {
323 + self.call_tool(tool, args, timeout).await.ok()
324 + }
325 +
326 + /// Call `tool` by its name on this server and return the `CallToolResult` as JSON. A
327 + /// result with `isError: true` is `Ok`; a transport or protocol failure, or no answer
328 + /// within `timeout`, is an `Err` that says which.
329 + pub async fn call_tool(&self, tool: &str, args: Value, timeout: Duration) -> Result<Value> {
330 let mut params = CallToolRequestParams::new(tool.to_string());
319 - if let Value::Object(map) = args {
320 - params = params.with_arguments(map);
331 + match args {
332 + Value::Object(map) => params = params.with_arguments(map),
333 + Value::Null => {}
334 + _ => bail!("tool arguments must be a JSON object"),
335 }
336 let result = tokio::time::timeout(timeout, self.service.peer().call_tool(params))
337 .await
324 - .ok()?
325 - .ok()?;
326 - serde_json::to_value(result).ok()
338 + .map_err(|_| anyhow!("timed out after {}s", timeout.as_secs()))?
339 + .map_err(|e| anyhow!("{e}"))?;
340 + Ok(serde_json::to_value(result)?)
341 }
342
343 async fn close(mut self) {
crates/ed-mcp/tests/http.rs
+24
@@ -130,6 +130,30 @@ async fn connects_lists_and_calls_with_a_bearer_token() {
130 set.shutdown().await;
131 }
132
133 +#[tokio::test]
134 +async fn a_connection_lists_and_calls_tools_by_their_own_names() {
135 + let url = serve().await;
136 + let spec = ServerSpec::http("mail", url).with_bearer(TOKEN);
137 + let conn = Connection::connect(&spec, &client(), Duration::from_secs(5))
138 + .await
139 + .unwrap();
140 +
141 + let mut names: Vec<_> = conn.tools().iter().map(|t| t.name.to_string()).collect();
142 + names.sort();
143 + assert_eq!(names, ["list_messages", "send_message", "whoami"]);
144 +
145 + let result = conn
146 + .call_tool("whoami", json!({}), Duration::from_secs(5))
147 + .await
148 + .unwrap();
149 + assert_eq!(result["content"][0]["text"], "ran whoami");
150 + let err = conn
151 + .call_tool("whoami", json!([1]), Duration::from_secs(5))
152 + .await
153 + .unwrap_err();
154 + assert!(err.to_string().contains("JSON object"), "{err:#}");
155 +}
156 +
157 #[tokio::test]
158 async fn a_wrong_bearer_token_is_rejected() {
159 let url = serve().await;