use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use agentd::mcp::client::McpClient;
use serde_json::{Value, json};
#[derive(Default)]
struct Seen {
session_ids: Vec<Option<String>>,
protocol_versions: Vec<Option<String>>,
methods: Vec<String>,
}
#[derive(Clone)]
struct HttpReq {
session_id: Option<String>,
protocol_version: Option<String>,
body: Value,
}
fn read_http_request(stream: &TcpStream) -> Option<HttpReq> {
let mut reader = BufReader::new(stream.try_clone().ok()?);
let mut line = String::new();
if reader.read_line(&mut line).ok()? == 0 {
return None;
}
let mut content_length = 0usize;
let mut session_id = None;
let mut protocol_version = None;
loop {
let mut h = String::new();
if reader.read_line(&mut h).ok()? == 0 {
break;
}
let h = h.trim_end();
if h.is_empty() {
break;
}
if let Some((k, v)) = h.split_once(':') {
let key = k.trim().to_ascii_lowercase();
let val = v.trim().to_string();
match key.as_str() {
"content-length" => content_length = val.parse().unwrap_or(0),
"mcp-session-id" => session_id = Some(val),
"mcp-protocol-version" => protocol_version = Some(val),
_ => {}
}
}
}
let mut buf = vec![0u8; content_length];
reader.read_exact(&mut buf).ok()?;
let body: Value = serde_json::from_slice(&buf).ok()?;
Some(HttpReq {
session_id,
protocol_version,
body,
})
}
fn write_json(stream: &mut TcpStream, extra_header: &str, payload: &Value) {
let body = serde_json::to_vec(payload).unwrap();
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n{extra_header}Content-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
let _ = stream.write_all(resp.as_bytes());
let _ = stream.write_all(&body);
let _ = stream.flush();
}
fn write_sse(stream: &mut TcpStream, notification: &Value, response: &Value) {
let head = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n";
let _ = stream.write_all(head.as_bytes());
let _ = stream.write_all(format!("data: {notification}\n\n").as_bytes());
let _ = stream.write_all(format!("data: {response}\n\n").as_bytes());
let _ = stream.flush();
}
fn accepted_notification(uri: &str) -> Value {
json!({
"jsonrpc": "2.0",
"method": "notifications/resources/updated",
"params": {"uri": uri}
})
}
fn spawn_mock() -> (String, Arc<Mutex<Seen>>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let endpoint = format!("http://127.0.0.1:{port}/mcp");
let seen = Arc::new(Mutex::new(Seen::default()));
let seen_thread = Arc::clone(&seen);
let uri = "mock://res";
thread::spawn(move || {
for conn in listener.incoming() {
let mut stream = match conn {
Ok(s) => s,
Err(_) => continue,
};
let Some(req) = read_http_request(&stream) else {
continue;
};
let method = req.body["method"].as_str().unwrap_or("").to_string();
let id = req.body.get("id").cloned().unwrap_or(Value::Null);
{
let mut g = seen_thread.lock().unwrap();
g.session_ids.push(req.session_id.clone());
g.protocol_versions.push(req.protocol_version.clone());
g.methods.push(method.clone());
}
match method.as_str() {
"initialize" => {
let payload = json!({
"jsonrpc": "2.0", "id": id,
"result": {
"protocolVersion": "2025-06-18",
"capabilities": {"resources": {"subscribe": true, "listChanged": true}, "tools": {}},
"serverInfo": {"name": "mock-http", "version": "0"}
}
});
write_json(&mut stream, "Mcp-Session-Id: sess-1\r\n", &payload);
}
"notifications/initialized" => {
let _ = stream.write_all(
b"HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
);
}
"tools/list" => {
let payload = json!({
"jsonrpc": "2.0", "id": id,
"result": {"tools": [{"name": "echo", "description": "echo", "inputSchema": {"type": "object"}}]}
});
write_json(&mut stream, "", &payload);
}
"tools/call" => {
let response = json!({
"jsonrpc": "2.0", "id": id,
"result": {"content": [{"type": "text", "text": "pong"}], "isError": false}
});
write_sse(&mut stream, &accepted_notification(uri), &response);
}
"resources/read" => {
let payload = json!({
"jsonrpc": "2.0", "id": id,
"result": {"contents": [{"uri": uri, "mimeType": "text/plain", "text": "hello"}]}
});
write_json(&mut stream, "", &payload);
}
"resources/subscribe" => {
let payload = json!({"jsonrpc": "2.0", "id": id, "result": {}});
write_json(&mut stream, "", &payload);
}
_ => {
let payload = json!({
"jsonrpc": "2.0", "id": id,
"error": {"code": -32601, "message": "method not found"}
});
write_json(&mut stream, "", &payload);
}
}
}
});
(endpoint, seen)
}
#[test]
fn streamable_http_full_lifecycle() {
let (endpoint, seen) = spawn_mock();
let mut client =
McpClient::connect("mock", &endpoint, Vec::new(), Duration::from_secs(5)).expect("connect");
client.initialize().expect("initialize handshake");
assert_eq!(
client.protocol_version(),
Some("2025-06-18"),
"the client adopts the version the server negotiated"
);
assert!(client.capabilities().supports_tools(), "tools advertised");
assert!(
client.capabilities().supports_subscribe(),
"resources.subscribe advertised"
);
let tools = client.list_tools().expect("tools/list");
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].name, "echo");
let result = client
.call_tool("echo", Some(json!({"msg": "hi"})))
.expect("tools/call");
assert!(!result.is_error(), "call is not a tool-domain error");
let notes = client.drain_notifications();
assert_eq!(notes.len(), 1, "one resources/updated captured");
assert_eq!(notes[0].method, "notifications/resources/updated");
let read = client.read_resource("mock://res").expect("resources/read");
assert_eq!(read.contents.len(), 1);
let g = seen.lock().unwrap();
let init_idx = g.methods.iter().position(|m| m == "initialize").unwrap();
assert_eq!(
g.protocol_versions[init_idx], None,
"the initialize request must NOT carry MCP-Protocol-Version"
);
for (i, method) in g.methods.iter().enumerate() {
if i > init_idx {
assert_eq!(
g.session_ids[i].as_deref(),
Some("sess-1"),
"request #{i} ({method}) must echo the session id"
);
assert_eq!(
g.protocol_versions[i].as_deref(),
Some("2025-06-18"),
"request #{i} ({method}) must carry the negotiated MCP-Protocol-Version"
);
}
}
}
#[test]
fn notification_get_stream_delivers_server_pushes() {
let addr_file = format!(
"/tmp/agentd-mcp-notify-{}-{}.addr",
std::process::id(),
line!()
);
let addr_file_thread = addr_file.clone();
std::thread::spawn(move || {
agentd::mcp::mock_http::run(&addr_file_thread, "mock://res", true);
});
let deadline = std::time::Instant::now() + Duration::from_secs(3);
while !std::path::Path::new(&addr_file).exists() {
assert!(
std::time::Instant::now() < deadline,
"mock never announced its address"
);
std::thread::sleep(Duration::from_millis(10));
}
let addr = std::fs::read_to_string(&addr_file).expect("read mock addr-file");
let mut client = McpClient::connect(
"mock",
&format!("http://{}", addr.trim()),
Vec::new(),
Duration::from_secs(5),
)
.expect("connect");
client.initialize().expect("initialize");
assert!(client.capabilities().supports_subscribe());
client.subscribe("mock://res").expect("subscribe");
let deadline = std::time::Instant::now() + Duration::from_secs(3);
let mut got = Vec::new();
while got.is_empty() && std::time::Instant::now() < deadline {
got = client.drain_notifications();
if got.is_empty() {
std::thread::sleep(Duration::from_millis(20));
}
}
assert_eq!(
got.len(),
1,
"one resources/updated pushed over the GET stream"
);
assert_eq!(got[0].method, "notifications/resources/updated");
drop(client);
let _ = std::fs::remove_file(&addr_file);
}
#[test]
fn client_prompts_and_completions() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let endpoint = format!("http://127.0.0.1:{port}/mcp");
thread::spawn(move || {
for conn in listener.incoming() {
let Ok(mut stream) = conn else { continue };
let Some(req) = read_http_request(&stream) else {
continue;
};
let method = req.body["method"].as_str().unwrap_or("");
let id = req.body.get("id").cloned().unwrap_or(Value::Null);
let payload = match method {
"initialize" => json!({"jsonrpc":"2.0","id":id,"result":{
"protocolVersion":"2025-11-25",
"capabilities":{"prompts":{},"completions":{}},
"serverInfo":{"name":"p","version":"0"}}}),
"prompts/list" => json!({"jsonrpc":"2.0","id":id,"result":{
"prompts":[{"name":"greet","arguments":[{"name":"who","required":true}]}]}}),
"prompts/get" => json!({"jsonrpc":"2.0","id":id,"result":{
"description":"greeting",
"messages":[{"role":"user","content":{"type":"text","text":"Hello!"}}]}}),
"completion/complete" => json!({"jsonrpc":"2.0","id":id,"result":{
"completion":{"values":["alice","alan"],"hasMore":false}}}),
_ => json!({"jsonrpc":"2.0","id":id,"error":{"code":-32601,"message":"nope"}}),
};
write_json(&mut stream, "", &payload);
}
});
let mut client =
McpClient::connect("p", &endpoint, Vec::new(), Duration::from_secs(5)).expect("connect");
client.initialize().expect("initialize");
assert!(client.capabilities().supports_prompts());
assert!(client.capabilities().supports_completions());
let prompts = client.list_prompts().expect("prompts/list");
assert_eq!(prompts.len(), 1);
assert_eq!(prompts[0].name, "greet");
assert_eq!(prompts[0].arguments[0].required, Some(true));
let got = client
.get_prompt("greet", Some(json!({"who": "world"})))
.expect("prompts/get");
assert_eq!(got.messages.len(), 1);
assert_eq!(got.description.as_deref(), Some("greeting"));
let comp = client
.complete(
json!({"type": "ref/prompt", "name": "greet"}),
json!({"name": "who", "value": "al"}),
)
.expect("completion/complete");
assert_eq!(comp.completion.values, ["alice", "alan"]);
}
#[test]
fn connect_to_dead_endpoint_surfaces_transport_error() {
let mut client = McpClient::connect(
"dead",
"http://127.0.0.1:1/mcp",
Vec::new(),
Duration::from_millis(500),
)
.expect("connect is lazy — no dial yet");
let err = client.initialize();
assert!(
err.is_err(),
"initialize against a dead endpoint must error"
);
}