//! HTTP MCP server module. use crate::http::context::AppContext; use poem::handler; use poem::http::StatusCode; use poem::web::Data; use poem::{Body, Request, Response}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use std::sync::Arc; pub mod agent_tools; pub mod diagnostics; pub mod git_tools; pub mod merge_tools; pub mod qa_tools; pub mod shell_tools; pub mod status_tools; pub mod story_tools; pub mod wizard_tools; mod dispatch; mod tools_list; use dispatch::handle_tools_call; use tools_list::handle_tools_list; /// Returns true when the Accept header includes text/event-stream. fn wants_sse(req: &Request) -> bool { req.header("accept") .unwrap_or("") .contains("text/event-stream") } // ── JSON-RPC structs ────────────────────────────────────────────── #[derive(Deserialize)] struct JsonRpcRequest { jsonrpc: String, id: Option, method: String, #[serde(default)] params: Value, } #[derive(Serialize)] pub(super) struct JsonRpcResponse { jsonrpc: &'static str, #[serde(skip_serializing_if = "Option::is_none")] id: Option, #[serde(skip_serializing_if = "Option::is_none")] result: Option, #[serde(skip_serializing_if = "Option::is_none")] error: Option, } #[derive(Serialize)] struct JsonRpcError { code: i64, message: String, #[serde(skip_serializing_if = "Option::is_none")] data: Option, } impl JsonRpcResponse { pub(super) fn success(id: Option, result: Value) -> Self { Self { jsonrpc: "2.0", id, result: Some(result), error: None, } } pub(super) fn error(id: Option, code: i64, message: String) -> Self { Self { jsonrpc: "2.0", id, result: None, error: Some(JsonRpcError { code, message, data: None, }), } } } // ── Poem handlers ───────────────────────────────────────────────── #[handler] pub async fn mcp_get_handler() -> Response { Response::builder() .status(StatusCode::METHOD_NOT_ALLOWED) .body(Body::empty()) } #[handler] pub async fn mcp_post_handler(req: &Request, body: Body, ctx: Data<&Arc>) -> Response { // Validate Content-Type let content_type = req.header("content-type").unwrap_or(""); if !content_type.is_empty() && !content_type.contains("application/json") { return json_rpc_error_response( None, -32700, "Unsupported Content-Type; expected application/json".into(), ); } let bytes = match body.into_bytes().await { Ok(b) => b, Err(_) => return json_rpc_error_response(None, -32700, "Parse error".into()), }; let rpc: JsonRpcRequest = match serde_json::from_slice(&bytes) { Ok(r) => r, Err(_) => return json_rpc_error_response(None, -32700, "Parse error".into()), }; if rpc.jsonrpc != "2.0" { return json_rpc_error_response(rpc.id, -32600, "Invalid JSON-RPC version".into()); } // Notifications (no id) — accept silently if rpc.id.is_none() || rpc.id.as_ref() == Some(&Value::Null) { if rpc.method.starts_with("notifications/") { return Response::builder() .status(StatusCode::ACCEPTED) .body(Body::empty()); } return json_rpc_error_response(None, -32600, "Missing id".into()); } let sse = wants_sse(req); // Streaming agent output over SSE if sse && rpc.method == "tools/call" { let tool_name = rpc .params .get("name") .and_then(|v| v.as_str()) .unwrap_or(""); if tool_name == "run_command" { return shell_tools::handle_run_command_sse(rpc.id, &rpc.params, &ctx); } } let resp = match rpc.method.as_str() { "initialize" => handle_initialize(rpc.id, &rpc.params), "tools/list" => handle_tools_list(rpc.id), "tools/call" => handle_tools_call(rpc.id, &rpc.params, &ctx).await, _ => JsonRpcResponse::error(rpc.id, -32601, format!("Unknown method: {}", rpc.method)), }; if sse { to_sse_response(resp) } else { to_json_response(resp) } } fn json_rpc_error_response(id: Option, code: i64, message: String) -> Response { to_json_response(JsonRpcResponse::error(id, code, message)) } fn to_json_response(resp: JsonRpcResponse) -> Response { let body = serde_json::to_vec(&resp).unwrap_or_default(); Response::builder() .status(StatusCode::OK) .header("Content-Type", "application/json") .body(Body::from(body)) } pub(super) fn to_sse_response(resp: JsonRpcResponse) -> Response { let json = serde_json::to_string(&resp).unwrap_or_default(); let sse_body = format!("data: {json}\n\n"); Response::builder() .status(StatusCode::OK) .header("Content-Type", "text/event-stream") .header("Cache-Control", "no-cache") .body(Body::from_string(sse_body)) } // ── MCP protocol handlers ───────────────────────────────────────── fn handle_initialize(id: Option, params: &Value) -> JsonRpcResponse { let _protocol_version = params .get("protocolVersion") .and_then(|v| v.as_str()) .unwrap_or("2025-03-26"); JsonRpcResponse::success( id, json!({ "protocolVersion": "2025-03-26", "capabilities": { "tools": {} }, "serverInfo": { "name": "huskies", "version": "1.0.0" } }), ) } #[cfg(test)] mod tests { use super::*; use crate::http::test_helpers::test_ctx; #[test] fn json_rpc_response_serializes_success() { let resp = JsonRpcResponse::success(Some(json!(1)), json!({"ok": true})); let s = serde_json::to_string(&resp).unwrap(); assert!(s.contains("\"result\"")); assert!(!s.contains("\"error\"")); } #[test] fn json_rpc_response_serializes_error() { let resp = JsonRpcResponse::error(Some(json!(1)), -32600, "bad".into()); let s = serde_json::to_string(&resp).unwrap(); assert!(s.contains("\"error\"")); assert!(!s.contains("\"result\"")); } #[test] fn initialize_returns_capabilities() { let resp = handle_initialize( Some(json!(1)), &json!({"protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "test", "version": "1.0"}}), ); let result = resp.result.unwrap(); assert_eq!(result["protocolVersion"], "2025-03-26"); assert!(result["capabilities"]["tools"].is_object()); assert_eq!(result["serverInfo"]["name"], "huskies"); } #[test] fn to_sse_response_wraps_in_data_prefix() { let resp = JsonRpcResponse::success(Some(json!(1)), json!({"ok": true})); let http_resp = to_sse_response(resp); assert_eq!( http_resp.headers().get("content-type").unwrap(), "text/event-stream" ); } #[test] fn wants_sse_detects_accept_header() { // Can't easily construct a Request in tests without TestClient, // so test the logic indirectly via to_sse_response format let resp = JsonRpcResponse::success(Some(json!(1)), json!("ok")); let json_resp = to_json_response(resp); assert_eq!( json_resp.headers().get("content-type").unwrap(), "application/json" ); } #[test] fn json_rpc_error_response_builds_json_response() { let resp = json_rpc_error_response(Some(json!(42)), -32600, "test error".into()); assert_eq!(resp.status(), poem::http::StatusCode::OK); assert_eq!( resp.headers().get("content-type").unwrap(), "application/json" ); } // ── HTTP handler tests (TestClient) ─────────────────────────── fn test_mcp_app(ctx: std::sync::Arc) -> impl poem::Endpoint { use poem::EndpointExt; poem::Route::new() .at("/mcp", poem::post(mcp_post_handler).get(mcp_get_handler)) .data(ctx) } async fn read_body_json(resp: poem::test::TestResponse) -> Value { let body = resp.0.into_body().into_string().await.unwrap(); serde_json::from_str(&body).unwrap() } async fn post_json_mcp( cli: &poem::test::TestClient, payload: &str, ) -> Value { let resp = cli .post("/mcp") .header("content-type", "application/json") .body(payload.to_string()) .send() .await; read_body_json(resp).await } #[tokio::test] async fn mcp_get_handler_returns_405() { let tmp = tempfile::tempdir().unwrap(); let ctx = std::sync::Arc::new(test_ctx(tmp.path())); let cli = poem::test::TestClient::new(test_mcp_app(ctx)); let resp = cli.get("/mcp").send().await; assert_eq!(resp.0.status(), poem::http::StatusCode::METHOD_NOT_ALLOWED); } #[tokio::test] async fn mcp_post_invalid_content_type_returns_error() { let tmp = tempfile::tempdir().unwrap(); let ctx = std::sync::Arc::new(test_ctx(tmp.path())); let cli = poem::test::TestClient::new(test_mcp_app(ctx)); let resp = cli .post("/mcp") .header("content-type", "text/plain") .body("{}") .send() .await; let body = read_body_json(resp).await; assert!(body.get("error").is_some(), "expected error field: {body}"); } #[tokio::test] async fn mcp_post_invalid_json_returns_parse_error() { let tmp = tempfile::tempdir().unwrap(); let ctx = std::sync::Arc::new(test_ctx(tmp.path())); let cli = poem::test::TestClient::new(test_mcp_app(ctx)); let resp = cli .post("/mcp") .header("content-type", "application/json") .body("not-valid-json") .send() .await; let body = read_body_json(resp).await; assert!(body.get("error").is_some(), "expected error field: {body}"); } #[tokio::test] async fn mcp_post_wrong_jsonrpc_version_returns_error() { let tmp = tempfile::tempdir().unwrap(); let ctx = std::sync::Arc::new(test_ctx(tmp.path())); let cli = poem::test::TestClient::new(test_mcp_app(ctx)); let body = post_json_mcp( &cli, r#"{"jsonrpc":"1.0","id":1,"method":"initialize","params":{}}"#, ) .await; assert!( body["error"]["message"] .as_str() .unwrap_or("") .contains("version"), "expected version error: {body}" ); } #[tokio::test] async fn mcp_post_notification_with_null_id_returns_accepted() { let tmp = tempfile::tempdir().unwrap(); let ctx = std::sync::Arc::new(test_ctx(tmp.path())); let cli = poem::test::TestClient::new(test_mcp_app(ctx)); let resp = cli .post("/mcp") .header("content-type", "application/json") .body(r#"{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}"#) .send() .await; assert_eq!(resp.0.status(), poem::http::StatusCode::ACCEPTED); } #[tokio::test] async fn mcp_post_notification_with_explicit_null_id_returns_accepted() { let tmp = tempfile::tempdir().unwrap(); let ctx = std::sync::Arc::new(test_ctx(tmp.path())); let cli = poem::test::TestClient::new(test_mcp_app(ctx)); let resp = cli .post("/mcp") .header("content-type", "application/json") .body(r#"{"jsonrpc":"2.0","id":null,"method":"notifications/initialized","params":{}}"#) .send() .await; assert_eq!(resp.0.status(), poem::http::StatusCode::ACCEPTED); } #[tokio::test] async fn mcp_post_missing_id_non_notification_returns_error() { let tmp = tempfile::tempdir().unwrap(); let ctx = std::sync::Arc::new(test_ctx(tmp.path())); let cli = poem::test::TestClient::new(test_mcp_app(ctx)); let body = post_json_mcp( &cli, r#"{"jsonrpc":"2.0","method":"initialize","params":{}}"#, ) .await; assert!(body.get("error").is_some(), "expected error: {body}"); } #[tokio::test] async fn mcp_post_unknown_method_returns_error() { let tmp = tempfile::tempdir().unwrap(); let ctx = std::sync::Arc::new(test_ctx(tmp.path())); let cli = poem::test::TestClient::new(test_mcp_app(ctx)); let body = post_json_mcp( &cli, r#"{"jsonrpc":"2.0","id":1,"method":"bogus/method","params":{}}"#, ) .await; assert!( body["error"]["message"] .as_str() .unwrap_or("") .contains("Unknown method"), "expected unknown method error: {body}" ); } #[tokio::test] async fn mcp_post_initialize_returns_capabilities() { let tmp = tempfile::tempdir().unwrap(); let ctx = std::sync::Arc::new(test_ctx(tmp.path())); let cli = poem::test::TestClient::new(test_mcp_app(ctx)); let body = post_json_mcp( &cli, r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"#, ) .await; assert_eq!(body["result"]["protocolVersion"], "2025-03-26"); assert_eq!(body["result"]["serverInfo"]["name"], "huskies"); } #[tokio::test] async fn mcp_post_tools_list_returns_tools() { let tmp = tempfile::tempdir().unwrap(); let ctx = std::sync::Arc::new(test_ctx(tmp.path())); let cli = poem::test::TestClient::new(test_mcp_app(ctx)); let body = post_json_mcp( &cli, r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}"#, ) .await; assert!(body["result"]["tools"].is_array()); } #[tokio::test] async fn mcp_post_sse_returns_event_stream_content_type() { let tmp = tempfile::tempdir().unwrap(); let ctx = std::sync::Arc::new(test_ctx(tmp.path())); let cli = poem::test::TestClient::new(test_mcp_app(ctx)); let resp = cli .post("/mcp") .header("content-type", "application/json") .header("accept", "text/event-stream") .body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}"#) .send() .await; assert_eq!( resp.0.headers().get("content-type").unwrap(), "text/event-stream" ); } #[tokio::test] async fn mcp_post_sse_get_agent_output_missing_story_id() { let tmp = tempfile::tempdir().unwrap(); let ctx = std::sync::Arc::new(test_ctx(tmp.path())); let cli = poem::test::TestClient::new(test_mcp_app(ctx)); let resp = cli .post("/mcp") .header("content-type", "application/json") .header("accept", "text/event-stream") .body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_agent_output","arguments":{}}}"#) .send() .await; assert_eq!( resp.0.headers().get("content-type").unwrap(), "text/event-stream", "expected SSE content-type" ); } #[tokio::test] async fn mcp_post_sse_get_agent_output_without_agent_name_returns_disk_content() { // Without agent_name the SSE live-streaming intercept is skipped and // the disk-based handler runs. The transport still wraps the result in // SSE format (data: …\n\n) because the client sent Accept: text/event-stream, // but the content should be a valid JSON-RPC result, not a subscribe error. let tmp = tempfile::tempdir().unwrap(); let ctx = std::sync::Arc::new(test_ctx(tmp.path())); let cli = poem::test::TestClient::new(test_mcp_app(ctx)); let resp = cli .post("/mcp") .header("content-type", "application/json") .header("accept", "text/event-stream") .body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_agent_output","arguments":{"story_id":"1_test"}}}"#) .send() .await; let body = resp.0.into_body().into_string().await.unwrap(); // Body is SSE-wrapped: "data: {…}\n\n" — strip the prefix and verify it's // a valid JSON-RPC result (not an error about missing agent_name). let json_part = body .trim_start_matches("data: ") .trim_end_matches("\n\n") .trim(); let parsed: serde_json::Value = serde_json::from_str(json_part) .unwrap_or_else(|_| panic!("expected JSON-RPC in SSE body, got: {body}")); assert!( parsed.get("result").is_some(), "expected JSON-RPC result (disk-based handler ran): {parsed}" ); // Must NOT be an error about missing agent_name (agent_name is now optional) assert!( parsed.get("error").is_none(), "unexpected error when agent_name omitted: {parsed}" ); } #[tokio::test] async fn mcp_post_sse_get_agent_output_no_agent_no_logs_returns_not_found() { // Agent not in pool and no log files → SSE success with "No log files found" message. let tmp = tempfile::tempdir().unwrap(); let ctx = std::sync::Arc::new(test_ctx(tmp.path())); let cli = poem::test::TestClient::new(test_mcp_app(ctx)); let resp = cli .post("/mcp") .header("content-type", "application/json") .header("accept", "text/event-stream") .body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_agent_output","arguments":{"story_id":"99_nope","agent_name":"bot"}}}"#) .send() .await; assert_eq!( resp.0.headers().get("content-type").unwrap(), "text/event-stream" ); let body = resp.0.into_body().into_string().await.unwrap(); assert!(body.contains("data:"), "expected SSE data prefix: {body}"); // Must NOT return isError — should be a success result with "No log files found" assert!( !body.contains("isError"), "expected no isError for missing agent: {body}" ); assert!( body.contains("No log files found"), "expected not-found message: {body}" ); } #[tokio::test] async fn mcp_post_sse_get_agent_output_exited_agent_reads_disk_logs() { use crate::agent_log::AgentLogWriter; use crate::agents::AgentEvent; // Agent has exited (not in pool) but wrote logs to disk. let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let mut writer = AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-sse").unwrap(); writer .write_event(&AgentEvent::Output { story_id: "42_story_foo".to_string(), agent_name: "coder-1".to_string(), text: "disk output".to_string(), }) .unwrap(); drop(writer); let ctx = std::sync::Arc::new(test_ctx(root)); let cli = poem::test::TestClient::new(test_mcp_app(ctx)); let resp = cli .post("/mcp") .header("content-type", "application/json") .header("accept", "text/event-stream") .body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_agent_output","arguments":{"story_id":"42_story_foo","agent_name":"coder-1"}}}"#) .send() .await; let body = resp.0.into_body().into_string().await.unwrap(); assert!( body.contains("disk output"), "expected disk log content in SSE response: {body}" ); assert!( !body.contains("isError"), "expected no error for exited agent with logs: {body}" ); } }