use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
use std::time::Duration;
use anyhow::{Context, anyhow};
use serde_json::{Value, json};
use url::{Host, Url};
#[derive(Debug, Clone)]
pub struct McpHttpClient {
url: String,
client: reqwest::Client,
}
impl McpHttpClient {
pub fn new(url: String, timeout: Duration) -> anyhow::Result<Self> {
let (host, addrs) = validate_url_production(&url)?;
let client = reqwest::Client::builder()
.timeout(timeout)
.resolve_to_addrs(&host, &addrs)
.build()
.context("failed to build HTTP client (TLS backend unavailable)")?;
Ok(Self { url, client })
}
#[cfg(test)]
fn test_new(url: String, timeout: Duration) -> anyhow::Result<Self> {
let client = reqwest::Client::builder()
.timeout(timeout)
.build()
.context("failed to build HTTP client")?;
Ok(Self { url, client })
}
pub async fn call_tool(&self, name: &str, arguments: Value) -> anyhow::Result<String> {
let req = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": { "name": name, "arguments": arguments }
});
let resp = self
.client
.post(&self.url)
.json(&req)
.send()
.await
.with_context(|| format!("POST {} for tool {name}", self.url))?;
let status = resp.status();
if !status.is_success() {
let body = resp
.text()
.await
.unwrap_or_else(|_| "(failed to read error body)".to_string());
return Err(anyhow!("tool {name} returned HTTP {}: {}", status, body));
}
let body: Value = resp
.json()
.await
.with_context(|| format!("decoding JSON response for tool {name}"))?;
if let Some(err) = body.get("error") {
return Err(anyhow!("tool {name} JSON-RPC error: {err}"));
}
extract_text(&body)
.ok_or_else(|| anyhow!("tool {name} response missing result.content[].text"))
}
}
fn extract_text(body: &Value) -> Option<String> {
let content = body.get("result")?.get("content")?.as_array()?;
let mut out = String::new();
for item in content {
if let Some(t) = item.get("text").and_then(Value::as_str) {
if !out.is_empty() {
out.push('\n');
}
out.push_str(t);
}
}
Some(out)
}
fn blocked_reason(ip: &IpAddr) -> Option<&'static str> {
match ip {
IpAddr::V4(v4) => {
if v4.is_loopback() {
Some("loopback IPv4")
} else if v4.is_private() {
Some("private IPv4")
} else if v4.is_link_local() {
Some("link-local IPv4")
} else if v4.is_unspecified() {
Some("unspecified IPv4")
} else if v4.is_broadcast() {
Some("broadcast IPv4")
} else {
None
}
}
IpAddr::V6(v6) => {
if let Some(mapped) = v6.to_ipv4_mapped() {
return blocked_reason(&IpAddr::V4(mapped));
}
if v6.is_loopback() {
Some("loopback IPv6")
} else if v6.is_unspecified() {
Some("unspecified IPv6")
} else if v6.is_unicast_link_local() {
Some("link-local IPv6")
} else if is_unique_local_v6(v6) {
Some("unique-local IPv6 (ULA)")
} else {
None
}
}
}
}
fn is_unique_local_v6(v6: &std::net::Ipv6Addr) -> bool {
(v6.octets()[0] & 0xfe) == 0xfc
}
fn validate_url_production(url: &str) -> anyhow::Result<(String, Vec<SocketAddr>)> {
let parsed = Url::parse(url).with_context(|| format!("invalid URL: {url}"))?;
if !matches!(parsed.scheme(), "http" | "https") {
return Err(anyhow!(
"unsupported URL scheme '{}' (only http/https allowed)",
parsed.scheme()
));
}
let host = parsed
.host()
.ok_or_else(|| anyhow!("URL {url} has no host"))?;
let host_key = match host {
Host::Ipv4(v4) => v4.to_string(),
Host::Ipv6(v6) => v6.to_string(),
Host::Domain(name) => name.to_string(),
};
let port = parsed
.port_or_known_default()
.ok_or_else(|| anyhow!("URL {url} has no port and an unknown default"))?;
let addrs: Vec<SocketAddr> = match host {
Host::Ipv4(v4) => vec![SocketAddr::new(IpAddr::V4(v4), port)],
Host::Ipv6(v6) => vec![SocketAddr::new(IpAddr::V6(v6), port)],
Host::Domain(name) => (name, port)
.to_socket_addrs()
.with_context(|| format!("failed to resolve host '{name}'"))?
.collect(),
};
if addrs.is_empty() {
return Err(anyhow!("host of URL {url} resolved to no addresses"));
}
for addr in &addrs {
if let Some(reason) = blocked_reason(&addr.ip()) {
return Err(anyhow!("{reason} address {} not allowed (SSRF)", addr.ip()));
}
}
Ok((host_key, addrs))
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn call_tool_returns_text_content() {
let server = MockServer::start().await;
let body = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [{ "type": "text", "text": "wake-up pack" }]
}
});
Mock::given(method("POST"))
.and(path("/"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let client =
McpHttpClient::test_new(server.uri(), Duration::from_secs(2)).expect("valid URL");
let text = client
.call_tool("icm_wake_up", serde_json::json!({}))
.await
.expect("call_tool ok");
assert_eq!(text, "wake-up pack");
}
#[tokio::test]
async fn call_tool_errors_on_unreachable() {
let client = McpHttpClient::new("http://8.8.8.8:0".to_string(), Duration::from_millis(200))
.expect("valid URL");
let result = client.call_tool("icm_wake_up", serde_json::json!({})).await;
assert!(result.is_err());
}
#[test]
fn validate_url_rejects_loopback() {
assert!(validate_url_production("http://127.0.0.1:8080").is_err());
assert!(validate_url_production("http://[::1]:8080").is_err());
}
#[test]
fn validate_url_rejects_private_ips() {
assert!(validate_url_production("http://10.0.0.1:8080").is_err());
assert!(validate_url_production("http://192.168.1.1:8080").is_err());
assert!(validate_url_production("http://172.16.0.1:8080").is_err());
}
#[test]
fn validate_url_accepts_public_ips() {
assert!(validate_url_production("http://8.8.8.8:8080").is_ok());
assert!(validate_url_production("https://example.com:8080").is_ok());
}
#[test]
fn validate_url_rejects_ipv6_ula() {
assert!(validate_url_production("http://[fc00::1]:8080").is_err());
assert!(validate_url_production("http://[fd00::1]:8080").is_err());
assert!(validate_url_production("http://[fd12:3456:789a::1]:8080").is_err());
}
#[test]
fn validate_url_accepts_public_ipv6() {
assert!(validate_url_production("http://[2001:db8::1]:8080").is_ok());
assert!(validate_url_production("http://[2606:4700:4700::1111]:8080").is_ok());
}
#[test]
fn validate_url_rejects_ipv4_mapped_ipv6_loopback() {
assert!(validate_url_production("http://[::ffff:127.0.0.1]:8080").is_err());
assert!(validate_url_production("http://[::ffff:169.254.169.254]:8080").is_err());
assert!(validate_url_production("http://[::ffff:10.0.0.1]:8080").is_err());
}
#[test]
fn validate_url_rejects_unspecified_and_metadata() {
assert!(validate_url_production("http://0.0.0.0:8080").is_err());
assert!(validate_url_production("http://[::]:8080").is_err());
assert!(validate_url_production("http://169.254.169.254:8080").is_err());
}
#[test]
fn blocked_reason_flags_private_and_special_ranges() {
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
let blocked: &[IpAddr] = &[
IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)),
IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)),
IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)),
IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1)),
IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254)),
IpAddr::V4(Ipv4Addr::UNSPECIFIED),
IpAddr::V6(Ipv6Addr::LOCALHOST),
IpAddr::V6(Ipv6Addr::UNSPECIFIED),
IpAddr::V6(Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 1)),
IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 1)),
IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1)),
];
for ip in blocked {
assert!(blocked_reason(ip).is_some(), "expected {ip} to be blocked");
}
let allowed: &[IpAddr] = &[
IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)),
IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34)),
IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1)),
IpAddr::V6(Ipv6Addr::new(0x2606, 0x4700, 0x4700, 0, 0, 0, 0, 0x1111)),
];
for ip in allowed {
assert!(blocked_reason(ip).is_none(), "expected {ip} to be allowed");
}
}
#[test]
fn validate_url_returns_pinned_addrs_for_literal_ip() {
let (host, addrs) = validate_url_production("http://8.8.8.8:8080").expect("public IP ok");
assert_eq!(host, "8.8.8.8");
assert!(
addrs
.iter()
.any(|a| a.ip().to_string() == "8.8.8.8" && a.port() == 8080)
);
}
#[test]
fn validate_url_returns_unbracketed_host_for_literal_ipv6() {
let (host, addrs) =
validate_url_production("http://[2606:4700:4700::1111]:8080").expect("public IPv6 ok");
assert_eq!(host, "2606:4700:4700::1111");
assert!(addrs.iter().any(|a| a.port() == 8080));
}
#[test]
fn validate_url_rejects_domain_resolving_to_loopback() {
assert!(validate_url_production("http://localhost:8080").is_err());
}
#[test]
fn validate_url_rejects_unsupported_schemes() {
assert!(validate_url_production("file:///tmp/socket").is_err());
assert!(validate_url_production("ftp://example.com").is_err());
}
#[tokio::test]
async fn call_tool_errors_on_jsonrpc_error() {
let server = MockServer::start().await;
let body = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"error": { "code": -32000, "message": "boom" }
});
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let client =
McpHttpClient::test_new(server.uri(), Duration::from_secs(2)).expect("valid URL");
let result = client.call_tool("icm_wake_up", serde_json::json!({})).await;
assert!(result.is_err());
}
#[test]
fn extract_text_handles_missing_result() {
let body = serde_json::json!({
"jsonrpc": "2.0",
"id": 1
});
assert_eq!(extract_text(&body), None);
}
#[test]
fn extract_text_handles_missing_content() {
let body = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"result": { "other_field": "data" }
});
assert_eq!(extract_text(&body), None);
}
#[test]
fn extract_text_handles_non_array_content() {
let body = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"result": { "content": "not an array" }
});
assert_eq!(extract_text(&body), None);
}
#[test]
fn extract_text_concatenates_multiple_text_items() {
let body = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [
{ "type": "text", "text": "first" },
{ "type": "text", "text": "second" },
{ "type": "text", "text": "third" }
]
}
});
assert_eq!(
extract_text(&body),
Some("first\nsecond\nthird".to_string())
);
}
#[test]
fn extract_text_skips_non_text_items() {
let body = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [
{ "type": "text", "text": "a" },
{ "type": "image", "url": "https://example.com/img.png" },
{ "type": "text", "text": "b" }
]
}
});
assert_eq!(extract_text(&body), Some("a\nb".to_string()));
}
#[test]
fn extract_text_handles_missing_text_field() {
let body = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [
{ "type": "text" },
{ "type": "text", "text": "valid" }
]
}
});
assert_eq!(extract_text(&body), Some("valid".to_string()));
}
#[test]
fn extract_text_handles_empty_content_array() {
let body = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"result": { "content": [] }
});
assert_eq!(extract_text(&body), Some(String::new()));
}
use proptest::prelude::*;
proptest! {
#[test]
fn prop_blocked_reason_never_panics(octets in any::<[u8; 16]>(), v4 in any::<[u8; 4]>()) {
let _ = blocked_reason(&IpAddr::V6(std::net::Ipv6Addr::from(octets)));
let _ = blocked_reason(&IpAddr::V4(std::net::Ipv4Addr::from(v4)));
}
#[test]
fn prop_is_unique_local_v6_matches_fc00_slash_7(octets in any::<[u8; 16]>()) {
let v6 = std::net::Ipv6Addr::from(octets);
let expected = matches!(octets[0], 0xfc | 0xfd);
prop_assert_eq!(is_unique_local_v6(&v6), expected);
}
#[test]
fn prop_ula_v6_always_blocked(first in prop_oneof![Just(0xfcu8), Just(0xfdu8)], rest in any::<[u8; 15]>()) {
let mut octets = [0u8; 16];
octets[0] = first;
octets[1..].copy_from_slice(&rest);
let v6 = std::net::Ipv6Addr::from(octets);
prop_assert!(blocked_reason(&IpAddr::V6(v6)).is_some());
}
#[test]
fn prop_ipv4_mapped_v6_judged_as_v4(v4 in any::<[u8; 4]>()) {
let v4_addr = std::net::Ipv4Addr::from(v4);
let mapped = v4_addr.to_ipv6_mapped();
prop_assert_eq!(
blocked_reason(&IpAddr::V6(mapped)).is_some(),
blocked_reason(&IpAddr::V4(v4_addr)).is_some()
);
}
}
}