harness/openai_compatible/tools/mcp/
mod.rs1use std::path::Path;
9use std::sync::Arc;
10
11use self::client::McpClient;
12use self::tool::{McpResourceTool, McpTool};
13use crate::openai_compatible::tools::Tool;
14
15mod client;
16mod http;
17mod stdio;
18mod tool;
19
20#[derive(Debug, Clone)]
24pub struct McpServer {
25 pub name: String,
27 pub transport: McpTransport,
29}
30
31#[derive(Debug, Clone)]
33pub enum McpTransport {
34 Stdio {
36 command: String,
38 args: Vec<String>,
40 env: Vec<(String, String)>,
42 },
43 Http {
46 url: String,
48 headers: Vec<(String, String)>,
50 },
51}
52
53impl McpServer {
54 pub fn stdio(name: impl Into<String>, command: impl Into<String>, args: Vec<String>) -> Self {
56 Self { name: name.into(), transport: McpTransport::Stdio { command: command.into(), args, env: Vec::new() } }
57 }
58
59 pub fn http(name: impl Into<String>, url: impl Into<String>) -> Self {
61 Self { name: name.into(), transport: McpTransport::Http { url: url.into(), headers: Vec::new() } }
62 }
63
64 #[must_use]
67 pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
68 if let McpTransport::Stdio { env, .. } = &mut self.transport {
69 env.push((key.into(), value.into()));
70 }
71 self
72 }
73
74 #[must_use]
76 pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
77 if let McpTransport::Http { headers, .. } = &mut self.transport {
78 headers.push((key.into(), value.into()));
79 }
80 self
81 }
82}
83
84pub(crate) fn connect_all(servers: &[McpServer], cwd: &Path) -> (Vec<Box<dyn Tool>>, Vec<String>) {
88 let mut tools: Vec<Box<dyn Tool>> = Vec::new();
89 let mut status = Vec::new();
90 for server in servers {
91 match McpClient::connect(server, cwd) {
92 Ok((client, defs)) => {
93 let n = defs.len();
94 let client = Arc::new(client);
95 for def in defs {
96 tools.push(Box::new(McpTool::new(client.clone(), &server.name, def)));
97 }
98 let resources = client.list_resources();
101 let r = resources.len();
102 if !resources.is_empty() {
103 tools.push(Box::new(McpResourceTool::new(client.clone(), &server.name, &resources)));
104 }
105 let res_note = if r > 0 { format!(", {r} resource{}", plural(r)) } else { String::new() };
106 status.push(format!("mcp: connected `{}` ({n} tool{}{res_note})", server.name, plural(n)));
107 }
108 Err(e) => status.push(format!("mcp: `{}` unavailable — {e}", server.name)),
109 }
110 }
111 (tools, status)
112}
113
114fn plural(n: usize) -> &'static str {
115 if n == 1 {
116 ""
117 } else {
118 "s"
119 }
120}
121
122#[derive(Debug, Clone)]
126pub struct McpPrompt {
127 pub server: String,
129 pub name: String,
131 pub description: String,
133 pub arguments: Vec<McpPromptArg>,
135}
136
137#[derive(Debug, Clone)]
139pub struct McpPromptArg {
140 pub name: String,
142 pub description: String,
144 pub required: bool,
146}
147
148#[derive(Debug, Clone)]
150pub struct PromptMessage {
151 pub role: String,
153 pub content: String,
155}
156
157pub(crate) fn list_prompts(servers: &[McpServer], cwd: &Path) -> Vec<McpPrompt> {
161 let mut out = Vec::new();
162 for server in servers {
163 if let Ok((client, _tools)) = McpClient::connect(server, cwd) {
164 for p in client.list_prompts() {
165 out.push(McpPrompt {
166 server: server.name.clone(),
167 name: p.name,
168 description: p.description,
169 arguments: p.arguments,
170 });
171 }
172 }
173 }
174 out
175}
176
177pub(crate) fn get_prompt(
179 servers: &[McpServer],
180 server: &str,
181 name: &str,
182 arguments: &[(String, String)],
183 cwd: &Path,
184) -> Result<Vec<PromptMessage>, String> {
185 let cfg = servers.iter().find(|s| s.name == server).ok_or_else(|| format!("no MCP server named `{server}`"))?;
186 let (client, _tools) = McpClient::connect(cfg, cwd)?;
187 client.get_prompt(name, arguments)
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193
194 fn sh_server(name: &str, n_tools: usize, n_resources: usize) -> McpServer {
197 let tools: Vec<String> = (0..n_tools)
198 .map(|i| format!(r#"{{"name":"t{i}","description":"d","inputSchema":{{"type":"object"}}}}"#))
199 .collect();
200 let resources: Vec<String> =
201 (0..n_resources).map(|i| format!(r#"{{"uri":"file:///r{i}","name":"R{i}"}}"#)).collect();
202 let script = format!(
203 r#"
204 read _initialize
205 printf '%s\n' '{{"jsonrpc":"2.0","id":1,"result":{{"protocolVersion":"2025-11-25"}}}}'
206 read _initialized
207 read _list
208 printf '%s\n' '{{"jsonrpc":"2.0","id":2,"result":{{"tools":[{}]}}}}'
209 read _reslist
210 printf '%s\n' '{{"jsonrpc":"2.0","id":3,"result":{{"resources":[{}]}}}}'
211 "#,
212 tools.join(","),
213 resources.join(",")
214 );
215 McpServer::stdio(name, "sh", vec!["-c".to_owned(), script])
216 }
217
218 #[test]
219 fn a_transport_option_meant_for_the_other_transport_does_nothing() {
220 let stdio = McpServer::stdio("s", "npx", vec!["-y".to_owned()])
225 .env("TOKEN", "abc")
226 .header("Authorization", "Bearer x");
227 match &stdio.transport {
228 McpTransport::Stdio { command, args, env } => {
229 assert_eq!(command, "npx");
230 assert_eq!(args, &["-y"]);
231 assert_eq!(env, &[("TOKEN".to_owned(), "abc".to_owned())], "the env applies");
232 }
233 other => panic!("expected stdio, got {other:?}"),
234 }
235
236 let http = McpServer::http("h", "https://example.test/mcp")
237 .header("Authorization", "Bearer x")
238 .env("TOKEN", "abc");
239 match &http.transport {
240 McpTransport::Http { url, headers } => {
241 assert_eq!(url, "https://example.test/mcp");
242 assert_eq!(headers, &[("Authorization".to_owned(), "Bearer x".to_owned())], "the header applies");
243 }
244 other => panic!("expected http, got {other:?}"),
245 }
246 }
247
248 #[test]
249 fn a_server_that_will_not_start_is_reported_and_skipped() {
250 let servers = vec![McpServer::stdio("broken", "definitely-not-a-real-binary-xyz", vec![])];
254 let (tools, status) = connect_all(&servers, Path::new("."));
255
256 assert!(tools.is_empty());
257 assert_eq!(status.len(), 1);
258 assert!(status[0].contains("`broken` unavailable"), "got {:?}", status[0]);
259 assert!(status[0].contains("spawning"), "with the reason: {:?}", status[0]);
260 }
261
262 #[test]
263 fn a_connected_server_reports_what_it_actually_offered() {
264 let (tools, status) = connect_all(&[sh_server("many", 2, 1)], Path::new("."));
267 assert_eq!(tools.len(), 3, "two tools plus the resource reader");
268 assert_eq!(status, ["mcp: connected `many` (2 tools, 1 resource)"]);
269
270 let (tools, status) = connect_all(&[sh_server("one", 1, 0)], Path::new("."));
272 assert_eq!(tools.len(), 1, "no resource tool is added for a server with no resources");
273 assert_eq!(status, ["mcp: connected `one` (1 tool)"]);
274 }
275
276 #[test]
277 fn asking_an_unregistered_server_for_a_prompt_says_which_name_was_wrong() {
278 let servers = vec![McpServer::stdio("known", "sh", vec![])];
279 let err = get_prompt(&servers, "unknown", "p", &[], Path::new(".")).unwrap_err();
280 assert!(err.contains("no MCP server named `unknown`"), "got {err}");
281 }
282}