Feature chore bug 005 #19

Merged
iamdoubz merged 106 commits from feature_chore_bug_005 into main 2026-07-07 22:02:41 -05:00
Showing only changes of commit 0839d9c0b8 - Show all commits
+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();
}