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.
This commit is contained in:
iamdoubz committed 2026-07-07 00:48:09 -05:00
1 parent 71824d44cc
commit 0839d9c0b8
1 file changed
+99
+99
View File
@@ -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<dyn Store> = 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<str> = 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::<reqwest::Client>::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();
}