From 0839d9c0b847279e6fa8eee8346a328e1f227fa2 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:48:09 -0500 Subject: [PATCH] test(mcp): end-to-end client over real HTTP loopback (T10.4-10.9) A real rmcp client speaks Streamable HTTP to the real RmcpServer stack (bind_loopback + serve + WaMcpHandler) over an OS-assigned loopback port, using an in-memory SqliteStore -- not just isolated unit pieces. Asserts: an unauthenticated POST is rejected (401) before it ever reaches the MCP service; an authenticated client's list_tools/call_tool round-trip is schema-valid; and the call appends an mcp_access_log row even though default scope (`none`) denies it data (FR-MCP-5 logs the ask, not just the answer). mint_and_store/delete touch the real OS credential store, same pattern as sync::tests::webdav_round_trip. --- src-tauri/tests/mcp_server_test.rs | 99 ++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 src-tauri/tests/mcp_server_test.rs diff --git a/src-tauri/tests/mcp_server_test.rs b/src-tauri/tests/mcp_server_test.rs new file mode 100644 index 0000000..ddb6264 --- /dev/null +++ b/src-tauri/tests/mcp_server_test.rs @@ -0,0 +1,99 @@ +//! End-to-end MCP smoke test (Phase 10b, ADR-0011): an in-process client +//! actually speaks Streamable HTTP to the real `RmcpServer`/`WaMcpHandler` +//! over a loopback socket -- not just unit-level pieces. +//! +//! Covers the M2 acceptance criteria: +//! - a real MCP client can call `list_tools`/`call_tool` and get schema-valid +//! responses (FR-MCP-2); +//! - a request without a valid bearer token is rejected before it reaches the +//! MCP service; +//! - every tool call appends an `mcp_access_log` row (FR-MCP-5), even a +//! scope-denied one. +//! +//! `mcp::token::mint_and_store`/`delete` touch the real OS credential store +//! (same as `sync::tests::webdav_round_trip`'s use of `sync::credentials`) -- +//! cleaned up at the end of the test. +#![cfg(feature = "mcp")] + +use rmcp::ServiceExt; +use std::sync::Arc; +use whispassist_lib::mcp::handler::WaMcpHandler; +use whispassist_lib::mcp::{http_transport, token}; +use whispassist_lib::storage::{SqliteStore, Store}; + +#[tokio::test] +async fn client_calls_tools_over_http_with_a_token_and_every_read_is_logged() { + let store: Arc = Arc::new( + SqliteStore::connect_in_memory() + .await + .expect("in-memory store"), + ); + + let listener = http_transport::bind_loopback("127.0.0.1", 0) + .await + .expect("loopback bind"); + let addr = listener.local_addr().expect("local addr"); + let handler = WaMcpHandler::new(store.clone(), None); + let auth_token = token::mint_and_store().expect("mint token"); + let server = http_transport::serve(listener, auth_token.clone(), handler); + + let uri: std::sync::Arc = format!("http://{addr}/mcp").into(); + + // ---- Unauthenticated request is rejected before reaching the MCP service ---- + let raw = reqwest::Client::new() + .post(uri.as_ref()) + .header("accept", "application/json, text/event-stream") + .header("content-type", "application/json") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#) + .send() + .await + .expect("http request"); + assert_eq!(raw.status(), reqwest::StatusCode::UNAUTHORIZED); + + // ---- Authenticated client: list_tools + call_tool round-trip ---- + let transport = rmcp::transport::StreamableHttpClientTransport::::from_config( + rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig::with_uri( + uri.clone(), + ) + .auth_header(auth_token.clone()), + ); + let client = () + .serve(transport) + .await + .expect("client handshake (rejected if the token weren't accepted)"); + + let tools = client.list_tools(None).await.expect("list_tools"); + let names: Vec<&str> = tools.tools.iter().map(|t| t.name.as_ref()).collect(); + assert!(names.contains(&"list_recent_meetings")); + assert!(names.contains(&"get_transcript")); + assert!(names.contains(&"get_action_items")); + assert!(names.contains(&"get_feature_brief")); + + // Default scope is `none` (privacy-safe default) -- list_recent_meetings + // must return a schema-valid, empty result rather than an error. + let result = client + .call_tool(rmcp::model::CallToolRequestParams { + meta: None, + name: "list_recent_meetings".into(), + arguments: None, + task: None, + }) + .await + .expect("call_tool list_recent_meetings"); + let structured = result + .structured_content + .expect("structured content present"); + assert_eq!(structured["meetings"].as_array().map(Vec::len), Some(0)); + + client.cancel().await.expect("client shutdown"); + + // ---- Audit trail: the read above is logged regardless of scope denial ---- + let rows = store + .list_mcp_access_log(None) + .await + .expect("list_mcp_access_log"); + assert!(rows.iter().any(|r| r.tool == "list_recent_meetings")); + + server.stop().await; + token::delete(); +}