From 0ecc72f18f4283462b8f9d3c82615f2c78fac224 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:26:11 -0500 Subject: [PATCH] feat(mcp): wire mcp_status/set_mcp_enabled/set_mcp_scope/mcp_access_log (T10.4) Replaces the four not_implemented() stubs. mcp_status/set_mcp_enabled reach the process-wide RmcpServer singleton (behind `#[cfg(feature = "mcp")]`, with a not_implemented fallback for builds without it); set_mcp_scope/mcp_access_log are plain settings/Store I/O and work in every build regardless of the `mcp` cargo feature. The token is only ever returned once, right when set_mcp_enabled mints it -- it is never re-readable afterwards, same as any other freshly-issued secret. --- src-tauri/src/commands.rs | 125 ++++++++++++++++++++++++++++++++++---- 1 file changed, 114 insertions(+), 11 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 059f976..b4add3d 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -3135,29 +3135,132 @@ pub async fn set_brief_exposed(_id: String, _exposed: bool) -> WaResult<()> { } #[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) ----