Skip to main content

harness/openai_compatible/tools/mcp/
mod.rs

1//! MCP (Model Context Protocol) tool source. Each configured server is launched
2//! over stdio, handshaken, and its advertised tools are surfaced as
3//! [`crate::openai_compatible::tools::Tool`]s — so external MCP tools sit beside the built-ins in
4//! the same [`ToolSet`](crate::openai_compatible::tools::ToolSet), offered and dispatched
5//! identically. Connection is best-effort: a server that fails to start or
6//! handshake is skipped with a status line, never aborting the run.
7
8use 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/// An MCP server to expose to the model, over stdio (a launched process) or HTTP
21/// (a remote endpoint). Registered on the harness via
22/// [`crate::openai_compatible::OpenHarness::with_mcp_server`].
23#[derive(Debug, Clone)]
24pub struct McpServer {
25    /// Short name used to namespace this server's tools (offered as `name_tool`).
26    pub name: String,
27    /// How to reach the server.
28    pub transport: McpTransport,
29}
30
31/// How an [`McpServer`] is reached.
32#[derive(Debug, Clone)]
33pub enum McpTransport {
34    /// Launch a local server process and speak over its stdin/stdout.
35    Stdio {
36        /// Executable to run (e.g. `npx`, `uvx`, or an absolute path).
37        command: String,
38        /// Arguments passed to the command.
39        args: Vec<String>,
40        /// Extra environment variables for the server process.
41        env: Vec<(String, String)>,
42    },
43    /// Connect to a remote server over HTTP (the Streamable-HTTP JSON-RPC
44    /// transport): each request is a POST whose reply is JSON or an SSE stream.
45    Http {
46        /// The server endpoint URL.
47        url: String,
48        /// Extra request headers (e.g. `Authorization: Bearer …`).
49        headers: Vec<(String, String)>,
50    },
51}
52
53impl McpServer {
54    /// A local stdio server — `command` plus `args`, no extra environment.
55    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    /// A remote HTTP server at `url`.
60    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    /// Add an environment variable for the server process (stdio only; a no-op
65    /// for an HTTP server).
66    #[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    /// Add a request header (HTTP only; a no-op for a stdio server).
75    #[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
84/// Connect to every configured server and collect their tools, along with a
85/// human-readable status line per server (connected + tool count, or skipped +
86/// reason) for the caller to surface. Servers that fail are simply omitted.
87pub(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                // Resources are a bonus surface: a read-only `{server}_read_resource`
99                // tool is added only when the server exposes any.
100                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/// A prompt template advertised by an MCP server (`prompts/list`). A host
123/// surfaces these (e.g. as slash-commands) and resolves one to messages with
124/// [`crate::openai_compatible::OpenHarness::get_mcp_prompt`] to seed a run.
125#[derive(Debug, Clone)]
126pub struct McpPrompt {
127    /// The registered server name this prompt came from.
128    pub server: String,
129    /// The prompt's name (pass to `get_mcp_prompt`).
130    pub name: String,
131    /// Human-readable description.
132    pub description: String,
133    /// Declared arguments the prompt accepts.
134    pub arguments: Vec<McpPromptArg>,
135}
136
137/// One argument a [`McpPrompt`] accepts.
138#[derive(Debug, Clone)]
139pub struct McpPromptArg {
140    /// Argument name.
141    pub name: String,
142    /// Human-readable description.
143    pub description: String,
144    /// Whether the argument is required.
145    pub required: bool,
146}
147
148/// One message of a resolved prompt (`prompts/get`), with its text flattened.
149#[derive(Debug, Clone)]
150pub struct PromptMessage {
151    /// The message role (`user` / `assistant`).
152    pub role: String,
153    /// The message text.
154    pub content: String,
155}
156
157/// Connect each server, list its prompt templates, and tag each with the server
158/// name. Best-effort: a server that fails to connect or doesn't support prompts
159/// is skipped. Spawns (and drops) the server processes.
160pub(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
177/// Resolve a prompt template (by server + name, with arguments) to its messages.
178pub(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    /// A server that speaks just enough of the protocol to be connected to,
195    /// offering `n_tools` tools and `n_resources` resources.
196    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        // Both builders take anything, because a host assembling servers from
221        // config should not have to branch. The quiet part is that the option
222        // is dropped rather than misapplied — an `env` on an HTTP server is not
223        // smuggled in as a header.
224        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        // Best-effort is the whole contract here: one misconfigured server must
251        // not take the run down with it, and the reason has to reach the user
252        // or the tools simply appear to be missing.
253        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        // The status line is the only place a user learns an MCP server came up
265        // with nothing, which is otherwise indistinguishable from it working.
266        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        // Singular, and no resource note when there are none to read.
271        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}