use std::path::Path;
use std::sync::Arc;
use self::client::McpClient;
use self::tool::{McpResourceTool, McpTool};
use crate::openai_compatible::tools::Tool;
mod client;
mod http;
mod stdio;
mod tool;
#[derive(Debug, Clone)]
pub struct McpServer {
pub name: String,
pub transport: McpTransport,
}
#[derive(Debug, Clone)]
pub enum McpTransport {
Stdio {
command: String,
args: Vec<String>,
env: Vec<(String, String)>,
},
Http {
url: String,
headers: Vec<(String, String)>,
},
}
impl McpServer {
pub fn stdio(name: impl Into<String>, command: impl Into<String>, args: Vec<String>) -> Self {
Self { name: name.into(), transport: McpTransport::Stdio { command: command.into(), args, env: Vec::new() } }
}
pub fn http(name: impl Into<String>, url: impl Into<String>) -> Self {
Self { name: name.into(), transport: McpTransport::Http { url: url.into(), headers: Vec::new() } }
}
#[must_use]
pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
if let McpTransport::Stdio { env, .. } = &mut self.transport {
env.push((key.into(), value.into()));
}
self
}
#[must_use]
pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
if let McpTransport::Http { headers, .. } = &mut self.transport {
headers.push((key.into(), value.into()));
}
self
}
}
pub(crate) fn connect_all(servers: &[McpServer], cwd: &Path) -> (Vec<Box<dyn Tool>>, Vec<String>) {
let mut tools: Vec<Box<dyn Tool>> = Vec::new();
let mut status = Vec::new();
for server in servers {
match McpClient::connect(server, cwd) {
Ok((client, defs)) => {
let n = defs.len();
let client = Arc::new(client);
for def in defs {
tools.push(Box::new(McpTool::new(client.clone(), &server.name, def)));
}
let resources = client.list_resources();
let r = resources.len();
if !resources.is_empty() {
tools.push(Box::new(McpResourceTool::new(client.clone(), &server.name, &resources)));
}
let res_note = if r > 0 { format!(", {r} resource{}", plural(r)) } else { String::new() };
status.push(format!("mcp: connected `{}` ({n} tool{}{res_note})", server.name, plural(n)));
}
Err(e) => status.push(format!("mcp: `{}` unavailable — {e}", server.name)),
}
}
(tools, status)
}
fn plural(n: usize) -> &'static str {
if n == 1 {
""
} else {
"s"
}
}
#[derive(Debug, Clone)]
pub struct McpPrompt {
pub server: String,
pub name: String,
pub description: String,
pub arguments: Vec<McpPromptArg>,
}
#[derive(Debug, Clone)]
pub struct McpPromptArg {
pub name: String,
pub description: String,
pub required: bool,
}
#[derive(Debug, Clone)]
pub struct PromptMessage {
pub role: String,
pub content: String,
}
pub(crate) fn list_prompts(servers: &[McpServer], cwd: &Path) -> Vec<McpPrompt> {
let mut out = Vec::new();
for server in servers {
if let Ok((client, _tools)) = McpClient::connect(server, cwd) {
for p in client.list_prompts() {
out.push(McpPrompt {
server: server.name.clone(),
name: p.name,
description: p.description,
arguments: p.arguments,
});
}
}
}
out
}
pub(crate) fn get_prompt(
servers: &[McpServer],
server: &str,
name: &str,
arguments: &[(String, String)],
cwd: &Path,
) -> Result<Vec<PromptMessage>, String> {
let cfg = servers.iter().find(|s| s.name == server).ok_or_else(|| format!("no MCP server named `{server}`"))?;
let (client, _tools) = McpClient::connect(cfg, cwd)?;
client.get_prompt(name, arguments)
}
#[cfg(test)]
mod tests {
use super::*;
fn sh_server(name: &str, n_tools: usize, n_resources: usize) -> McpServer {
let tools: Vec<String> = (0..n_tools)
.map(|i| format!(r#"{{"name":"t{i}","description":"d","inputSchema":{{"type":"object"}}}}"#))
.collect();
let resources: Vec<String> =
(0..n_resources).map(|i| format!(r#"{{"uri":"file:///r{i}","name":"R{i}"}}"#)).collect();
let script = format!(
r#"
read _initialize
printf '%s\n' '{{"jsonrpc":"2.0","id":1,"result":{{"protocolVersion":"2025-11-25"}}}}'
read _initialized
read _list
printf '%s\n' '{{"jsonrpc":"2.0","id":2,"result":{{"tools":[{}]}}}}'
read _reslist
printf '%s\n' '{{"jsonrpc":"2.0","id":3,"result":{{"resources":[{}]}}}}'
"#,
tools.join(","),
resources.join(",")
);
McpServer::stdio(name, "sh", vec!["-c".to_owned(), script])
}
#[test]
fn a_transport_option_meant_for_the_other_transport_does_nothing() {
let stdio = McpServer::stdio("s", "npx", vec!["-y".to_owned()])
.env("TOKEN", "abc")
.header("Authorization", "Bearer x");
match &stdio.transport {
McpTransport::Stdio { command, args, env } => {
assert_eq!(command, "npx");
assert_eq!(args, &["-y"]);
assert_eq!(env, &[("TOKEN".to_owned(), "abc".to_owned())], "the env applies");
}
other => panic!("expected stdio, got {other:?}"),
}
let http = McpServer::http("h", "https://example.test/mcp")
.header("Authorization", "Bearer x")
.env("TOKEN", "abc");
match &http.transport {
McpTransport::Http { url, headers } => {
assert_eq!(url, "https://example.test/mcp");
assert_eq!(headers, &[("Authorization".to_owned(), "Bearer x".to_owned())], "the header applies");
}
other => panic!("expected http, got {other:?}"),
}
}
#[test]
fn a_server_that_will_not_start_is_reported_and_skipped() {
let servers = vec![McpServer::stdio("broken", "definitely-not-a-real-binary-xyz", vec![])];
let (tools, status) = connect_all(&servers, Path::new("."));
assert!(tools.is_empty());
assert_eq!(status.len(), 1);
assert!(status[0].contains("`broken` unavailable"), "got {:?}", status[0]);
assert!(status[0].contains("spawning"), "with the reason: {:?}", status[0]);
}
#[test]
fn a_connected_server_reports_what_it_actually_offered() {
let (tools, status) = connect_all(&[sh_server("many", 2, 1)], Path::new("."));
assert_eq!(tools.len(), 3, "two tools plus the resource reader");
assert_eq!(status, ["mcp: connected `many` (2 tools, 1 resource)"]);
let (tools, status) = connect_all(&[sh_server("one", 1, 0)], Path::new("."));
assert_eq!(tools.len(), 1, "no resource tool is added for a server with no resources");
assert_eq!(status, ["mcp: connected `one` (1 tool)"]);
}
#[test]
fn asking_an_unregistered_server_for_a_prompt_says_which_name_was_wrong() {
let servers = vec![McpServer::stdio("known", "sh", vec![])];
let err = get_prompt(&servers, "unknown", "p", &[], Path::new(".")).unwrap_err();
assert!(err.contains("no MCP server named `unknown`"), "got {err}");
}
}