use crate::config::McpServerConfig;
use crate::error::{OxoError, Result};
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[cfg(not(target_arch = "wasm32"))]
use serde_json::json;
#[cfg(not(target_arch = "wasm32"))]
use std::time::Duration;
const MCP_TIMEOUT_SECS: u64 = 5;
#[derive(Serialize)]
struct RpcRequest<'a> {
jsonrpc: &'a str,
id: u64,
method: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
params: Option<Value>,
}
#[derive(Deserialize)]
struct RpcResponse {
#[allow(dead_code)]
id: Option<Value>,
result: Option<Value>,
error: Option<Value>,
}
pub struct McpClient {
config: McpServerConfig,
#[cfg(not(target_arch = "wasm32"))]
http: reqwest::Client,
}
impl McpClient {
pub fn new(config: McpServerConfig) -> Self {
McpClient {
config,
#[cfg(not(target_arch = "wasm32"))]
http: reqwest::Client::builder()
.timeout(Duration::from_secs(MCP_TIMEOUT_SECS))
.build()
.expect("failed to build HTTP client with timeout"),
}
}
fn endpoint(&self) -> String {
let base = self.config.url.trim_end_matches('/');
if base.ends_with("/mcp") {
base.to_string()
} else {
format!("{base}/mcp")
}
}
#[cfg(not(target_arch = "wasm32"))]
async fn send(&self, method: &str, params: Option<Value>, id: u64) -> Result<Value> {
let req = RpcRequest {
jsonrpc: "2.0",
id,
method,
params,
};
let mut builder = self
.http
.post(self.endpoint())
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("MCP-Protocol-Version", "2024-11-05")
.json(&req);
if let Some(key) = &self.config.api_key {
builder = builder.header("Authorization", format!("Bearer {key}"));
}
let response = builder.send().await.map_err(|e| {
OxoError::IndexError(format!(
"MCP server '{}' unreachable: {e}",
self.config.name()
))
})?;
if !response.status().is_success() {
return Err(OxoError::IndexError(format!(
"MCP server '{}' returned HTTP {}",
self.config.name(),
response.status()
)));
}
let rpc: RpcResponse = response.json().await.map_err(|e| {
OxoError::IndexError(format!(
"MCP server '{}' returned invalid JSON: {e}",
self.config.name()
))
})?;
if let Some(err) = rpc.error {
return Err(OxoError::IndexError(format!(
"MCP error from '{}': {err}",
self.config.name()
)));
}
rpc.result.ok_or_else(|| {
OxoError::IndexError(format!(
"MCP server '{}' returned empty result for '{method}'",
self.config.name()
))
})
}
#[cfg(not(target_arch = "wasm32"))]
pub async fn initialize(&self) -> Result<(String, String)> {
let params = json!({
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {
"name": "oxo-call",
"version": env!("CARGO_PKG_VERSION")
}
});
let result = self.send("initialize", Some(params), 1).await?;
let name = result["serverInfo"]["name"]
.as_str()
.unwrap_or(&self.config.url)
.to_string();
let version = result["serverInfo"]["version"]
.as_str()
.unwrap_or("unknown")
.to_string();
Ok((name, version))
}
#[cfg(target_arch = "wasm32")]
pub async fn initialize(&self) -> Result<(String, String)> {
Err(OxoError::IndexError(
"MCP is not supported in WebAssembly".to_string(),
))
}
#[cfg(not(target_arch = "wasm32"))]
pub async fn list_skill_resources(&self) -> Result<Vec<McpSkillEntry>> {
let result = self.send("resources/list", None, 2).await?;
let resources = match result["resources"].as_array() {
Some(r) => r.clone(),
None => return Ok(Vec::new()),
};
let mut entries = Vec::new();
for res in &resources {
let uri = res["uri"].as_str().unwrap_or("").to_string();
let name = res["name"].as_str().unwrap_or("").to_string();
let description = res["description"].as_str().unwrap_or("").to_string();
let mime = res["mimeType"].as_str().unwrap_or("");
if let Some(tool) = uri.strip_prefix("skill://") {
entries.push(McpSkillEntry {
tool: tool.to_string(),
description,
uri,
});
} else if mime == "text/markdown" && !name.is_empty() {
entries.push(McpSkillEntry {
tool: name,
description,
uri,
});
}
}
Ok(entries)
}
#[cfg(target_arch = "wasm32")]
pub async fn list_skill_resources(&self) -> Result<Vec<McpSkillEntry>> {
Err(OxoError::IndexError(
"MCP is not supported in WebAssembly".to_string(),
))
}
#[cfg(not(target_arch = "wasm32"))]
pub async fn read_resource(&self, uri: &str) -> Result<String> {
let params = json!({ "uri": uri });
let result = self.send("resources/read", Some(params), 3).await?;
let contents = result["contents"].as_array().cloned().unwrap_or_default();
for item in &contents {
if let Some(text) = item["text"].as_str()
&& !text.is_empty()
{
return Ok(text.to_string());
}
}
Err(OxoError::IndexError(format!(
"MCP server '{}' returned empty content for '{uri}'",
self.config.name()
)))
}
#[cfg(not(target_arch = "wasm32"))]
pub async fn fetch_skill(&self, tool: &str) -> Option<String> {
let canonical = format!("skill://{tool}");
if let Ok(content) = self.read_resource(&canonical).await {
return Some(content);
}
if let Ok(entries) = self.list_skill_resources().await {
let tool_lc = tool.to_ascii_lowercase();
for entry in entries {
if entry.tool.to_ascii_lowercase() == tool_lc
&& let Ok(content) = self.read_resource(&entry.uri).await
{
return Some(content);
}
}
}
None
}
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct McpSkillEntry {
pub uri: String,
pub tool: String,
pub description: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::McpServerConfig;
fn make_config(url: &str) -> McpServerConfig {
McpServerConfig {
url: url.to_string(),
name: "test-server".to_string(),
api_key: None,
}
}
#[test]
fn test_endpoint_appends_mcp() {
let client = McpClient::new(make_config("http://localhost:3000"));
assert_eq!(client.endpoint(), "http://localhost:3000/mcp");
}
#[test]
fn test_endpoint_preserves_existing_mcp_suffix() {
let client = McpClient::new(make_config("http://localhost:3000/mcp"));
assert_eq!(client.endpoint(), "http://localhost:3000/mcp");
}
#[test]
fn test_endpoint_trims_trailing_slash_before_append() {
let client = McpClient::new(make_config("http://localhost:3000/"));
assert_eq!(client.endpoint(), "http://localhost:3000/mcp");
}
#[test]
fn test_endpoint_with_path_prefix() {
let client = McpClient::new(make_config("https://skills.example.org/api"));
assert_eq!(client.endpoint(), "https://skills.example.org/api/mcp");
}
#[test]
fn test_mcp_client_new_stores_config() {
let cfg = McpServerConfig {
url: "http://localhost:9000".to_string(),
name: "my-server".to_string(),
api_key: Some("secret".to_string()),
};
let client = McpClient::new(cfg.clone());
assert_eq!(client.config.url, "http://localhost:9000");
assert_eq!(client.config.name, "my-server");
assert_eq!(client.config.api_key.as_deref(), Some("secret"));
}
#[test]
fn test_mcp_skill_entry_debug() {
let entry = McpSkillEntry {
uri: "skill://samtools".to_string(),
tool: "samtools".to_string(),
description: "SAM/BAM tool".to_string(),
};
let s = format!("{entry:?}");
assert!(s.contains("samtools"));
assert!(s.contains("SAM/BAM tool"));
}
#[test]
fn test_mcp_skill_entry_clone() {
let entry = McpSkillEntry {
uri: "skill://bwa".to_string(),
tool: "bwa".to_string(),
description: "Burrows-Wheeler Aligner".to_string(),
};
let cloned = entry.clone();
assert_eq!(cloned.uri, entry.uri);
assert_eq!(cloned.tool, entry.tool);
assert_eq!(cloned.description, entry.description);
}
#[test]
fn test_rpc_request_serialization() {
let req = RpcRequest {
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: None,
};
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("\"jsonrpc\":\"2.0\""));
assert!(json.contains("\"method\":\"initialize\""));
assert!(!json.contains("params"));
}
#[test]
fn test_rpc_request_with_params_serialization() {
let req = RpcRequest {
jsonrpc: "2.0",
id: 2,
method: "resources/read",
params: Some(serde_json::json!({ "uri": "skill://samtools" })),
};
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("\"uri\""));
assert!(json.contains("skill://samtools"));
}
#[test]
fn test_rpc_response_success_deserialization() {
let json = r#"{"id": 1, "result": {"serverInfo": {"name": "my-server", "version": "1.0"}}, "error": null}"#;
let resp: RpcResponse = serde_json::from_str(json).unwrap();
assert!(resp.result.is_some());
assert!(resp.error.is_none());
let result = resp.result.unwrap();
assert_eq!(result["serverInfo"]["name"], "my-server");
}
#[test]
fn test_rpc_response_error_deserialization() {
let json =
r#"{"id": 1, "result": null, "error": {"code": -32601, "message": "not found"}}"#;
let resp: RpcResponse = serde_json::from_str(json).unwrap();
assert!(resp.result.is_none());
assert!(resp.error.is_some());
let err = resp.error.unwrap();
assert_eq!(err["code"], -32601);
}
#[test]
fn test_rpc_response_no_id_deserialization() {
let json = r#"{"result": {"resources": []}, "error": null}"#;
let resp: RpcResponse = serde_json::from_str(json).unwrap();
assert!(resp.id.is_none());
assert!(resp.result.is_some());
}
#[test]
fn test_mcp_server_config_name_with_name_field() {
let cfg = McpServerConfig {
url: "http://localhost:3000".to_string(),
name: "my-server".to_string(),
api_key: None,
};
assert_eq!(cfg.name(), "my-server");
}
#[test]
fn test_mcp_server_config_name_empty_falls_back_to_url() {
let cfg = McpServerConfig {
url: "http://localhost:3000".to_string(),
name: String::new(),
api_key: None,
};
let n = cfg.name();
assert!(!n.is_empty(), "name() should never return empty string");
}
#[test]
fn test_mcp_client_with_api_key() {
let cfg = McpServerConfig {
url: "https://skills.example.org".to_string(),
name: "secure-server".to_string(),
api_key: Some("mysecretkey".to_string()),
};
let client = McpClient::new(cfg);
assert_eq!(
client.endpoint(),
"https://skills.example.org/mcp",
"endpoint should append /mcp"
);
assert_eq!(
client.config.api_key.as_deref(),
Some("mysecretkey"),
"api_key should be stored"
);
}
#[test]
fn test_mcp_timeout_constant() {
assert_eq!(MCP_TIMEOUT_SECS, 5, "MCP timeout should be 5 seconds");
}
#[cfg(not(target_arch = "wasm32"))]
mod mock_tests {
use super::*;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn make_server_config(url: &str) -> McpServerConfig {
McpServerConfig {
url: url.to_string(),
name: "test-server".to_string(),
api_key: None,
}
}
fn rpc_result(result: serde_json::Value) -> serde_json::Value {
serde_json::json!({"id": 1, "result": result, "error": null})
}
fn rpc_error(msg: &str) -> serde_json::Value {
serde_json::json!({"id": 1, "result": null, "error": {"code": -32601, "message": msg}})
}
#[tokio::test]
async fn test_initialize_success() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/mcp"))
.respond_with(ResponseTemplate::new(200).set_body_json(rpc_result(
serde_json::json!({"serverInfo": {"name": "my-skills", "version": "1.2.3"}}),
)))
.mount(&server)
.await;
let client = McpClient::new(make_server_config(&server.uri()));
let result = client.initialize().await;
assert!(
result.is_ok(),
"initialize should succeed: {:?}",
result.err()
);
let (name, version) = result.unwrap();
assert_eq!(name, "my-skills");
assert_eq!(version, "1.2.3");
}
#[tokio::test]
async fn test_initialize_missing_server_info_uses_url() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/mcp"))
.respond_with(
ResponseTemplate::new(200).set_body_json(rpc_result(serde_json::json!({}))),
)
.mount(&server)
.await;
let client = McpClient::new(make_server_config(&server.uri()));
let result = client.initialize().await;
assert!(result.is_ok());
let (name, version) = result.unwrap();
assert!(!name.is_empty());
assert_eq!(version, "unknown");
}
#[tokio::test]
async fn test_initialize_rpc_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/mcp"))
.respond_with(
ResponseTemplate::new(200).set_body_json(rpc_error("method not found")),
)
.mount(&server)
.await;
let client = McpClient::new(make_server_config(&server.uri()));
let result = client.initialize().await;
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("MCP error") || msg.contains("method not found"),
"should mention MCP error: {msg}"
);
}
#[tokio::test]
async fn test_initialize_http_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/mcp"))
.respond_with(ResponseTemplate::new(503))
.mount(&server)
.await;
let client = McpClient::new(make_server_config(&server.uri()));
let result = client.initialize().await;
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("503") || msg.contains("HTTP"),
"should mention HTTP error: {msg}"
);
}
#[tokio::test]
async fn test_list_skill_resources_skill_uri() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/mcp"))
.respond_with(ResponseTemplate::new(200).set_body_json(rpc_result(
serde_json::json!({
"resources": [
{"uri": "skill://samtools", "name": "samtools", "description": "SAM/BAM"},
{"uri": "skill://bwa", "name": "bwa", "description": "BWA"},
]
}),
)))
.mount(&server)
.await;
let client = McpClient::new(make_server_config(&server.uri()));
let result = client.list_skill_resources().await;
assert!(result.is_ok());
let entries = result.unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].tool, "samtools");
assert_eq!(entries[1].tool, "bwa");
}
#[tokio::test]
async fn test_list_skill_resources_markdown_fallback() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/mcp"))
.respond_with(ResponseTemplate::new(200).set_body_json(rpc_result(
serde_json::json!({
"resources": [
{"uri": "resource://docs/samtools", "name": "samtools",
"description": "samtools docs", "mimeType": "text/markdown"},
]
}),
)))
.mount(&server)
.await;
let client = McpClient::new(make_server_config(&server.uri()));
let result = client.list_skill_resources().await;
assert!(result.is_ok());
let entries = result.unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].tool, "samtools");
}
#[tokio::test]
async fn test_list_skill_resources_empty_when_no_resources_key() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/mcp"))
.respond_with(
ResponseTemplate::new(200).set_body_json(rpc_result(serde_json::json!({}))),
)
.mount(&server)
.await;
let client = McpClient::new(make_server_config(&server.uri()));
let result = client.list_skill_resources().await;
assert!(result.is_ok());
assert!(result.unwrap().is_empty());
}
#[tokio::test]
async fn test_read_resource_success() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/mcp"))
.respond_with(ResponseTemplate::new(200).set_body_json(rpc_result(
serde_json::json!({
"contents": [
{"uri": "skill://samtools", "text": "# samtools skill\n..."}
]
}),
)))
.mount(&server)
.await;
let client = McpClient::new(make_server_config(&server.uri()));
let result = client.read_resource("skill://samtools").await;
assert!(result.is_ok());
let content = result.unwrap();
assert!(content.contains("samtools"));
}
#[tokio::test]
async fn test_read_resource_empty_content_returns_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/mcp"))
.respond_with(ResponseTemplate::new(200).set_body_json(rpc_result(
serde_json::json!({"contents": [{"uri": "skill://samtools", "text": ""}]}),
)))
.mount(&server)
.await;
let client = McpClient::new(make_server_config(&server.uri()));
let result = client.read_resource("skill://samtools").await;
assert!(result.is_err(), "empty text should return error");
}
#[tokio::test]
async fn test_read_resource_no_contents_returns_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/mcp"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(rpc_result(serde_json::json!({"contents": []}))),
)
.mount(&server)
.await;
let client = McpClient::new(make_server_config(&server.uri()));
let result = client.read_resource("skill://samtools").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_fetch_skill_via_canonical_uri() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/mcp"))
.respond_with(ResponseTemplate::new(200).set_body_json(rpc_result(
serde_json::json!({
"contents": [{"uri": "skill://samtools", "text": "# samtools\n## Concepts\n"}]
}),
)))
.mount(&server)
.await;
let client = McpClient::new(make_server_config(&server.uri()));
let result = client.fetch_skill("samtools").await;
assert!(result.is_some(), "should find samtools skill");
assert!(result.unwrap().contains("samtools"));
}
#[tokio::test]
async fn test_fetch_skill_falls_back_to_list_scan() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/mcp"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(rpc_result(serde_json::json!({"contents": []}))),
)
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/mcp"))
.respond_with(ResponseTemplate::new(200).set_body_json(rpc_result(
serde_json::json!({
"resources": [{"uri": "resource://samtools", "name": "samtools", "description": "", "mimeType": "text/markdown"}]
}),
)))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/mcp"))
.respond_with(ResponseTemplate::new(200).set_body_json(rpc_result(
serde_json::json!({
"contents": [{"uri": "resource://samtools", "text": "# samtools content"}]
}),
)))
.mount(&server)
.await;
let client = McpClient::new(make_server_config(&server.uri()));
let result = client.fetch_skill("samtools").await;
let _ = result;
}
#[tokio::test]
async fn test_fetch_skill_not_found_returns_none() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/mcp"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(rpc_result(serde_json::json!({"contents": []}))),
)
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/mcp"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(rpc_result(serde_json::json!({"resources": []}))),
)
.mount(&server)
.await;
let client = McpClient::new(make_server_config(&server.uri()));
let result = client.fetch_skill("nonexistent-tool").await;
assert!(result.is_none());
}
#[tokio::test]
async fn test_send_with_api_key() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/mcp"))
.respond_with(ResponseTemplate::new(200).set_body_json(rpc_result(
serde_json::json!({"serverInfo": {"name": "secure", "version": "1.0"}}),
)))
.mount(&server)
.await;
let cfg = McpServerConfig {
url: server.uri(),
name: "secure-server".to_string(),
api_key: Some("mysecretkey".to_string()),
};
let client = McpClient::new(cfg);
let result = client.initialize().await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_send_connection_refused_returns_error() {
let client = McpClient::new(make_server_config("http://127.0.0.1:1"));
let result = client.initialize().await;
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("unreachable") || msg.contains("MCP") || msg.contains("error"),
"should indicate server unreachable: {msg}"
);
}
}
}