Merge branch 'worktree-agent-af937df502b61b006' into feature_chore_bug_005
# Conflicts: # src-tauri/src/storage/mod.rs
This commit is contained in:
commit
2582d8947f
17 files changed
+1644
-69
No files matched your search
Generated
+81
-6
@@ -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
@@ -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
@@ -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) ----
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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.",
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user