diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ee6d6e8..aa092ff 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -71,7 +71,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ "base64ct", "blake2", - "cpufeatures", + "cpufeatures 0.2.17", "password-hash", ] @@ -475,7 +475,18 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", ] [[package]] @@ -485,7 +496,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", - "chacha20", + "chacha20 0.9.1", "cipher", "poly1305", "zeroize", @@ -626,6 +637,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.4.0" @@ -1492,6 +1512,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", ] [[package]] @@ -1795,6 +1816,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.10.1" @@ -1808,6 +1835,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec 1.15.2", @@ -3140,7 +3168,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -3439,6 +3467,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20 0.10.1", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -3477,6 +3516,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3699,18 +3744,28 @@ checksum = "cc4c9c94680f75470ee8083a0667988b5d7b5beb70b9f998a8e51de7c682ce60" dependencies = [ "async-trait", "base64 0.22.1", + "bytes", "chrono", "futures", + "http", + "http-body", + "http-body-util", "pastey", "pin-project-lite", + "rand 0.10.2", + "reqwest 0.13.4", "rmcp-macros", "schemars 1.2.1", "serde", "serde_json", + "sse-stream", "thiserror 2.0.18", "tokio", + "tokio-stream", "tokio-util", + "tower-service", "tracing", + "uuid", ] [[package]] @@ -4158,7 +4213,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -4169,7 +4224,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -4545,6 +4600,19 @@ dependencies = [ "url", ] +[[package]] +name = "sse-stream" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3962b63f038885f15bce2c6e02c0e7925c072f1ac86bb60fd44c5c6b762fb72" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -5979,12 +6047,17 @@ version = "0.2.0" dependencies = [ "argon2", "async-trait", + "bytes", "chacha20poly1305", "chrono", "docx-rs", "futures-util", "getrandom 0.2.17", "hound", + "http", + "http-body-util", + "hyper", + "hyper-util", "keyring", "ort", "printpdf", @@ -6003,6 +6076,8 @@ dependencies = [ "tauri-plugin-dialog", "thiserror 1.0.69", "tokio", + "tokio-util", + "tower-service", "tracing", "tracing-subscriber", "uuid", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e9adce4..5c3185b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -47,7 +47,26 @@ chacha20poly1305 = "0.10" getrandom = "0.2" zeroize = "1" # wipe key material from memory on lock keyring = { version = "3", optional = true, features = ["windows-native"] } # OS credential store (sync + AI creds); windows-native = real Credential Manager (else keyring 3.x uses a no-op mock store) -rmcp = { version = "0.16", optional = true, features = ["server"] } # MCP server (ADR-0011) +# MCP server (ADR-0011). `client`/`transport-streamable-http-client-reqwest` +# are only ever constructed by this crate's own in-process tests (an actual +# MCP client talking to our loopback server) -- WA never opens an outbound +# MCP connection at runtime, so this adds no egress (FR-MCP-7). +rmcp = { version = "0.16", optional = true, features = [ + "server", "transport-streamable-http-server", "transport-io", + "client", "transport-streamable-http-client-reqwest", +] } +# Low-level HTTP glue for the Streamable HTTP transport: `rmcp`'s +# `StreamableHttpService` is a bare `tower_service::Service`, so something has +# to actually accept TCP connections and run HTTP/1 on top of it. All four +# versions are already in Cargo.lock transitively (via reqwest/tauri), so this +# just promotes them to direct deps -- no new crates. +hyper = { version = "1", optional = true, features = ["server", "http1"] } +hyper-util = { version = "0.1", optional = true, features = ["tokio"] } +http-body-util = { version = "0.1", optional = true } +http = { version = "1", optional = true } +bytes = { version = "1", optional = true } +tower-service = { version = "0.3", optional = true } +tokio-util = { version = "0.7", optional = true } # audio / transcription / diarization / calendar are integrated per-phase and are # feature-gated so the CPU-only build always compiles (NFR-MNT-4). @@ -106,7 +125,11 @@ pst = [] # shells out to readpst (libpst) — no cra # Phase 9 sync = ["dep:keyring"] # remote upload (WebDAV + OAuth providers) # Phase 10 -mcp = ["dep:rmcp", "dep:keyring"] # WhispAssist as an MCP server + hosted-AI creds +mcp = [ + "dep:rmcp", "dep:keyring", "dep:hyper", "dep:hyper-util", + "dep:http-body-util", "dep:http", "dep:bytes", "dep:tower-service", + "dep:tokio-util", +] # WhispAssist as an MCP server + hosted-AI creds [profile.release] opt-level = "z" # optimize for size — keep the binary small (NFR-RES-1) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index a792abb..7951955 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -65,6 +65,11 @@ fn default_settings() -> Settings { audio_output_device: None, microphone_enabled: true, audio_input_device: None, + mcp_enabled: false, + mcp_transport: "http".into(), + mcp_port: 4849, + mcp_expose: "none".into(), + mcp_expose_recordings: false, } } @@ -1735,7 +1740,10 @@ pub(crate) fn serve_recording( if let Some((start, end)) = parse_byte_range(range, total) { return base() .status(StatusCode::PARTIAL_CONTENT) - .header(header::CONTENT_RANGE, format!("bytes {start}-{end}/{total}")) + .header( + header::CONTENT_RANGE, + format!("bytes {start}-{end}/{total}"), + ) .header(header::CONTENT_LENGTH, (end - start + 1).to_string()) .body(plain[start..=end].to_vec()) .unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR)); @@ -3233,9 +3241,18 @@ pub async fn list_feature_briefs( /// index (`docs/03-data-model.md`). #[tauri::command] pub async fn get_feature_brief(state: State<'_, AppState>, id: String) -> WaResult { - let row = state - .store - .get_feature_brief_row(&id) + get_feature_brief_core(&state.store, &id).await +} + +/// Core of `get_feature_brief`, factored out of the `State`-taking command so +/// the MCP `get_feature_brief` tool (`mcp::handler`, which only holds an +/// `Arc`, not a live Tauri `State`) can call the same logic. +pub(crate) async fn get_feature_brief_core( + store: &std::sync::Arc, + id: &str, +) -> WaResult { + let row = store + .get_feature_brief_row(id) .await .map_err(|e| WaError::new("storage", e.to_string()))?; let abs_path = meeting_dir(&row.meeting_id).join(&row.path); @@ -3270,29 +3287,132 @@ pub async fn set_brief_exposed( } #[tauri::command] -pub async fn mcp_status() -> WaResult { - Err(not_implemented("mcp_status")) +pub async fn mcp_status(state: State<'_, AppState>, app: AppHandle) -> WaResult { + #[cfg(feature = "mcp")] + { + let settings = load_settings(); + let server = crate::mcp::server::instance(state.store.clone(), app); + let running = server.is_running().await; + let transport = crate::mcp::McpTransport::parse(&settings.mcp_transport); + let endpoint = if running { + mcp_endpoint_for(transport, settings.mcp_port) + } else { + String::new() + }; + Ok(serde_json::json!({ + "enabled": running, + "transport": transport.as_str(), + "endpoint": endpoint, + "tokenSet": crate::mcp::token::is_set(), + "exposeScope": settings.mcp_expose, + })) + } + #[cfg(not(feature = "mcp"))] + { + let _ = (state, app); + Err(not_implemented("mcp_status")) + } +} + +/// Builds the endpoint string surfaced by `mcp_status`/`set_mcp_enabled` — +/// shared so both agree on the shape (`http://127.0.0.1:/mcp` for +/// Streamable HTTP, or the `--mcp-stdio` command line the agent should spawn). +#[cfg(feature = "mcp")] +fn mcp_endpoint_for(transport: crate::mcp::McpTransport, port: u16) -> String { + match transport { + crate::mcp::McpTransport::Http => format!("http://127.0.0.1:{port}/mcp"), + crate::mcp::McpTransport::Stdio => { + let exe = std::env::current_exe() + .ok() + .and_then(|p| p.to_str().map(str::to_string)) + .unwrap_or_else(|| "whispassist.exe".to_string()); + format!("{exe} --mcp-stdio") + } + } } /// Enable/disable the loopback MCP server; returns endpoint + token on enable (FR-MCP-1/6). +/// The token is minted fresh on every enable and lives only in the OS +/// credential store (`mcp::token`) — this command's return value is the one +/// time it's ever surfaced, exactly like a newly-created password. #[tauri::command] pub async fn set_mcp_enabled( - _enabled: bool, - _transport: Option, - _port: Option, + state: State<'_, AppState>, + app: AppHandle, + enabled: bool, + transport: Option, + port: Option, ) -> WaResult { - Err(not_implemented("set_mcp_enabled")) + #[cfg(feature = "mcp")] + { + use crate::mcp::McpServer; + + let mut settings = load_settings(); + if let Some(t) = &transport { + settings.mcp_transport = t.clone(); + } + if let Some(p) = port { + settings.mcp_port = p; + } + settings.mcp_enabled = enabled; + save_settings(&settings)?; + + let server = crate::mcp::server::instance(state.store.clone(), app); + if enabled { + let cfg = crate::mcp::McpConfig { + transport: crate::mcp::McpTransport::parse(&settings.mcp_transport), + port: settings.mcp_port, + expose: crate::mcp::ExposeScope::parse(&settings.mcp_expose), + expose_recordings: settings.mcp_expose_recordings, + }; + let handle = server + .start(cfg) + .await + .map_err(|e| WaError::new("mcp", e.to_string()))?; + Ok(serde_json::json!({ "endpoint": handle.endpoint, "token": handle.token })) + } else { + server + .stop(crate::mcp::McpHandle { + endpoint: String::new(), + token: String::new(), + }) + .await + .map_err(|e| WaError::new("mcp", e.to_string()))?; + Ok(serde_json::json!({ "endpoint": "", "token": "" })) + } + } + #[cfg(not(feature = "mcp"))] + { + let _ = (state, app, enabled, transport, port); + Err(not_implemented("set_mcp_enabled")) + } } -/// Set exposure scope (none|selected|all) and whether recordings may be served (FR-MCP-3). +/// Set exposure scope (none|selected|all) and whether recordings may be served +/// (FR-MCP-3). Pure settings I/O — every MCP tool handler reads this live +/// (`mcp::handler::WaMcpHandler::current_scope`), so a change here takes +/// effect immediately without restarting the server. #[tauri::command] -pub async fn set_mcp_scope(_expose: String, _expose_recordings: Option) -> WaResult<()> { - Err(not_implemented("set_mcp_scope")) +pub async fn set_mcp_scope(expose: String, expose_recordings: Option) -> WaResult<()> { + let mut settings = load_settings(); + settings.mcp_expose = expose; + if let Some(r) = expose_recordings { + settings.mcp_expose_recordings = r; + } + save_settings(&settings) } +/// Audit trail (FR-MCP-5) — every tool read, allowed or denied. #[tauri::command] -pub async fn mcp_access_log(_limit: Option) -> WaResult> { - Err(not_implemented("mcp_access_log")) +pub async fn mcp_access_log( + state: State<'_, AppState>, + limit: Option, +) -> WaResult> { + state + .store + .list_mcp_access_log(limit) + .await + .map_err(|e| WaError::new("mcp", e.to_string())) } // ---- Agent push / task-tracker handoff (Phase 10c, later) ---- diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a1e730f..cf5cf17 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -281,3 +281,49 @@ pub(crate) fn update_tray_tooltip(app: &tauri::AppHandle, text: &str) { let _ = tray.0.set_tooltip(Some(text)); } } + +/// Entry point for `whispassist.exe --mcp-stdio` (FR-MCP-6): serves one MCP +/// session over this process's own stdin/stdout instead of showing a window, +/// against the same `wa.db` the GUI instance uses. There is no Tauri +/// `AppHandle` in this mode, so `mcp://access` events have nowhere to go — +/// the `mcp_access_log` DB row is still written regardless (FR-MCP-5). +pub fn run_mcp_stdio() { + #[cfg(feature = "mcp")] + { + // stderr, not stdout: stdout is the MCP JSON-RPC channel. + let _ = tracing_subscriber::fmt() + .with_env_filter("info") + .with_writer(std::io::stderr) + .try_init(); + let rt = match tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + eprintln!("whispassist --mcp-stdio: failed to start a runtime: {e}"); + std::process::exit(1); + } + }; + rt.block_on(async { + let store: std::sync::Arc = + match storage::SqliteStore::connect().await { + Ok(s) => std::sync::Arc::new(s), + Err(e) => { + eprintln!("whispassist --mcp-stdio: failed to open wa.db: {e}"); + std::process::exit(1); + } + }; + let handler = mcp::handler::WaMcpHandler::new(store, None); + if let Err(e) = mcp::stdio_transport::serve_once(handler).await { + eprintln!("whispassist --mcp-stdio: session ended with an error: {e}"); + std::process::exit(1); + } + }); + } + #[cfg(not(feature = "mcp"))] + { + eprintln!("this build was compiled without MCP support (the `mcp` cargo feature is off)"); + std::process::exit(1); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index afe2e08..67d809d 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -3,5 +3,14 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { + // `whispassist.exe --mcp-stdio` (FR-MCP-6): the stdio "adapter the agent + // spawns" is this same binary, in headless mode -- it serves one MCP + // session over its own stdin/stdout and exits, instead of opening the + // GUI window. A coding agent's MCP client config spawns this exact + // command line (see `mcp_status`/`set_mcp_enabled`'s returned endpoint). + if std::env::args().any(|a| a == "--mcp-stdio") { + whispassist_lib::run_mcp_stdio(); + return; + } whispassist_lib::run(); } diff --git a/src-tauri/src/mcp/handler.rs b/src-tauri/src/mcp/handler.rs new file mode 100644 index 0000000..f0c4ede --- /dev/null +++ b/src-tauri/src/mcp/handler.rs @@ -0,0 +1,346 @@ +//! `rmcp::ServerHandler` implementation — the tools-first surface (FR-MCP-2) +//! that a connected coding agent actually calls. Every tool handler: +//! 1. Reads the *current* scope from `Settings` (not a snapshot taken at +//! server start) so `set_mcp_scope` takes effect immediately. +//! 2. Logs the read (FR-MCP-5) — even when the read is denied, so the audit +//! trail reflects what an agent *asked for*. +//! 3. Independently re-checks scope + the recordings gate (FR-MCP-3) — there +//! is deliberately no single choke point upstream of this file. + +use crate::mcp::{scope, ExposeScope}; +use crate::models::MeetingId; +use crate::storage::{MeetingFilter, Store}; +use rmcp::model::{ + CallToolRequestParams, CallToolResult, Implementation, JsonObject, ListToolsResult, + PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool, +}; +use rmcp::service::{RequestContext, RoleServer}; +use rmcp::{ErrorData as McpProtoError, ServerHandler}; +use serde_json::{json, Value}; +use std::sync::Arc; +use tauri::{AppHandle, Emitter}; + +/// Shared handle the HTTP/stdio transports build a fresh `rmcp` service +/// around per-connection (`ServerHandler` methods take `&self`, so this just +/// needs to be `Clone` + cheap — it's an `Arc` and an `AppHandle`). +/// `app` is `None` in `--mcp-stdio` mode (a separate process with no Tauri +/// window to emit events to, see `mcp::stdio_transport`/`main.rs`) — the +/// `mcp_access_log` row is still written either way (FR-MCP-5), only the +/// live `"mcp://access"` event has nowhere to go. +#[derive(Clone)] +pub struct WaMcpHandler { + store: Arc, + app: Option, +} + +impl WaMcpHandler { + pub fn new(store: Arc, app: Option) -> Self { + Self { store, app } + } + + /// Live scope read (not cached) so `set_mcp_scope` applies without a + /// server restart. + fn current_scope(&self) -> (ExposeScope, bool) { + let settings = crate::commands::load_settings(); + ( + ExposeScope::parse(&settings.mcp_expose), + settings.mcp_expose_recordings, + ) + } + + async fn log_access(&self, tool: &str, meeting_id: Option<&MeetingId>, client: Option<&str>) { + if let Err(e) = self.store.record_mcp_access(tool, meeting_id, client).await { + tracing::warn!("failed to record mcp access log row: {e}"); + } + if let Some(app) = &self.app { + let _ = app.emit( + "mcp://access", + json!({ + "at": now_ms(), + "tool": tool, + "meetingId": meeting_id, + "client": client, + }), + ); + } + } + + fn client_name(context: &RequestContext) -> Option { + context + .peer + .peer_info() + .map(|info| info.client_info.name.clone()) + } + + async fn tool_list_recent_meetings( + &self, + args: &Option, + client: Option<&str>, + ) -> Result { + self.log_access("list_recent_meetings", None, client).await; + let (scope_val, expose_recordings) = self.current_scope(); + if !scope::meetings_visible(scope_val) { + return Ok(CallToolResult::structured(json!({ "meetings": [] }))); + } + let limit = arg_u64(args, "limit").unwrap_or(20).clamp(1, 100) as usize; + let items = self + .store + .list_meetings(MeetingFilter::default()) + .await + .map_err(store_err)?; + let mut out = Vec::with_capacity(limit); + for item in items { + if out.len() >= limit { + break; + } + let Ok(full) = self.store.get_meeting(&item.id).await else { + continue; + }; + if !scope::recording_gate_ok(expose_recordings, full.recorded) { + continue; + } + out.push(json!({ + "id": item.id, + "title": item.title, + "startedAt": item.started_at, + "durationSecs": item.duration_secs, + "status": item.status.as_str(), + "tags": item.tags, + })); + } + Ok(CallToolResult::structured(json!({ "meetings": out }))) + } + + async fn tool_get_transcript( + &self, + args: &Option, + client: Option<&str>, + ) -> Result { + let meeting_id = arg_str(args, "meetingId") + .ok_or_else(|| McpProtoError::invalid_params("meetingId is required", None))?; + self.log_access("get_transcript", Some(&meeting_id), client) + .await; + let (scope_val, expose_recordings) = self.current_scope(); + if !scope::meetings_visible(scope_val) { + return Ok(denied("get_transcript scope is not `all`")); + } + let meeting = self + .store + .get_meeting(&meeting_id) + .await + .map_err(store_err)?; + if !scope::recording_gate_ok(expose_recordings, meeting.recorded) { + return Ok(denied( + "this meeting retained its recording; expose_recordings is off", + )); + } + Ok(CallToolResult::structured(json!({ + "meetingId": meeting.id, + "title": meeting.title, + "segments": meeting.segments, + }))) + } + + async fn tool_get_action_items( + &self, + args: &Option, + client: Option<&str>, + ) -> Result { + let meeting_id = arg_str(args, "meetingId") + .ok_or_else(|| McpProtoError::invalid_params("meetingId is required", None))?; + self.log_access("get_action_items", Some(&meeting_id), client) + .await; + let (scope_val, expose_recordings) = self.current_scope(); + if !scope::meetings_visible(scope_val) { + return Ok(denied("get_action_items scope is not `all`")); + } + let meeting = self + .store + .get_meeting(&meeting_id) + .await + .map_err(store_err)?; + if !scope::recording_gate_ok(expose_recordings, meeting.recorded) { + return Ok(denied( + "this meeting retained its recording; expose_recordings is off", + )); + } + let items = self + .store + .list_action_items(&meeting_id) + .await + .map_err(store_err)?; + Ok(CallToolResult::structured(json!({ + "meetingId": meeting_id, + "items": items, + }))) + } + + async fn tool_get_feature_brief( + &self, + args: &Option, + client: Option<&str>, + ) -> Result { + let id = arg_str(args, "id") + .ok_or_else(|| McpProtoError::invalid_params("id is required", None))?; + self.log_access("get_feature_brief", None, client).await; + let (scope_val, _expose_recordings) = self.current_scope(); + if matches!(scope_val, ExposeScope::None) { + return Ok(denied("MCP scope is `none`; no briefs are exposed")); + } + let row = match self.store.get_feature_brief_row(&id).await { + Ok(row) => row, + Err(e) => { + return Ok(CallToolResult::structured_error(json!({ + "error": "storage", + "message": e.to_string(), + }))) + } + }; + if !scope::brief_visible(scope_val, row.exposed) { + return Ok(denied( + "this brief is not exposed (toggle it on via set_brief_exposed, or set scope to `all`)", + )); + } + match crate::commands::get_feature_brief_core(&self.store, &id).await { + Ok(brief) => Ok(CallToolResult::structured( + serde_json::to_value(brief) + .map_err(|e| McpProtoError::internal_error(e.to_string(), None))?, + )), + Err(e) => Ok(CallToolResult::structured_error(json!({ + "error": e.kind, + "message": e.message, + }))), + } + } +} + +impl ServerHandler for WaMcpHandler { + fn get_info(&self) -> ServerInfo { + ServerInfo { + capabilities: ServerCapabilities::builder().enable_tools().build(), + server_info: Implementation { + name: "whispassist".into(), + title: Some("WhispAssist".into()), + version: env!("CARGO_PKG_VERSION").into(), + description: None, + icons: None, + website_url: None, + }, + instructions: Some( + "WhispAssist meeting-assistant tools. Served data may be forwarded by this \ + agent to its own model provider outside WhispAssist's control -- WA discloses \ + this in its UI and logs every read (FR-MCP-5). Recordings (.wav) are never \ + served by any tool here." + .into(), + ), + ..Default::default() + } + } + + async fn list_tools( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + let tools = vec![ + Tool::new( + "list_recent_meetings", + "Recent meetings, most recent first (scoped by the user's MCP settings).", + obj_schema(json!({ + "type": "object", + "properties": { "limit": { "type": "integer", "minimum": 1, "maximum": 100 } }, + "additionalProperties": false, + })), + ), + Tool::new( + "get_transcript", + "Full transcript (speaker-labeled segments) for one meeting.", + obj_schema(json!({ + "type": "object", + "properties": { "meetingId": { "type": "string" } }, + "required": ["meetingId"], + "additionalProperties": false, + })), + ), + Tool::new( + "get_action_items", + "Confirmed action items for one meeting.", + obj_schema(json!({ + "type": "object", + "properties": { "meetingId": { "type": "string" } }, + "required": ["meetingId"], + "additionalProperties": false, + })), + ), + Tool::new( + "get_feature_brief", + "Agent-ready spec (problem/outcome/acceptance criteria) distilled from a meeting.", + obj_schema(json!({ + "type": "object", + "properties": { "id": { "type": "string" } }, + "required": ["id"], + "additionalProperties": false, + })), + ), + ]; + Ok(ListToolsResult::with_all_items(tools)) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + let client = Self::client_name(&context); + match request.name.as_ref() { + "list_recent_meetings" => { + self.tool_list_recent_meetings(&request.arguments, client.as_deref()) + .await + } + "get_transcript" => { + self.tool_get_transcript(&request.arguments, client.as_deref()) + .await + } + "get_action_items" => { + self.tool_get_action_items(&request.arguments, client.as_deref()) + .await + } + "get_feature_brief" => { + self.tool_get_feature_brief(&request.arguments, client.as_deref()) + .await + } + other => Err(McpProtoError::invalid_params( + format!("unknown tool: {other}"), + None, + )), + } + } +} + +fn obj_schema(value: Value) -> Arc { + Arc::new(value.as_object().cloned().unwrap_or_default()) +} + +fn arg_str(args: &Option, key: &str) -> Option { + args.as_ref()?.get(key)?.as_str().map(str::to_string) +} + +fn arg_u64(args: &Option, key: &str) -> Option { + args.as_ref()?.get(key)?.as_u64() +} + +fn store_err(e: crate::storage::StoreError) -> McpProtoError { + McpProtoError::internal_error(e.to_string(), None) +} + +fn denied(reason: &str) -> CallToolResult { + CallToolResult::structured_error(json!({ "error": "scope_denied", "message": reason })) +} + +fn now_ms() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or_default() +} diff --git a/src-tauri/src/mcp/http_transport.rs b/src-tauri/src/mcp/http_transport.rs new file mode 100644 index 0000000..396b1a3 --- /dev/null +++ b/src-tauri/src/mcp/http_transport.rs @@ -0,0 +1,168 @@ +//! Streamable HTTP transport (FR-MCP-6): loopback-only bind + a bearer-token +//! gate that runs in front of every connection, before a single byte reaches +//! the MCP service. `rmcp`'s `StreamableHttpService` is a bare +//! `tower_service::Service` (not an axum app), so this module supplies the +//! actual TCP accept loop + HTTP/1 framing via `hyper`. + +use crate::mcp::handler::WaMcpHandler; +use crate::mcp::{token, McpError}; +use bytes::Bytes; +use http_body_util::{combinators::BoxBody, BodyExt, Full}; +use hyper::body::Incoming; +use hyper::service::service_fn; +use hyper::{Request, Response, StatusCode}; +use hyper_util::rt::TokioIo; +use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; +use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService}; +use std::convert::Infallible; +use std::net::{IpAddr, SocketAddr}; +use std::sync::Arc; +use tokio::net::TcpListener; +use tokio_util::sync::CancellationToken; + +/// Binds `host:port`, refusing anything that doesn't resolve to a loopback +/// address (127.0.0.0/8 or ::1) -- the only way this crate ever opens a +/// listening socket for MCP (FR-MCP-1, NFR-SEC-5). Kept generic over `host` +/// purely so the refusal path is directly unit-testable; the only production +/// caller (`mcp::server`) always passes `"127.0.0.1"`. +pub(crate) async fn bind_loopback(host: &str, port: u16) -> Result { + let ip: IpAddr = host.parse().map_err(|_| McpError::NonLoopback)?; + if !ip.is_loopback() { + return Err(McpError::NonLoopback); + } + TcpListener::bind(SocketAddr::new(ip, port)) + .await + .map_err(|e| McpError::Server(e.to_string())) +} + +/// A running HTTP server; `stop()` cancels the accept loop and all live +/// connections and waits for cleanup. +pub(crate) struct HttpServerHandle { + pub local_addr: SocketAddr, + shutdown: CancellationToken, + join: tokio::task::JoinHandle<()>, +} + +impl HttpServerHandle { + pub async fn stop(self) { + self.shutdown.cancel(); + let _ = self.join.await; + } +} + +/// Serves the MCP Streamable HTTP endpoint (`/mcp`, per the config's session +/// routing) on an already-bound loopback listener. Every request must present +/// `Authorization: Bearer ` matching the stored token (constant-time +/// compare, `mcp::token::verify`) or it never reaches `rmcp`. +pub(crate) fn serve( + listener: TcpListener, + expected_token: String, + handler: WaMcpHandler, +) -> HttpServerHandle { + let local_addr = listener + .local_addr() + .expect("a just-bound TcpListener has a local addr"); + let shutdown = CancellationToken::new(); + + let config = StreamableHttpServerConfig { + stateful_mode: true, + ..Default::default() + }; + let session_manager = Arc::new(LocalSessionManager::default()); + let service = StreamableHttpService::new(move || Ok(handler.clone()), session_manager, config); + + let accept_ct = shutdown.clone(); + let join = tokio::spawn(async move { + loop { + tokio::select! { + _ = accept_ct.cancelled() => break, + accepted = listener.accept() => { + let Ok((stream, _peer)) = accepted else { continue }; + let io = TokioIo::new(stream); + let svc = service.clone(); + let token = expected_token.clone(); + let conn_ct = accept_ct.clone(); + tokio::spawn(async move { + let guarded = service_fn(move |req: Request| { + let mut svc = svc.clone(); + let token = token.clone(); + async move { Ok::<_, Infallible>(handle_request(req, &mut svc, &token).await) } + }); + let conn = hyper::server::conn::http1::Builder::new().serve_connection(io, guarded); + tokio::select! { + _ = conn_ct.cancelled() => {} + _ = conn => {} + } + }); + } + } + } + }); + + HttpServerHandle { + local_addr, + shutdown, + join, + } +} + +async fn handle_request( + req: Request, + svc: &mut StreamableHttpService, + expected_token: &str, +) -> Response> { + if !is_authorized(&req, expected_token) { + return unauthorized_response(); + } + let (parts, body) = req.into_parts(); + let req = Request::from_parts(parts, body.boxed()); + tower_service::Service::call(svc, req) + .await + .unwrap_or_else(|never: Infallible| match never {}) +} + +fn is_authorized(req: &Request, expected_token: &str) -> bool { + let Some(header) = req.headers().get(hyper::header::AUTHORIZATION) else { + return false; + }; + let Ok(header) = header.to_str() else { + return false; + }; + let Some(presented) = header.strip_prefix("Bearer ") else { + return false; + }; + token::verify(presented, expected_token) +} + +fn unauthorized_response() -> Response> { + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header(hyper::header::CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from_static(b"{\"error\":\"unauthorized\"}")).boxed()) + .expect("valid response") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn refuses_a_non_loopback_bind() { + // A real routable address is never allowed regardless of port + // availability -- the check happens before any socket syscall. + let err = bind_loopback("8.8.8.8", 0).await.unwrap_err(); + assert!(matches!(err, McpError::NonLoopback)); + } + + #[tokio::test] + async fn refuses_an_unparseable_host() { + let err = bind_loopback("not-an-ip", 0).await.unwrap_err(); + assert!(matches!(err, McpError::NonLoopback)); + } + + #[tokio::test] + async fn binds_127_0_0_1_on_an_os_assigned_port() { + let listener = bind_loopback("127.0.0.1", 0).await.expect("loopback bind"); + assert!(listener.local_addr().unwrap().ip().is_loopback()); + } +} diff --git a/src-tauri/src/mcp/mod.rs b/src-tauri/src/mcp/mod.rs index feade85..be9c67c 100644 --- a/src-tauri/src/mcp/mod.rs +++ b/src-tauri/src/mcp/mod.rs @@ -14,10 +14,28 @@ use crate::models::{FeatureBrief, MeetingId}; use async_trait::async_trait; +pub mod scope; + +#[cfg(feature = "mcp")] +pub mod handler; +#[cfg(feature = "mcp")] +pub mod http_transport; +#[cfg(feature = "mcp")] +pub mod server; +#[cfg(feature = "mcp")] +pub mod stdio_transport; +#[cfg(feature = "mcp")] +pub mod token; + +#[cfg(feature = "mcp")] +pub use server::RmcpServer; + #[derive(Debug, thiserror::Error)] pub enum McpError { #[error("refusing to bind non-loopback address")] NonLoopback, + #[error("unauthorized: missing or invalid token")] + Unauthorized, #[error("server error: {0}")] Server(String), } @@ -38,7 +56,7 @@ pub struct McpConfig { pub expose_recordings: bool, } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum McpTransport { /// Streamable HTTP on http://127.0.0.1:/mcp (loopback only). Http, @@ -46,24 +64,85 @@ pub enum McpTransport { Stdio, } -#[derive(Debug, Clone, Copy)] +impl McpTransport { + pub fn as_str(self) -> &'static str { + match self { + McpTransport::Http => "http", + McpTransport::Stdio => "stdio", + } + } + + /// Unknown/missing values fall back to `Http` — the safer default to + /// document to the user (stdio requires a client that spawns a process). + pub fn parse(s: &str) -> Self { + match s { + "stdio" => McpTransport::Stdio, + _ => McpTransport::Http, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ExposeScope { None, Selected, All, } +impl ExposeScope { + pub fn as_str(self) -> &'static str { + match self { + ExposeScope::None => "none", + ExposeScope::Selected => "selected", + ExposeScope::All => "all", + } + } + + /// Unknown values fall back to `None` — scope-control is a privacy + /// control, so an unparsed value must never silently become permissive. + pub fn parse(s: &str) -> Self { + match s { + "selected" => ExposeScope::Selected, + "all" => ExposeScope::All, + _ => ExposeScope::None, + } + } +} + /// Returned on start: where to point the agent + the token it must present. pub struct McpHandle { pub endpoint: String, pub token: String, } +#[derive(Debug, Clone, Copy)] pub struct McpToolDescriptor { pub name: &'static str, pub description: &'static str, } +/// The four tools-first-surface descriptors (FR-MCP-2), shared by the trait's +/// default listing and anything else that needs to enumerate them without a +/// running server (e.g. the settings/privacy UI). +pub const TOOL_DESCRIPTORS: [McpToolDescriptor; 4] = [ + McpToolDescriptor { + name: "list_recent_meetings", + description: "Recent meetings (scoped).", + }, + McpToolDescriptor { + name: "get_transcript", + description: "Transcript for a meeting (scoped).", + }, + McpToolDescriptor { + name: "get_action_items", + description: "Action items for a meeting.", + }, + McpToolDescriptor { + name: "get_feature_brief", + description: "Agent-ready spec distilled from a meeting.", + }, +]; + /// The MCP server. Built on the official Rust SDK (`rmcp`, feature `mcp`). #[async_trait] pub trait McpServer: Send + Sync { @@ -82,41 +161,3 @@ pub trait FeatureBriefBuilder: Send + Sync { target_repo: Option<&str>, ) -> Result; } - -/// Default rmcp-backed server (feature `mcp`). -#[cfg(feature = "mcp")] -pub struct RmcpServer; - -#[cfg(feature = "mcp")] -#[async_trait] -impl McpServer for RmcpServer { - async fn start(&self, _cfg: McpConfig) -> Result { - // T10.4: bind loopback ONLY (reject non-loopback), mint a token, register tools, - // serve over Streamable HTTP (/mcp) or stdio. Never opens an outbound socket. - todo!("Phase 10b — start MCP server (loopback, token)") - } - async fn stop(&self, _handle: McpHandle) -> Result<(), McpError> { - todo!("Phase 10b — stop MCP server") - } - fn tools(&self) -> Vec { - // T10.5: the tools an agent can call. Tools-first for Copilot compatibility. - vec![ - McpToolDescriptor { - name: "list_recent_meetings", - description: "Recent meetings (scoped).", - }, - McpToolDescriptor { - name: "get_transcript", - description: "Transcript for a meeting (scoped).", - }, - McpToolDescriptor { - name: "get_action_items", - description: "Action items for a meeting.", - }, - McpToolDescriptor { - name: "get_feature_brief", - description: "Agent-ready spec distilled from a meeting.", - }, - ] - } -} diff --git a/src-tauri/src/mcp/scope.rs b/src-tauri/src/mcp/scope.rs new file mode 100644 index 0000000..6dd7602 --- /dev/null +++ b/src-tauri/src/mcp/scope.rs @@ -0,0 +1,100 @@ +//! Pure scope-control logic (FR-MCP-3), split out from `mcp/mod.rs` so it's +//! unit-testable without a DB, a running server, or the `mcp` cargo feature. +//! +//! Design note (documented here because the schema doesn't (yet) carry a +//! per-meeting "expose this meeting" flag -- only `feature_briefs.exposed` +//! does, per `docs/03-data-model.md`): with `ExposeScope::Selected`, meetings/ +//! transcripts/action-items have no selection mechanism to key off in this +//! milestone, so they are treated the same as `None` (deny) rather than the +//! same as `All` (allow) -- a privacy-conservative default consistent with +//! every other WA default (recording/sync/hosted-AI/MCP itself all default +//! OFF). Only `get_feature_brief` has real per-item selection today, via the +//! brief's own `exposed` flag (M1). A future "select meetings" UI/schema +//! addition should upgrade `Selected` for the other three tools without +//! changing this function's callers. + +use crate::mcp::ExposeScope; + +/// Whether `list_recent_meetings`/`get_transcript`/`get_action_items` may see +/// meetings at all under the current scope. `Selected` has no per-meeting +/// selection mechanism yet (see module docs) so it is conservatively treated +/// like `None`. +pub fn meetings_visible(scope: ExposeScope) -> bool { + matches!(scope, ExposeScope::All) +} + +/// Whether a specific feature brief may be served. `exposed` is the brief's +/// own per-item flag (`feature_briefs.exposed`, set via `set_brief_exposed`). +pub fn brief_visible(scope: ExposeScope, exposed: bool) -> bool { + match scope { + ExposeScope::None => false, + ExposeScope::Selected => exposed, + ExposeScope::All => true, + } +} + +/// Recordings (`.wav`) are never exposed unless explicitly allowed (FR-MCP-3), +/// independent of `ExposeScope`. None of the four MCP tools serve raw audio +/// bytes today, but a meeting that retained its recording (ADR-0009) is +/// treated as more sensitive-by-association: its transcript/action items are +/// also withheld unless the user opted into `expose_recordings`. Every tool +/// handler must call this for each candidate meeting -- there is no central +/// choke point (FR-MCP-3 "enforce in every tool handler"). +pub fn recording_gate_ok(expose_recordings: bool, meeting_recorded: bool) -> bool { + expose_recordings || !meeting_recorded +} + +/// Combined check a tool handler runs before including one meeting's data. +pub fn meeting_allowed( + scope: ExposeScope, + expose_recordings: bool, + meeting_recorded: bool, +) -> bool { + meetings_visible(scope) && recording_gate_ok(expose_recordings, meeting_recorded) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn none_hides_all_meetings() { + assert!(!meetings_visible(ExposeScope::None)); + } + + #[test] + fn selected_hides_meetings_pending_a_selection_mechanism() { + // Documented conservative choice -- see module docs. + assert!(!meetings_visible(ExposeScope::Selected)); + } + + #[test] + fn all_shows_meetings() { + assert!(meetings_visible(ExposeScope::All)); + } + + #[test] + fn brief_visibility_follows_the_exposed_flag_only_under_selected() { + assert!(!brief_visible(ExposeScope::None, true)); + assert!(!brief_visible(ExposeScope::Selected, false)); + assert!(brief_visible(ExposeScope::Selected, true)); + assert!(brief_visible(ExposeScope::All, false)); + assert!(brief_visible(ExposeScope::All, true)); + } + + #[test] + fn recordings_never_served_unless_explicitly_allowed() { + assert!(!recording_gate_ok(false, true)); + assert!(recording_gate_ok(false, false)); + assert!(recording_gate_ok(true, true)); + assert!(recording_gate_ok(true, false)); + } + + #[test] + fn meeting_allowed_requires_both_scope_and_recording_gate() { + assert!(!meeting_allowed(ExposeScope::All, false, true)); // recorded, not opted-in + assert!(meeting_allowed(ExposeScope::All, false, false)); // not recorded + assert!(meeting_allowed(ExposeScope::All, true, true)); // opted-in + assert!(!meeting_allowed(ExposeScope::None, true, false)); // scope still wins + } +} diff --git a/src-tauri/src/mcp/server.rs b/src-tauri/src/mcp/server.rs new file mode 100644 index 0000000..f9be83e --- /dev/null +++ b/src-tauri/src/mcp/server.rs @@ -0,0 +1,107 @@ +//! `RmcpServer` — the concrete `McpServer` implementation (T10.4). Owns the +//! one running transport (HTTP listener, if any) so `stop()` can tear it +//! down; a process-wide singleton (`instance`) is what `commands.rs` reaches +//! for, since Tauri command handlers are separate calls with no shared state +//! of their own beyond `AppState`. + +use crate::mcp::handler::WaMcpHandler; +use crate::mcp::{ + http_transport, token, McpConfig, McpError, McpHandle, McpServer, McpToolDescriptor, + McpTransport, +}; +use crate::storage::Store; +use async_trait::async_trait; +use std::sync::{Arc, OnceLock}; +use tauri::AppHandle; +use tokio::sync::Mutex; + +enum Running { + Http(http_transport::HttpServerHandle), + /// stdio has nothing running *in this process* — the agent spawns its + /// own `--mcp-stdio` child (see `mcp::stdio_transport`); this variant + /// just records "enabled" for `mcp_status`. + Stdio, +} + +pub struct RmcpServer { + store: Arc, + app: AppHandle, + running: Mutex>, +} + +impl RmcpServer { + pub fn new(store: Arc, app: AppHandle) -> Self { + Self { + store, + app, + running: Mutex::new(None), + } + } + + async fn stop_running(&self) { + if let Some(Running::Http(handle)) = self.running.lock().await.take() { + handle.stop().await; + } + } + + /// `true` once a `start()` has actually taken effect (HTTP listener bound + /// or stdio mode recorded) — used by `mcp_status`. + pub async fn is_running(&self) -> bool { + self.running.lock().await.is_some() + } +} + +#[async_trait] +impl McpServer for RmcpServer { + async fn start(&self, cfg: McpConfig) -> Result { + // Re-enabling (or switching transport/port) replaces whatever was running. + self.stop_running().await; + let auth_token = token::mint_and_store()?; + + match cfg.transport { + McpTransport::Http => { + let listener = http_transport::bind_loopback("127.0.0.1", cfg.port).await?; + let handler = WaMcpHandler::new(self.store.clone(), Some(self.app.clone())); + let handle = http_transport::serve(listener, auth_token.clone(), handler); + let endpoint = format!("http://{}/mcp", handle.local_addr); + *self.running.lock().await = Some(Running::Http(handle)); + Ok(McpHandle { + endpoint, + token: auth_token, + }) + } + McpTransport::Stdio => { + *self.running.lock().await = Some(Running::Stdio); + let exe = std::env::current_exe() + .ok() + .and_then(|p| p.to_str().map(str::to_string)) + .unwrap_or_else(|| "whispassist.exe".to_string()); + Ok(McpHandle { + endpoint: format!("{exe} --mcp-stdio"), + token: auth_token, + }) + } + } + } + + async fn stop(&self, _handle: McpHandle) -> Result<(), McpError> { + self.stop_running().await; + token::delete(); + Ok(()) + } + + fn tools(&self) -> Vec { + crate::mcp::TOOL_DESCRIPTORS.to_vec() + } +} + +static INSTANCE: OnceLock> = OnceLock::new(); + +/// The process-wide `RmcpServer`. `store`/`app` are only used on the first +/// call (they're the same `AppState`/`AppHandle` for the process's whole +/// life); later calls just return the existing instance. +pub fn instance(store: Arc, app: AppHandle) -> Arc { + INSTANCE + .get_or_init(|| Arc::new(RmcpServer::new(store, app))) + .clone() +} diff --git a/src-tauri/src/mcp/stdio_transport.rs b/src-tauri/src/mcp/stdio_transport.rs new file mode 100644 index 0000000..3447cf6 --- /dev/null +++ b/src-tauri/src/mcp/stdio_transport.rs @@ -0,0 +1,31 @@ +//! stdio transport (FR-MCP-6) — "a thin adapter the agent spawns". A coding +//! agent's MCP client config spawns `whispassist.exe --mcp-stdio` and talks +//! JSON-RPC over that child process's stdin/stdout; `main.rs` checks for that +//! flag before building the Tauri window and calls `serve_once` here instead. +//! +//! There is no bearer-token header to check here (unlike HTTP): the ability +//! to spawn this process at all already requires the same OS-level privilege +//! as running any other local command as the signed-in user, so process-spawn +//! capability is the trust boundary for stdio, same as other local-only MCP +//! servers. `set_mcp_enabled` still mints/stores a token (`mcp::token`) for +//! parity with the HTTP transport and in case a future stdio client wants to +//! pass it, but this transport does not require presenting it. + +use crate::mcp::handler::WaMcpHandler; +use crate::mcp::McpError; +use rmcp::ServiceExt; + +/// Serves one MCP session over the current process's stdin/stdout until the +/// peer disconnects, then returns. +pub async fn serve_once(handler: WaMcpHandler) -> Result<(), McpError> { + let transport = rmcp::transport::io::stdio(); + let running = handler + .serve(transport) + .await + .map_err(|e| McpError::Server(e.to_string()))?; + running + .waiting() + .await + .map_err(|e| McpError::Server(e.to_string()))?; + Ok(()) +} diff --git a/src-tauri/src/mcp/token.rs b/src-tauri/src/mcp/token.rs new file mode 100644 index 0000000..7303f39 --- /dev/null +++ b/src-tauri/src/mcp/token.rs @@ -0,0 +1,95 @@ +//! MCP auth-token storage (FR-MCP-1/6). The token itself is **never** written +//! to `settings.json`/`wa.db`/logs — only the OS credential store, exactly +//! like sync secrets (`sync::credentials`) and hosted-AI API keys. + +use crate::mcp::McpError; + +const SERVICE: &str = "WhispAssist-mcp"; +const ACCOUNT: &str = "token"; +const TOKEN_BYTES: usize = 32; + +fn entry() -> Result { + keyring::Entry::new(SERVICE, ACCOUNT).map_err(|e| McpError::Server(e.to_string())) +} + +/// Generates a fresh random token (hex-encoded, 64 chars) and persists it, +/// replacing whatever was there before (each `set_mcp_enabled` mints a new +/// one — there is no "reveal the existing token" path, same treatment as a +/// password). +pub fn mint_and_store() -> Result { + let mut buf = [0u8; TOKEN_BYTES]; + getrandom::getrandom(&mut buf).map_err(|e| McpError::Server(e.to_string()))?; + let token = hex_encode(&buf); + entry()? + .set_password(&token) + .map_err(|e| McpError::Server(e.to_string()))?; + Ok(token) +} + +/// Best-effort read for `mcp_status`'s `tokenSet` flag — never returned to +/// the frontend as a value, only whether one exists. +pub fn is_set() -> bool { + entry() + .and_then(|e| { + e.get_password() + .map_err(|e| McpError::Server(e.to_string())) + }) + .is_ok() +} + +pub fn get() -> Result { + entry()? + .get_password() + .map_err(|e| McpError::Server(e.to_string())) +} + +/// Best-effort cleanup on disable — a missing entry is not an error. +pub fn delete() { + if let Ok(e) = entry() { + match e.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => {} + Err(err) => tracing::warn!("failed to delete MCP token: {err}"), + } + } +} + +/// Constant-time comparison so token checking doesn't leak timing +/// information about how many leading bytes matched (NFR-SEC-5). +pub fn verify(presented: &str, expected: &str) -> bool { + let a = presented.as_bytes(); + let b = expected.as_bytes(); + if a.len() != b.len() { + return false; + } + let mut diff = 0u8; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + diff == 0 +} + +fn hex_encode(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push_str(&format!("{b:02x}")); + } + s +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn verify_requires_exact_match() { + assert!(verify("abc123", "abc123")); + assert!(!verify("abc123", "abc124")); + assert!(!verify("abc12", "abc123")); + assert!(!verify("", "abc123")); + } + + #[test] + fn hex_encode_is_lowercase_and_fixed_width() { + assert_eq!(hex_encode(&[0, 255, 16]), "00ff10"); + } +} diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 7ba4a31..8ef842b 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -226,6 +226,33 @@ pub struct Settings { // default capture device. #[serde(default)] pub audio_input_device: Option, + // Local MCP server (Phase 10b, ADR-0011, FR-MCP-1). OFF by default; the + // auth token itself is NEVER stored here — only in the OS credential + // store (see `mcp::token`). `mcp_expose` is one of none|selected|all; + // `mcp_expose_recordings` gates access to meetings with retained audio + // (ADR-0009) regardless of `mcp_expose` (FR-MCP-3). + #[serde(default)] + pub mcp_enabled: bool, + #[serde(default = "default_mcp_transport")] + pub mcp_transport: String, // http|stdio + #[serde(default = "default_mcp_port")] + pub mcp_port: u16, + #[serde(default = "default_mcp_expose")] + pub mcp_expose: String, // none|selected|all + #[serde(default)] + pub mcp_expose_recordings: bool, +} + +fn default_mcp_transport() -> String { + "http".into() +} + +fn default_mcp_port() -> u16 { + 4849 +} + +fn default_mcp_expose() -> String { + "none".into() } /// serde default for a `bool` field that should be `true` when absent from an diff --git a/src-tauri/src/storage/mod.rs b/src-tauri/src/storage/mod.rs index d18ebba..a3e1764 100644 --- a/src-tauri/src/storage/mod.rs +++ b/src-tauri/src/storage/mod.rs @@ -5,8 +5,9 @@ //! summary) are regenerable; retention never touches an in-progress meeting. use crate::models::{ - ActionItem, CalendarEvent, ContextExcerpt, FeatureBriefInfo, ImportedEvent, MeetingId, - MeetingListItem, MeetingStatus, Participant, SearchHit, SpeakerInfo, TranscriptSegment, + ActionItem, CalendarEvent, ContextExcerpt, FeatureBriefInfo, ImportedEvent, McpAccessEntry, + MeetingId, MeetingListItem, MeetingStatus, Participant, SearchHit, SpeakerInfo, + TranscriptSegment, }; use crate::paths; use async_trait::async_trait; @@ -373,6 +374,30 @@ pub trait Store: Send + Sync { async fn get_feature_brief_row(&self, id: &str) -> Result; /// Scope control: include/exclude a brief from the MCP server (FR-MCP-3). async fn set_brief_exposed(&self, id: &str, exposed: bool) -> Result<(), StoreError>; + + // ---- MCP server (Phase 10b, ADR-0011) ---- + /// Confirmed action items for a meeting (FR-MCP-2 `get_action_items`) — + /// distinct from `list_pending_reminders` (which is filtered to + /// unfired-reminder rows across *all* meetings for the startup reconcile). + async fn list_action_items( + &self, + meeting_id: &MeetingId, + ) -> Result, StoreError>; + /// Appends one row to the audit log (FR-MCP-5). Every MCP tool read calls + /// this, regardless of whether the read was actually allowed to see + /// anything — the audit trail is "what an agent asked for", not just + /// "what it received". + async fn record_mcp_access( + &self, + tool: &str, + meeting_id: Option<&MeetingId>, + client: Option<&str>, + ) -> Result<(), StoreError>; + /// Most recent audit rows first, optionally capped (`mcp_access_log` command). + async fn list_mcp_access_log( + &self, + limit: Option, + ) -> Result, StoreError>; } /// SQLite-backed store. Migrations live in `migrations/` (`sqlx::migrate!`). @@ -1620,6 +1645,71 @@ impl Store for SqliteStore { } Ok(()) } + + async fn list_action_items( + &self, + meeting_id: &MeetingId, + ) -> Result, StoreError> { + let rows = sqlx::query( + "SELECT id, text, owner, due_at, confirmed, reminder_set FROM action_items \ + WHERE meeting_id = ? ORDER BY created_at ASC", + ) + .bind(meeting_id) + .fetch_all(&self.pool) + .await?; + Ok(rows + .iter() + .map(|r| ActionItem { + id: Some(r.get("id")), + text: r.get("text"), + owner: r.get("owner"), + due_at: r.get("due_at"), + confirmed: r.get::("confirmed") != 0, + reminder_set: r.get::("reminder_set") != 0, + }) + .collect()) + } + + async fn record_mcp_access( + &self, + tool: &str, + meeting_id: Option<&MeetingId>, + client: Option<&str>, + ) -> Result<(), StoreError> { + sqlx::query( + "INSERT INTO mcp_access_log (id, at, tool, meeting_id, client) VALUES (?,?,?,?,?)", + ) + .bind(uuid::Uuid::new_v4().to_string()) + .bind(now_unix()) + .bind(tool) + .bind(meeting_id) + .bind(client) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn list_mcp_access_log( + &self, + limit: Option, + ) -> Result, StoreError> { + let cap: i64 = limit.map(i64::from).unwrap_or(200).max(1); + let rows = sqlx::query( + "SELECT at, tool, meeting_id, client FROM mcp_access_log ORDER BY at DESC LIMIT ?", + ) + .bind(cap) + .fetch_all(&self.pool) + .await?; + Ok(rows + .iter() + .map(|r| McpAccessEntry { + at: r.get("at"), + tool: r.get("tool"), + meeting_id: r.get("meeting_id"), + client: r.get("client"), + }) + .collect()) + } } /// Read a derived artifact, transparently decrypting it if the vault sealed it diff --git a/src/lib/api.ts b/src/lib/api.ts index bd734b6..428d998 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -289,6 +289,10 @@ export interface AppSettings { consent_acknowledged: boolean; sync_enabled: boolean; mcp_enabled: boolean; + mcp_transport: string; // http|stdio + mcp_port: number; + mcp_expose: string; // none|selected|all + mcp_expose_recordings: boolean; retention_max_age_days: number | null; retention_max_size_gb: number | null; pst_last_path: string | null; @@ -325,6 +329,15 @@ export interface McpAccessEntry { client: string | null; } +// mcp_status() response (FR-MCP-1/6). `endpoint` is empty while disabled. +export interface McpStatus { + enabled: boolean; + transport: "http" | "stdio"; + endpoint: string; + tokenSet: boolean; + exposeScope: "none" | "selected" | "all"; +} + // ---- Commands ---- export const api = { // `record` controls audio RETENTION (default false / off — ADR-0009). @@ -455,7 +468,7 @@ export const api = { getFeatureBrief: (id: string) => invoke("get_feature_brief", { id }), setBriefExposed: (id: string, exposed: boolean) => invoke("set_brief_exposed", { id, exposed }), - mcpStatus: () => invoke("mcp_status"), + mcpStatus: () => invoke("mcp_status"), setMcpEnabled: (enabled: boolean, transport?: "http" | "stdio", port?: number) => invoke<{ endpoint: string; token: string }>("set_mcp_enabled", { enabled, transport, port }), setMcpScope: (expose: "none" | "selected" | "all", exposeRecordings?: boolean) => @@ -538,8 +551,11 @@ export const events = { onSyncLinked: ( cb: (p: { ok: boolean; kind: string; error?: string }) => void, ): Promise => listen("sync://linked", (e) => cb(e.payload as never)), - onMcpAccess: (cb: (p: McpAccessEntry & { client?: string }) => void): Promise => - listen("mcp://access", (e) => cb(e.payload as never)), + // Live tail of the FR-MCP-5 audit log (camelCase on the wire, unlike the + // snake_case McpAccessEntry rows `mcpAccessLog()` returns). + onMcpAccess: ( + cb: (p: { at: number; tool: string; meetingId?: MeetingId; client?: string }) => void, + ): Promise => listen("mcp://access", (e) => cb(e.payload as never)), onAgentProgress: ( cb: (p: { briefId: string; tool: string; line: string }) => void, ): Promise => listen("agent://progress", (e) => cb(e.payload as never)), diff --git a/src/lib/stores/settings.svelte.ts b/src/lib/stores/settings.svelte.ts index bd3b0e9..57aa534 100644 --- a/src/lib/stores/settings.svelte.ts +++ b/src/lib/stores/settings.svelte.ts @@ -15,6 +15,8 @@ import { type LlmStatus, type ModelInfo, type PrivacySelfCheck, + type McpStatus, + type McpAccessEntry, } from "../api"; const DEFAULT_SETTINGS: AppSettings = { @@ -30,6 +32,10 @@ const DEFAULT_SETTINGS: AppSettings = { consent_acknowledged: false, sync_enabled: false, // sync OFF by default (ADR-0010) mcp_enabled: false, // MCP server OFF by default (ADR-0011) + mcp_transport: "http", + mcp_port: 4849, + mcp_expose: "none", // scope OFF by default (FR-MCP-3) + mcp_expose_recordings: false, retention_max_age_days: null, // no cap by default (FR-STORE-2) retention_max_size_gb: null, pst_last_path: null, @@ -58,6 +64,15 @@ class SettingsStore { // Privacy self-check (T7.6, FR-SEC-2). privacy = $state(null); + // MCP server (Phase 10b, ADR-0011). + mcpStatus = $state(null); + mcpAccessLog = $state([]); + mcpSaving = $state(false); + /** The freshly-minted token from the last `setMcpEnabled(true)` call — + * shown exactly once (it is never re-readable afterwards, same as any + * other newly-issued secret). Cleared on disable or when the panel closes. */ + mcpLastToken = $state(null); + // LLM provider status (T5.2, FR-LLM-1). llmStatus = $state(null); llmSaving = $state(false); @@ -82,6 +97,16 @@ class SettingsStore { await this.loadModels(); await this.loadPrivacy(); await this.loadLlmStatus(); + await this.loadMcpStatus(); + await this.loadMcpAccessLog(); + // Live tail of the FR-MCP-5 audit log — every tool read an agent makes + // while the panel is open shows up immediately, not just on refresh. + await events.onMcpAccess(({ at, tool, meetingId, client }) => { + this.mcpAccessLog = [ + { at, tool, meeting_id: meetingId ?? null, client: client ?? null }, + ...this.mcpAccessLog, + ].slice(0, 50); + }); await events.onHardwareChanged(({ active }) => { if (this.hardware) this.hardware.active = active; }); @@ -175,6 +200,49 @@ class SettingsStore { } } + async loadMcpStatus() { + try { + this.mcpStatus = await api.mcpStatus(); + } catch { + this.mcpStatus = null; + } + } + + async loadMcpAccessLog(limit = 50) { + try { + this.mcpAccessLog = await api.mcpAccessLog(limit); + } catch { + this.mcpAccessLog = []; + } + } + + /** Enable/disable the loopback MCP server (FR-MCP-1/6). On enable, the + * returned token is stashed in `mcpLastToken` for the one-time reveal. */ + async setMcpEnabled(enabled: boolean, transport?: "http" | "stdio", port?: number) { + this.mcpSaving = true; + try { + const res = await api.setMcpEnabled(enabled, transport, port); + this.mcpLastToken = enabled ? res.token : null; + } catch { + this.backendStub = true; + } finally { + this.mcpSaving = false; + } + await this.loadMcpStatus(); + await this.loadPrivacy(); + } + + /** Scope control (FR-MCP-3) — takes effect immediately, no restart needed. */ + async setMcpScope(expose: "none" | "selected" | "all", exposeRecordings?: boolean) { + try { + await api.setMcpScope(expose, exposeRecordings); + } catch { + this.backendStub = true; + } + await this.loadMcpStatus(); + await this.loadPrivacy(); + } + async loadLlmStatus() { try { this.llmStatus = await api.llmStatus(); diff --git a/src/lib/views/Settings.svelte b/src/lib/views/Settings.svelte index a559ea3..c4de083 100644 --- a/src/lib/views/Settings.svelte +++ b/src/lib/views/Settings.svelte @@ -25,6 +25,9 @@ ChevronRight, RotateCcw, Info, + Bot, + Copy, + KeyRound, } from "@lucide/svelte"; import { OLLAMA_OPTIONS, @@ -37,7 +40,7 @@ let { onClose }: { onClose: () => void } = $props(); let section = $state< - "recording" | "hardware" | "storage" | "calendar" | "sync" | "ai" | "privacy" | "about" + "recording" | "hardware" | "storage" | "calendar" | "sync" | "ai" | "mcp" | "privacy" | "about" >("recording"); // ---- About (version + build commit + source) ---- @@ -83,6 +86,49 @@ } } + // ---- MCP server (Phase 10b, ADR-0011) ---- + let mcpTransport = $state<"http" | "stdio">( + settings.settings.mcp_transport === "stdio" ? "stdio" : "http", + ); + let mcpPort = $state(settings.settings.mcp_port); + let mcpExpose = $state<"none" | "selected" | "all">( + (settings.settings.mcp_expose as "none" | "selected" | "all") ?? "none", + ); + let mcpExposeRecordings = $state(settings.settings.mcp_expose_recordings); + let mcpCopied = $state<"endpoint" | "token" | null>(null); + $effect(() => { + // Re-sync the form when settings (re)load, same pattern as the LLM form above. + mcpTransport = settings.settings.mcp_transport === "stdio" ? "stdio" : "http"; + mcpPort = settings.settings.mcp_port; + mcpExpose = (settings.settings.mcp_expose as "none" | "selected" | "all") ?? "none"; + mcpExposeRecordings = settings.settings.mcp_expose_recordings; + }); + async function toggleMcpEnabled(enabled: boolean) { + await settings.setMcpEnabled(enabled, mcpTransport, mcpPort); + } + async function saveMcpScope() { + await settings.setMcpScope(mcpExpose, mcpExposeRecordings); + } + async function copyToClipboard(text: string, what: "endpoint" | "token") { + try { + await navigator.clipboard.writeText(text); + mcpCopied = what; + setTimeout(() => { + if (mcpCopied === what) mcpCopied = null; + }, 2000); + } catch { + /* clipboard API unavailable — the value is still selectable/copyable by hand */ + } + } + function relativeTime(atMs: number): string { + const diffSec = Math.round((Date.now() - atMs) / 1000); + if (diffSec < 5) return "just now"; + if (diffSec < 60) return `${diffSec}s ago`; + if (diffSec < 3600) return `${Math.round(diffSec / 60)}m ago`; + if (diffSec < 86400) return `${Math.round(diffSec / 3600)}h ago`; + return new Date(atMs).toLocaleString(); + } + // ---- Advanced Ollama configuration (sparse; only overrides are stored) ---- const OPTION_GROUPS: OllamaGroup[] = ["sampling", "repetition", "mirostat", "context"]; type OptVal = number | boolean | string[]; @@ -489,6 +535,9 @@ + @@ -1191,6 +1240,146 @@ {/if} + {:else if section === "mcp"} +
+

MCP server

+ + + +

+ Off by default. Loopback-only, token-gated — nothing is reachable from the network. +

+ +
+ + {#if mcpTransport === "http"} + + {/if} +
+ {#if settings.mcpStatus?.enabled} +

+ To change transport/port, turn the server off first, then back on. +

+ {/if} + +

Scope

+
+ +
+ + + {#if settings.mcpLastToken} +
+
+
+

+ This won't be shown again. It's stored in your OS credential store; if you lose it, + turn the server off and back on to mint a new one. +

+
+ {settings.mcpLastToken} + +
+
+ {/if} + + {#if settings.mcpStatus?.enabled} +
+ Endpoint{settings.mcpStatus.endpoint} + +
+

+ Point your agent's MCP client config at this + {settings.mcpStatus.transport === "stdio" ? "command" : "URL"}, with the token above as + a bearer credential. +

+ {/if} + +

Access log

+

Every tool read an agent makes, allowed or denied (FR-MCP-5).

+ {#if settings.mcpAccessLog.length === 0} +

No agent has read anything yet.

+ {:else} +
    + {#each settings.mcpAccessLog as entry, i (entry.at + "-" + i)} +
  • + {entry.tool} + {#if entry.meeting_id}meeting {entry.meeting_id.slice(0, 8)}{/if} + {#if entry.client}{entry.client}{/if} + {relativeTime(entry.at)} +
  • + {/each} +
+ + {/if} +
{:else if section === "privacy"}

Privacy

@@ -1210,6 +1399,18 @@ {settings.privacy.syncEnabled ? "enabled" : "off"} +
+ MCP server + {settings.mcpStatus?.enabled ? `on · ${settings.mcpStatus.exposeScope}` : "off"} + +
+ {#if settings.mcpStatus?.enabled} +

+

+ {/if}

Egress allowlist

{#if settings.privacy.allowlistedHosts.length === 0} @@ -1660,6 +1861,18 @@ ul.hosts li { padding: 0.2rem 0; } + .mcp-token { + margin: 0.6rem 0; + padding: 0.6rem 0.75rem; + border: 1px solid var(--warning, #d97706); + border-radius: 8px; + display: flex; + flex-direction: column; + gap: 0.35rem; + } + .mcp-token code { + word-break: break-all; + } /* ---- AI provider: status + advanced config ---- */ .ghost {