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:
1 parent
71824d44cc
commit
0839d9c0b8
1 file changed
+99
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user