Merge branch 'worktree-agent-af937df502b61b006' into feature_chore_bug_005

# Conflicts:
#	src-tauri/src/storage/mod.rs
This commit is contained in:
iamdoubz committed 2026-07-07 01:15:08 -05:00
commit 2582d8947f
17 files changed
+1644 -69

No files matched your search

+81 -6
View File
@@ -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",
+25 -2
View File
@@ -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)
+135 -15
View File
@@ -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<FeatureBrief> {
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<dyn Store>`, not a live Tauri `State`) can call the same logic.
pub(crate) async fn get_feature_brief_core(
store: &std::sync::Arc<dyn crate::storage::Store>,
id: &str,
) -> WaResult<FeatureBrief> {
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<serde_json::Value> {
Err(not_implemented("mcp_status"))
pub async fn mcp_status(state: State<'_, AppState>, app: AppHandle) -> WaResult<serde_json::Value> {
#[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:<port>/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<String>,
_port: Option<u16>,
state: State<'_, AppState>,
app: AppHandle,
enabled: bool,
transport: Option<String>,
port: Option<u16>,
) -> WaResult<serde_json::Value> {
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<bool>) -> WaResult<()> {
Err(not_implemented("set_mcp_scope"))
pub async fn set_mcp_scope(expose: String, expose_recordings: Option<bool>) -> 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<u32>) -> WaResult<Vec<McpAccessEntry>> {
Err(not_implemented("mcp_access_log"))
pub async fn mcp_access_log(
state: State<'_, AppState>,
limit: Option<u32>,
) -> WaResult<Vec<McpAccessEntry>> {
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) ----
+46
View File
@@ -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<dyn storage::Store> =
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);
}
}
+9
View File
@@ -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();
}
+346
View File
@@ -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<Store>` 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<dyn Store>,
app: Option<AppHandle>,
}
impl WaMcpHandler {
pub fn new(store: Arc<dyn Store>, app: Option<AppHandle>) -> 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<RoleServer>) -> Option<String> {
context
.peer
.peer_info()
.map(|info| info.client_info.name.clone())
}
async fn tool_list_recent_meetings(
&self,
args: &Option<JsonObject>,
client: Option<&str>,
) -> Result<CallToolResult, McpProtoError> {
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<JsonObject>,
client: Option<&str>,
) -> Result<CallToolResult, McpProtoError> {
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<JsonObject>,
client: Option<&str>,
) -> Result<CallToolResult, McpProtoError> {
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<JsonObject>,
client: Option<&str>,
) -> Result<CallToolResult, McpProtoError> {
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<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListToolsResult, McpProtoError> {
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<RoleServer>,
) -> Result<CallToolResult, McpProtoError> {
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<JsonObject> {
Arc::new(value.as_object().cloned().unwrap_or_default())
}
fn arg_str(args: &Option<JsonObject>, key: &str) -> Option<String> {
args.as_ref()?.get(key)?.as_str().map(str::to_string)
}
fn arg_u64(args: &Option<JsonObject>, key: &str) -> Option<u64> {
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()
}
+168
View File
@@ -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<TcpListener, McpError> {
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 <token>` 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<Incoming>| {
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<Incoming>,
svc: &mut StreamableHttpService<WaMcpHandler>,
expected_token: &str,
) -> Response<BoxBody<Bytes, Infallible>> {
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<Incoming>, 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<BoxBody<Bytes, Infallible>> {
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());
}
}
+81 -40
View File
@@ -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:<port>/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<FeatureBrief, BriefError>;
}
/// 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<McpHandle, McpError> {
// 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<McpToolDescriptor> {
// 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.",
},
]
}
}
+100
View File
@@ -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
}
}
+107
View File
@@ -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<dyn Store>,
app: AppHandle,
running: Mutex<Option<Running>>,
}
impl RmcpServer {
pub fn new(store: Arc<dyn Store>, 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<McpHandle, McpError> {
// 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<McpToolDescriptor> {
crate::mcp::TOOL_DESCRIPTORS.to_vec()
}
}
static INSTANCE: OnceLock<Arc<RmcpServer>> = 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<dyn Store>, app: AppHandle) -> Arc<RmcpServer> {
INSTANCE
.get_or_init(|| Arc::new(RmcpServer::new(store, app)))
.clone()
}
+31
View File
@@ -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(())
}
+95
View File
@@ -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, McpError> {
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<String, McpError> {
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<String, McpError> {
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");
}
}
+27
View File
@@ -226,6 +226,33 @@ pub struct Settings {
// default capture device.
#[serde(default)]
pub audio_input_device: Option<String>,
// 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
+92 -2
View File
@@ -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<FeatureBriefRow, StoreError>;
/// 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<Vec<ActionItem>, 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<u32>,
) -> Result<Vec<McpAccessEntry>, 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<Vec<ActionItem>, 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::<i64, _>("confirmed") != 0,
reminder_set: r.get::<i64, _>("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<u32>,
) -> Result<Vec<McpAccessEntry>, 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
+19 -3
View File
@@ -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<FeatureBrief>("get_feature_brief", { id }),
setBriefExposed: (id: string, exposed: boolean) =>
invoke<void>("set_brief_exposed", { id, exposed }),
mcpStatus: () => invoke("mcp_status"),
mcpStatus: () => invoke<McpStatus>("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<UnlistenFn> => listen("sync://linked", (e) => cb(e.payload as never)),
onMcpAccess: (cb: (p: McpAccessEntry & { client?: string }) => void): Promise<UnlistenFn> =>
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<UnlistenFn> => listen("mcp://access", (e) => cb(e.payload as never)),
onAgentProgress: (
cb: (p: { briefId: string; tool: string; line: string }) => void,
): Promise<UnlistenFn> => listen("agent://progress", (e) => cb(e.payload as never)),
+68
View File
@@ -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<PrivacySelfCheck | null>(null);
// MCP server (Phase 10b, ADR-0011).
mcpStatus = $state<McpStatus | null>(null);
mcpAccessLog = $state<McpAccessEntry[]>([]);
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<string | null>(null);
// LLM provider status (T5.2, FR-LLM-1).
llmStatus = $state<LlmStatus | null>(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();
+214 -1
View File
@@ -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 @@
<button class:active={section === "ai"} onclick={() => (section = "ai")}>
<Sparkles size={14} aria-hidden="true" /> AI
</button>
<button class:active={section === "mcp"} onclick={() => (section = "mcp")}>
<Bot size={14} aria-hidden="true" /> MCP server
</button>
<button class:active={section === "privacy"} onclick={() => (section = "privacy")}>
<ShieldCheck size={14} aria-hidden="true" /> Privacy
</button>
@@ -1191,6 +1240,146 @@
</div>
{/if}
</section>
{:else if section === "mcp"}
<section>
<h3>MCP server</h3>
<div class="banner">
<AlertTriangle size={14} aria-hidden="true" />
<span>
This lets your own coding agent (Claude Code, Codex, Copilot, OpenCode, …) pull meeting
context on your local machine. Once connected, <strong>that agent</strong> may forward
what it reads to its own model provider's cloud — outside WhispAssist's control.
WhispAssist itself never sends this data anywhere; the server only listens on this
device (<code>127.0.0.1</code>) and every read is logged below.
</span>
</div>
<label class="row">
<input
type="checkbox"
checked={settings.mcpStatus?.enabled ?? false}
disabled={settings.mcpSaving}
onchange={(e) => toggleMcpEnabled((e.target as HTMLInputElement).checked)}
/>
<span>Enable the MCP server</span>
</label>
<p class="muted">
Off by default. Loopback-only, token-gated — nothing is reachable from the network.
</p>
<div class="grid">
<label
>Transport
<select bind:value={mcpTransport} disabled={settings.mcpStatus?.enabled}>
<option value="http">Streamable HTTP</option>
<option value="stdio">stdio (agent spawns a process)</option>
</select>
</label>
{#if mcpTransport === "http"}
<label
>Port<input
type="number"
min="1"
max="65535"
bind:value={mcpPort}
disabled={settings.mcpStatus?.enabled}
/></label
>
{/if}
</div>
{#if settings.mcpStatus?.enabled}
<p class="muted small">
To change transport/port, turn the server off first, then back on.
</p>
{/if}
<h4>Scope</h4>
<div class="grid">
<label
>Expose
<select bind:value={mcpExpose} onchange={saveMcpScope}>
<option value="none">None — nothing is shared</option>
<option value="selected">Selected — only feature briefs you've marked shared</option>
<option value="all"
>All — meetings, transcripts, action items, and shared briefs</option
>
</select>
</label>
</div>
<label class="row small">
<input
type="checkbox"
bind:checked={mcpExposeRecordings}
disabled={mcpExpose === "none"}
onchange={saveMcpScope}
/>
<span
>Also allow meetings with a saved recording (off by default — a recorded meeting's
transcript is withheld even in "All" scope until this is on)</span
>
</label>
{#if settings.mcpLastToken}
<div class="mcp-token" aria-live="polite">
<div class="row">
<KeyRound size={14} aria-hidden="true" />
<strong>New auth token — shown once, copy it now</strong>
</div>
<p class="muted small">
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.
</p>
<div class="row">
<code>{settings.mcpLastToken}</code>
<button
class="link"
onclick={() => copyToClipboard(settings.mcpLastToken ?? "", "token")}
>
<Copy size={13} aria-hidden="true" />
{mcpCopied === "token" ? "Copied" : "Copy"}
</button>
</div>
</div>
{/if}
{#if settings.mcpStatus?.enabled}
<div class="row">
Endpoint<code>{settings.mcpStatus.endpoint}</code>
<button
class="link"
onclick={() => copyToClipboard(settings.mcpStatus?.endpoint ?? "", "endpoint")}
>
<Copy size={13} aria-hidden="true" />
{mcpCopied === "endpoint" ? "Copied" : "Copy"}
</button>
</div>
<p class="muted small">
Point your agent's MCP client config at this
{settings.mcpStatus.transport === "stdio" ? "command" : "URL"}, with the token above as
a bearer credential.
</p>
{/if}
<h4>Access log</h4>
<p class="muted">Every tool read an agent makes, allowed or denied (FR-MCP-5).</p>
{#if settings.mcpAccessLog.length === 0}
<p class="muted">No agent has read anything yet.</p>
{:else}
<ul class="events">
{#each settings.mcpAccessLog as entry, i (entry.at + "-" + i)}
<li>
<code>{entry.tool}</code>
{#if entry.meeting_id}<span class="host"
>meeting {entry.meeting_id.slice(0, 8)}</span
>{/if}
{#if entry.client}<span class="badge">{entry.client}</span>{/if}
<span class="host">{relativeTime(entry.at)}</span>
</li>
{/each}
</ul>
<button class="link" onclick={() => settings.loadMcpAccessLog()}>Refresh</button>
{/if}
</section>
{:else if section === "privacy"}
<section>
<h3>Privacy</h3>
@@ -1210,6 +1399,18 @@
{settings.privacy.syncEnabled ? "enabled" : "off"}
</span>
</div>
<div class="row">
MCP server<span class="badge" class:busy={settings.mcpStatus?.enabled}>
{settings.mcpStatus?.enabled ? `on · ${settings.mcpStatus.exposeScope}` : "off"}
</span>
</div>
{#if settings.mcpStatus?.enabled}
<p class="muted confirm">
<Check size={14} aria-hidden="true" />
Inbound on loopback only — it adds nothing to the egress list above. A connected agent may
still forward what it reads to its own model provider; see the MCP server tab.
</p>
{/if}
<h4>Egress allowlist</h4>
{#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 {