mod common; use common::http_server::TestHttpServer; use common::{query_manager, seed_workspace}; use rmcp::ServiceExt; use rmcp::model::CallToolRequestParams; use serde_json::{Value, json}; use std::process::Stdio; use tempfile::TempDir; use tokio::process::Command; use yaak_models::models::HttpRequest; use yaak_models::util::UpdateSource; #[tokio::test] async fn mcp_serves_tools_over_stdio() { let temp_dir = TempDir::new().expect("Failed to create temp dir"); let data_dir = temp_dir.path(); seed_workspace(data_dir, "wk_test"); let server = TestHttpServer::spawn_ok("hello from mcp"); let request = HttpRequest { id: "rq_mcp".to_string(), workspace_id: "wk_test".to_string(), name: "MCP Send".to_string(), method: "GET".to_string(), url: server.url.clone(), ..Default::default() }; query_manager(data_dir) .with_tx(|tx| tx.upsert_http_request(&request, &UpdateSource::Sync)) .expect("Failed to seed request"); let mut child = Command::new(env!("CARGO_BIN_EXE_yaak")) .arg("--data-dir") .arg(data_dir) .arg("mcp") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .kill_on_drop(true) .spawn() .unwrap(); let io = (child.stdout.take().unwrap(), child.stdin.take().unwrap()); let client = ().serve(io).await.unwrap(); let call = async |name: &'static str, arguments: Value| { let Value::Object(arguments) = arguments else { unreachable!() }; let result = client .call_tool(CallToolRequestParams::new(name).with_arguments(arguments)) .await .unwrap(); assert_ne!(result.is_error, Some(true), "{result:?}"); let text = result.content[0].as_text().unwrap().text.clone(); serde_json::from_str::(&text).unwrap() }; assert_eq!(client.list_all_tools().await.unwrap().len(), 15); assert_eq!( call("list_workspaces", json!({})).await, json!([{ "id": "wk_test", "name": "Seed Workspace" }]) ); let response = call("send_http_request", json!({ "id": "rq_mcp" })).await; assert_eq!(response["status"], 200); assert_eq!(response["requestId"], "rq_mcp"); client.cancel().await.unwrap(); assert!(child.wait().await.unwrap().success()); } /// Answers each request with its `X-Environment` header and reports the /// headers it received. fn marker_server() -> (String, std::sync::mpsc::Receiver) { use std::io::{Read, Write}; let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let url = format!("http://{}", listener.local_addr().unwrap()); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { for stream in listener.incoming() { let Ok(mut stream) = stream else { break }; let mut request = Vec::new(); let mut chunk = [0u8; 4096]; while !request.windows(4).any(|w| w == b"\r\n\r\n") { match stream.read(&mut chunk) { Ok(0) | Err(_) => break, Ok(n) => request.extend_from_slice(&chunk[..n]), } } let request = String::from_utf8_lossy(&request).to_lowercase(); let marker = request .lines() .find_map(|l| l.strip_prefix("x-environment:")) .map(|v| v.trim().to_string()) .unwrap_or_default(); let _ = tx.send(request); let response = format!( "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{marker}", marker.len() ); let _ = stream.write_all(response.as_bytes()); } }); (url, rx) } #[tokio::test] async fn mcp_send_settings_reach_template_functions() { use yaak_models::models::{ Cookie, CookieDomain, CookieExpires, CookieJar, Environment, EnvironmentVariable, HttpRequestHeader, }; let temp_dir = TempDir::new().expect("Failed to create temp dir"); let data_dir = temp_dir.path(); seed_workspace(data_dir, "wk_test"); let (url, received) = marker_server(); let header = |name: &str, value: &str| HttpRequestHeader { enabled: true, name: name.into(), value: value.into(), ..Default::default() }; let override_id = query_manager(data_dir) .with_tx(|db| { let source = &UpdateSource::Sync; let vars = |value: &str| { vec![EnvironmentVariable { enabled: true, name: "marker".into(), value: value.into(), ..Default::default() }] }; let base = db.ensure_base_environment("wk_test")?; let base = db.upsert_environment(&Environment { variables: vars("global"), ..base }, source)?; let chosen = db.upsert_environment( &Environment { name: "chosen".into(), workspace_id: "wk_test".into(), parent_model: "environment".into(), parent_id: Some(base.id.clone()), variables: vars("chosen"), ..Default::default() }, source, )?; db.upsert_cookie_jar( &CookieJar { workspace_id: "wk_test".into(), name: "Default".into(), cookies: vec![Cookie { name: "session".into(), value: "from-jar".into(), domain: CookieDomain::HostOnly("127.0.0.1".into()), expires: CookieExpires::SessionEnd, path: "/".into(), secure: false, http_only: false, same_site: None, }], ..Default::default() }, source, )?; db.upsert_http_request( &HttpRequest { id: "rq_inner".into(), workspace_id: "wk_test".into(), method: "GET".into(), url: format!("{url}/inner"), headers: vec![header("X-Environment", "${[ marker ]}")], ..Default::default() }, source, )?; db.upsert_http_request( &HttpRequest { id: "rq_outer".into(), workspace_id: "wk_test".into(), method: "GET".into(), url: format!("{url}/outer"), headers: vec![ header( "X-Nested", "${[ response.body.raw(request='rq_inner', behavior='always') ]}", ), header("X-Cookie", "${[ cookie.value(name='session') ]}"), ], ..Default::default() }, source, )?; Ok::<_, yaak_models::error::Error>(chosen.id) }) .unwrap(); let mut child = Command::new(env!("CARGO_BIN_EXE_yaak")) .arg("--data-dir") .arg(data_dir) .arg("mcp") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .kill_on_drop(true) .spawn() .unwrap(); let io = (child.stdout.take().unwrap(), child.stdin.take().unwrap()); let client = ().serve(io).await.unwrap(); let Value::Object(arguments) = json!({ "id": "rq_outer", "environmentId": override_id }) else { unreachable!() }; let result = client .call_tool(CallToolRequestParams::new("send_http_request").with_arguments(arguments)) .await .unwrap(); assert_ne!(result.is_error, Some(true), "{result:?}"); let outer = received.iter().find(|r| r.starts_with("get /outer")).unwrap(); assert!(outer.contains("x-nested: chosen\r\n"), "{outer}"); assert!(outer.contains("x-cookie: from-jar\r\n"), "{outer}"); client.cancel().await.unwrap(); assert!(child.wait().await.unwrap().success()); } /// QA sweep bug 6: the CLI has no window, so `show_toast` prints the toast to stderr (stdout is /// the protocol channel) the way the CLI prints plugin toasts, and reports it as shown. #[tokio::test] async fn mcp_show_toast_prints_the_message_to_stderr() { use tokio::io::AsyncReadExt; let temp_dir = TempDir::new().expect("Failed to create temp dir"); let data_dir = temp_dir.path(); seed_workspace(data_dir, "wk_test"); let mut child = Command::new(env!("CARGO_BIN_EXE_yaak")) .arg("--data-dir") .arg(data_dir) .arg("mcp") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true) .spawn() .unwrap(); let mut stderr = child.stderr.take().unwrap(); let io = (child.stdout.take().unwrap(), child.stdin.take().unwrap()); let client = ().serve(io).await.unwrap(); let arguments = json!({ "message": "Build finished" }); let Value::Object(arguments) = arguments else { unreachable!() }; let result = client .call_tool(CallToolRequestParams::new("show_toast").with_arguments(arguments)) .await .unwrap(); assert_ne!(result.is_error, Some(true), "{result:?}"); assert_eq!(result.content[0].as_text().unwrap().text, "✓ Toast shown: \"Build finished\""); client.cancel().await.unwrap(); child.kill().await.unwrap(); let mut output = String::new(); stderr.read_to_string(&mut output).await.unwrap(); assert!(output.lines().any(|l| l == "[mcp] Build finished"), "stderr: {output}"); } /// Spawns `yaak mcp --http` with `args` and returns the child, the URL it /// printed, and the token it printed or was given. async fn spawn_http(data_dir: &std::path::Path, args: &[&str]) -> (tokio::process::Child, String, String) { use tokio::io::AsyncBufReadExt; let mut child = Command::new(env!("CARGO_BIN_EXE_yaak")) .arg("--data-dir") .arg(data_dir) .args(["mcp", "--http", "--port", "0"]) .args(args) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::piped()) .kill_on_drop(true) .spawn() .unwrap(); let mut stderr = tokio::io::BufReader::new(child.stderr.take().unwrap()).lines(); let mut url = None; let mut token = None; while url.is_none() || token.is_none() { let line = stderr.next_line().await.unwrap().expect("server exited before announcing"); if let Some(rest) = line.strip_prefix("Yaak MCP server listening on ") { url = Some(rest.to_string()); } else if let Some(rest) = line.strip_prefix("Token: ") { token = Some(rest.to_string()); } } (child, url.unwrap(), token.unwrap()) } /// Posts one JSON-RPC message, with the session id when there is one, and /// returns the response and its body parsed out of either JSON or SSE. async fn post( client: &reqwest::Client, url: &str, auth: Option<&str>, session: Option<&str>, body: Value, ) -> (reqwest::StatusCode, reqwest::header::HeaderMap, Option) { let mut request = client .post(url) .header("Content-Type", "application/json") .header("Accept", "application/json, text/event-stream") .body(body.to_string()); if let Some(auth) = auth { request = request.header("Authorization", auth); } if let Some(session) = session { request = request.header("Mcp-Session-Id", session); } let response = request.send().await.unwrap(); let status = response.status(); let headers = response.headers().clone(); let text = response.text().await.unwrap(); let message = text .lines() .filter_map(|l| l.strip_prefix("data:")) .last() .map(str::trim) .unwrap_or(text.trim()); // A plain-text body, such as the reason for a 500, comes back as a string to show let body = serde_json::from_str(message).unwrap_or_else(|_| Value::String(text.clone())); (status, headers, Some(body)) } #[tokio::test] async fn mcp_http_requires_the_token() { let temp_dir = TempDir::new().expect("Failed to create temp dir"); let data_dir = temp_dir.path(); seed_workspace(data_dir, "wk_test"); let (mut child, url, token) = spawn_http(data_dir, &["--token", "s3cret"]).await; assert_eq!(token, "s3cret"); assert!(url.starts_with("http://127.0.0.1:"), "{url}"); assert!(url.ends_with("/mcp"), "{url}"); let client = reqwest::Client::new(); let initialize = json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "test", "version": "1.0" } } }); for auth in [None, Some("Bearer wrong")] { let (status, headers, _) = post(&client, &url, auth, None, initialize.clone()).await; assert_eq!(status, reqwest::StatusCode::UNAUTHORIZED, "{auth:?}"); assert_eq!(headers["www-authenticate"], "Bearer"); } let auth = Some("Bearer s3cret"); let (status, headers, body) = post(&client, &url, auth, None, initialize).await; assert_eq!(status, reqwest::StatusCode::OK, "initialize answered {body:?}"); let session = headers["mcp-session-id"].to_str().unwrap().to_string(); assert_eq!(body.unwrap()["result"]["serverInfo"]["name"], "yaak-mcp-server"); let initialized = json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }); let (status, _, _) = post(&client, &url, auth, Some(&session), initialized).await; assert_eq!(status, reqwest::StatusCode::ACCEPTED); let list = json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {} }); let (status, _, body) = post(&client, &url, auth, Some(&session), list).await; assert_eq!(status, reqwest::StatusCode::OK); let tools = body.unwrap()["result"]["tools"].as_array().unwrap().len(); assert_eq!(tools, 15); child.kill().await.unwrap(); } #[tokio::test] async fn mcp_http_generates_a_token() { let temp_dir = TempDir::new().expect("Failed to create temp dir"); let data_dir = temp_dir.path(); seed_workspace(data_dir, "wk_test"); let (mut child, url, token) = spawn_http(data_dir, &[]).await; assert_eq!(token.len(), 64, "{token}"); assert!(token.chars().all(|c| c.is_ascii_hexdigit()), "{token}"); let client = reqwest::Client::new(); let initialize = json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "test", "version": "1.0" } } }); let (status, _, _) = post(&client, &url, None, None, initialize.clone()).await; assert_eq!(status, reqwest::StatusCode::UNAUTHORIZED); let (status, _, _) = post(&client, &url, Some(&format!("Bearer {token}")), None, initialize).await; assert_eq!(status, reqwest::StatusCode::OK); child.kill().await.unwrap(); }