From d14a6766e59cc8e9a07340bb56d103e34a00011f Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:03:21 -0500 Subject: [PATCH 001/103] feat(briefs): storage layer for feature_briefs (T10.6, M1.1) --- src-tauri/src/storage/mod.rs | 141 ++++++++++++++++++++++++++++++++++- 1 file changed, 139 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/storage/mod.rs b/src-tauri/src/storage/mod.rs index eb10ddc..609b4da 100644 --- a/src-tauri/src/storage/mod.rs +++ b/src-tauri/src/storage/mod.rs @@ -5,8 +5,8 @@ //! summary) are regenerable; retention never touches an in-progress meeting. use crate::models::{ - ActionItem, CalendarEvent, ImportedEvent, MeetingId, MeetingListItem, MeetingStatus, - Participant, SearchHit, SpeakerInfo, TranscriptSegment, + ActionItem, CalendarEvent, ContextExcerpt, FeatureBriefInfo, ImportedEvent, MeetingId, + MeetingListItem, MeetingStatus, Participant, SearchHit, SpeakerInfo, TranscriptSegment, }; use crate::paths; use async_trait::async_trait; @@ -128,6 +128,64 @@ pub struct Retention { pub max_size_gb: Option, } +/// A feature brief distilled from a meeting (ADR-0011, M1). Holds only a +/// `credential_ref`-style pointer (`path`) into the meeting's `briefs/` +/// folder — the JSON body is the source of truth; this row is the index +/// `list_feature_briefs`/the MCP scope check reads. Maps 1:1 to the +/// `feature_briefs` table. +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct FeatureBriefRow { + pub id: String, + pub meeting_id: MeetingId, + pub title: String, + pub target_repo: Option, + pub path: String, // briefs/.json, relative to the meeting's folder + pub exposed: bool, + pub created_at: i64, +} + +impl From for FeatureBriefInfo { + fn from(row: FeatureBriefRow) -> Self { + FeatureBriefInfo { + id: row.id, + meeting_id: row.meeting_id, + title: row.title, + target_repo: row.target_repo, + exposed: row.exposed, + } + } +} + +/// On-disk shape of `briefs/.json` (`docs/03-data-model.md`, ADR-0011) — +/// the schema/provenance envelope around the same fields as the IPC +/// `FeatureBrief` (`models.rs`). Written by `create_feature_brief` (M1.4), +/// sealed at rest with the vault when unlocked, exactly like `summary.json`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BriefFile { + pub schema: u32, + pub id: String, + pub meeting_id: MeetingId, + pub generated_at: i64, + pub provider: String, + pub model: String, + pub title: String, + pub problem: String, + pub desired_outcome: String, + pub acceptance_criteria: Vec, + pub target_repo: Option, + pub context_excerpts: Vec, + pub source: BriefSource, +} + +/// `schema`-envelope companion recording which meeting this brief came from, +/// by name and time — distinct from the FK `meeting_id`, which can outlive a +/// renamed/retitled meeting. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BriefSource { + pub meeting_title: String, + pub at: i64, +} + /// A configured upload destination as stored in the DB (ADR-0010). Holds only a /// `credential_ref` into the OS credential store — never the secret itself /// (FR-SYNC-6). Maps 1:1 to the `sync_targets` table. @@ -297,6 +355,24 @@ pub trait Store: Send + Sync { &self, meeting_id: Option<&MeetingId>, ) -> Result, StoreError>; + + // ---- Feature briefs (Phase 10 M1, ADR-0011) ---- + /// Indexes a brief already sealed to disk by `create_feature_brief` + /// (M1.4). Deletion cascades via the meeting FK — `delete_meeting` + /// already drops the row (and its folder) with the rest of the meeting. + async fn insert_feature_brief(&self, row: FeatureBriefRow) -> Result<(), StoreError>; + /// Newest first; `None` lists across all meetings (the MCP "recent + /// briefs" surface and the frontend's per-meeting list share this call). + async fn list_feature_briefs( + &self, + meeting_id: Option<&MeetingId>, + ) -> Result, StoreError>; + /// The full row (incl. `path`) so a caller can resolve and read the + /// sealed JSON file itself (`get_feature_brief`, MCP `get_feature_brief` + /// tool). + async fn get_feature_brief_row(&self, id: &str) -> Result; + /// Scope control: include/exclude a brief from the MCP server (FR-MCP-3). + async fn set_brief_exposed(&self, id: &str, exposed: bool) -> Result<(), StoreError>; } /// SQLite-backed store. Migrations live in `migrations/` (`sqlx::migrate!`). @@ -1484,6 +1560,67 @@ impl Store for SqliteStore { }; Ok(rows) } + + async fn insert_feature_brief(&self, row: FeatureBriefRow) -> Result<(), StoreError> { + sqlx::query( + "INSERT INTO feature_briefs (id, meeting_id, title, target_repo, path, exposed, created_at) \ + VALUES (?,?,?,?,?,?,?)", + ) + .bind(&row.id) + .bind(&row.meeting_id) + .bind(&row.title) + .bind(&row.target_repo) + .bind(&row.path) + .bind(row.exposed) + .bind(row.created_at) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn list_feature_briefs( + &self, + meeting_id: Option<&MeetingId>, + ) -> Result, StoreError> { + let rows = match meeting_id { + Some(m) => { + sqlx::query_as::<_, FeatureBriefRow>( + "SELECT * FROM feature_briefs WHERE meeting_id = ? ORDER BY created_at DESC", + ) + .bind(m) + .fetch_all(&self.pool) + .await? + } + None => { + sqlx::query_as::<_, FeatureBriefRow>( + "SELECT * FROM feature_briefs ORDER BY created_at DESC", + ) + .fetch_all(&self.pool) + .await? + } + }; + Ok(rows.into_iter().map(FeatureBriefInfo::from).collect()) + } + + async fn get_feature_brief_row(&self, id: &str) -> Result { + sqlx::query_as::<_, FeatureBriefRow>("SELECT * FROM feature_briefs WHERE id = ?") + .bind(id) + .fetch_optional(&self.pool) + .await? + .ok_or_else(|| StoreError::NotFound(format!("feature brief {id}"))) + } + + async fn set_brief_exposed(&self, id: &str, exposed: bool) -> Result<(), StoreError> { + let res = sqlx::query("UPDATE feature_briefs SET exposed = ? WHERE id = ?") + .bind(exposed) + .bind(id) + .execute(&self.pool) + .await?; + if res.rows_affected() == 0 { + return Err(StoreError::NotFound(format!("feature brief {id}"))); + } + Ok(()) + } } /// Read a derived artifact, transparently decrypting it if the vault sealed it -- 2.34.1 From a7fda9584336f06f1d6b928292e290395a9db8a0 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:03:22 -0500 Subject: [PATCH 002/103] feat(llm): non-streaming complete() primitive for Ollama/OpenAI-compat (T10.6, M1.2) --- src-tauri/src/llm/mod.rs | 97 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 95 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/llm/mod.rs b/src-tauri/src/llm/mod.rs index ecda5f0..81a4dc4 100644 --- a/src-tauri/src/llm/mod.rs +++ b/src-tauri/src/llm/mod.rs @@ -46,6 +46,12 @@ pub trait LlmProvider: Send + Sync { /// Non-streaming — the reply is short enough that a single round trip is /// simpler than wiring up another token-stream event for it. async fn suggest_tags(&self, transcript: &str) -> Result, LlmError>; + /// One-shot, non-streaming completion (T10.6, ADR-0011): a caller-supplied + /// system prompt + user message, one full reply back — no output-format + /// contract of its own (unlike `summarize`'s three fixed sections), since + /// callers like `briefs::FeatureBriefBuilder` define their own. Mirrors + /// `suggest_tags`'s single-round-trip shape. + async fn complete(&self, system: &str, user: &str) -> Result; /// True if the endpoint resolves to loopback/local (FR-LLM-6, FR-SEC-1). fn is_local(&self) -> bool; } @@ -154,8 +160,10 @@ fn parse_summary(text: &str) -> Summary { } /// `- text` / `* text` -> `Some("text")`; skips empty bullets and the -/// placeholder "- None" the prompt asks for when a section is empty. -fn bullet_text(line: &str) -> Option { +/// placeholder "- None" the prompt asks for when a section is empty. Shared +/// with `briefs::parse_brief` — its "Acceptance Criteria" section is the same +/// bullet-list shape as `summarize`'s Decisions/Action Items. +pub(crate) fn bullet_text(line: &str) -> Option { let trimmed = line.trim(); let stripped = trimmed .strip_prefix("- ") @@ -438,6 +446,34 @@ impl LlmProvider for OllamaProvider { )) } + /// One-shot completion (T10.6) — same non-streaming `/api/chat` shape as + /// `suggest_tags`, just with a caller-supplied system/user pair instead of + /// the fixed tag-list prompt. + async fn complete(&self, system: &str, user: &str) -> Result { + let body = serde_json::json!({ + "model": self.model, + "messages": [ + { "role": "system", "content": system }, + { "role": "user", "content": user }, + ], + "stream": false, + }); + let resp = reqwest::Client::new() + .post(format!("{}/api/chat", self.base())) + .json(&body) + .send() + .await + .map_err(|e| LlmError::Unreachable(e.to_string()))?; + if !resp.status().is_success() { + return Err(LlmError::Request(format!("HTTP {}", resp.status()))); + } + let chunk: OllamaChatChunk = resp + .json() + .await + .map_err(|e| LlmError::Request(e.to_string()))?; + Ok(chunk.message.map(|m| m.content).unwrap_or_default()) + } + fn is_local(&self) -> bool { is_local_endpoint(&self.endpoint) } @@ -627,6 +663,55 @@ impl LlmProvider for OpenAiCompatProvider { Ok(parse_tags(&full_text)) } + /// One-shot completion (T10.6) — reuses `suggest_tags`'s SSE-accumulate + /// path (this API has no simpler non-streaming reply shape) with a + /// caller-supplied system/user pair instead of the fixed tag prompt. + async fn complete(&self, system: &str, user: &str) -> Result { + let body = serde_json::json!({ + "model": self.model, + "messages": [ + { "role": "system", "content": system }, + { "role": "user", "content": user }, + ], + "stream": true, + }); + let req = self.auth( + reqwest::Client::new() + .post(format!("{}/v1/chat/completions", self.base())) + .json(&body), + ); + let resp = req + .send() + .await + .map_err(|e| LlmError::Unreachable(e.to_string()))?; + if !resp.status().is_success() { + return Err(LlmError::Request(format!("HTTP {}", resp.status()))); + } + + let mut full_text = String::new(); + stream_lines(resp, |line| { + let Some(payload) = line.strip_prefix("data:") else { + return true; + }; + let payload = payload.trim(); + if payload == "[DONE]" { + return false; + } + let Ok(chunk) = serde_json::from_str::(payload) else { + return true; + }; + for choice in chunk.choices { + if let Some(content) = choice.delta.content { + full_text.push_str(&content); + } + } + true + }) + .await?; + + Ok(full_text) + } + fn is_local(&self) -> bool { self.credential_ref.is_none() && is_local_endpoint(&self.endpoint) } @@ -755,6 +840,14 @@ impl LlmProvider for AnthropicProvider { "Anthropic provider isn't built yet (Phase 10a)".to_string(), )) } + async fn complete(&self, _system: &str, _user: &str) -> Result { + // Not built yet (Phase 10a); `briefs::LlmFeatureBriefBuilder` (M1) only + // ever gets an Ollama/OpenAI-compatible provider from + // `llm_provider_from_settings` today, so this path isn't reachable yet. + Err(LlmError::Request( + "Anthropic provider isn't built yet (Phase 10a)".to_string(), + )) + } fn is_local(&self) -> bool { false } -- 2.34.1 From 18f46ea6c36cbc7b2a41accb8cede30e64bf0806 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:04:39 -0500 Subject: [PATCH 003/103] feat(briefs): FeatureBriefBuilder distiller + golden-transcript tests (T10.6, M1.3/M1.6) --- src-tauri/src/briefs/mod.rs | 575 ++++++++++++++++++++++++++++++++++++ 1 file changed, 575 insertions(+) create mode 100644 src-tauri/src/briefs/mod.rs diff --git a/src-tauri/src/briefs/mod.rs b/src-tauri/src/briefs/mod.rs new file mode 100644 index 0000000..3769257 --- /dev/null +++ b/src-tauri/src/briefs/mod.rs @@ -0,0 +1,575 @@ +//! Feature-brief distiller (Phase 10 M1, ADR-0011, FR-MCP-4/T10.6). Turns a +//! finished meeting's transcript into an agent-ready spec via the configured +//! `LlmProvider` — no new egress, no new dependency: it's the same provider +//! `generate_summary` already talks to. +//! +//! The distillation itself (`distill`) is a plain function over an +//! `LlmProvider` + transcript data, independent of `Store` — that's what lets +//! the golden-transcript test exercise it with a `MockLlmProvider` and no +//! database at all. `LlmFeatureBriefBuilder` is the thin `Store`-aware +//! adapter the `FeatureBriefBuilder` trait (`docs/04-api-contracts.md`) +//! describes, used by `commands::create_feature_brief`. + +use crate::llm::{bullet_text, LlmError, LlmProvider}; +use crate::models::{ContextExcerpt, FeatureBrief, MeetingId, SpeakerInfo, TranscriptSegment}; +use crate::storage::{Store, StoreError}; +use async_trait::async_trait; +use std::collections::HashSet; +use std::sync::Arc; + +#[derive(Debug, thiserror::Error)] +pub enum BriefError { + #[error("storage error: {0}")] + Store(#[from] StoreError), + #[error("llm error: {0}")] + Llm(#[from] LlmError), +} + +/// Builds the agent-ready spec from a transcript via the configured +/// `LlmProvider` (`docs/04-api-contracts.md`). +#[async_trait] +pub trait FeatureBriefBuilder: Send + Sync { + async fn build( + &self, + meeting_id: &MeetingId, + target_repo: Option<&str>, + ) -> Result; +} + +/// System prompt contract (mirrors `llm::RESPONSE_FORMAT_INSTRUCTIONS`): +/// exactly four sections, in order, nothing invented beyond the transcript. +const BRIEF_INSTRUCTIONS: &str = "Distill this transcript into an implementation brief for a \ + coding agent. Respond in Markdown with exactly, in order: \"## Title\" (one line), \ + \"## Problem\", \"## Desired Outcome\", \"## Acceptance Criteria\" (a \"- \" bullet list, one \ + testable criterion per line). Be concrete and terse; invent nothing not in the transcript; no \ + other sections."; + +/// Parsed reply, before assembly into the IPC/file shapes. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct BriefFields { + pub title: String, + pub problem: String, + pub desired_outcome: String, + pub acceptance_criteria: Vec, +} + +/// Assembles the (system, user) prompt pair — metadata + optional +/// `target_repo` hint + the transcript, mirroring `commands::build_prompt`'s +/// shape for `summarize`. +fn build_brief_messages( + meeting_title: &str, + participants: &[String], + target_repo: Option<&str>, + transcript: &str, +) -> (String, String) { + let mut user = format!("Meeting: {meeting_title}\n"); + if !participants.is_empty() { + user.push_str(&format!("Participants: {}\n", participants.join(", "))); + } + if let Some(repo) = target_repo { + user.push_str(&format!("Target repo: {repo}\n")); + } + user.push_str("\nTranscript:\n"); + user.push_str(transcript); + (BRIEF_INSTRUCTIONS.to_string(), user) +} + +/// Splits a "## Title / ## Problem / ## Desired Outcome / ## Acceptance +/// Criteria" Markdown reply (see `BRIEF_INSTRUCTIONS`) into `BriefFields` — +/// mirrors `llm::parse_summary`. A missing/malformed section never panics: +/// `title`/`problem`/`desired_outcome` fall back to `""` (title falls back +/// further, to `meeting_title`, since a brief with no title at all is +/// unusable), and `acceptance_criteria` falls back to `[]`. +pub fn parse_brief(md: &str, meeting_title: &str) -> BriefFields { + let mut title = String::new(); + let mut problem = String::new(); + let mut desired_outcome = String::new(); + let mut acceptance_criteria = Vec::new(); + let mut section = -1i8; // 0 title, 1 problem, 2 desired outcome, 3 acceptance criteria, -1 other/unknown + + for line in md.lines() { + let lower = line.trim().to_ascii_lowercase(); + if lower.starts_with("## title") { + section = 0; + continue; + } + if lower.starts_with("## problem") { + section = 1; + continue; + } + if lower.starts_with("## desired outcome") { + section = 2; + continue; + } + if lower.starts_with("## acceptance criteria") { + section = 3; + continue; + } + if line.trim_start().starts_with('#') { + section = -1; + continue; + } + match section { + 0 => { + let text = line.trim(); + if !text.is_empty() { + if !title.is_empty() { + title.push(' '); + } + title.push_str(text); + } + } + 1 => { + problem.push_str(line); + problem.push('\n'); + } + 2 => { + desired_outcome.push_str(line); + desired_outcome.push('\n'); + } + 3 => { + if let Some(item) = bullet_text(line) { + acceptance_criteria.push(item); + } + } + _ => {} + } + } + + let title = title.trim().to_string(); + BriefFields { + title: if title.is_empty() { + meeting_title.to_string() + } else { + title + }, + problem: problem.trim().to_string(), + desired_outcome: desired_outcome.trim().to_string(), + acceptance_criteria, + } +} + +/// Lowercased alphanumeric words of length >= 3 — short enough to skip +/// common stopwords ("the", "to", "we") without a stopword list, long enough +/// to still catch meaningful terms. +fn keywords(text: &str) -> HashSet { + text.split(|c: char| !c.is_alphanumeric()) + .filter(|w| w.len() >= 3) + .map(|w| w.to_ascii_lowercase()) + .collect() +} + +fn display_name(label: &str, speakers: &[SpeakerInfo]) -> String { + speakers + .iter() + .find(|s| s.label == label) + .and_then(|s| s.display_name.clone()) + .unwrap_or_else(|| label.to_string()) +} + +const MIN_EXCERPT_LEN: usize = 40; +const MAX_EXCERPTS: usize = 5; +const FALLBACK_EXCERPTS: usize = 3; + +/// Selects grounding excerpts for a brief (the M1 grounding invariant: every +/// returned `text` is copied verbatim from a transcript segment — never +/// model paraphrase). Scores each substantive segment (>= ~40 chars) by +/// keyword overlap with `problem` + `acceptance_criteria`, taking the top +/// <= 5; falls back to the first 3 substantive segments if nothing scores +/// (e.g. a terse reply with too few keywords, or a transcript that just +/// doesn't share vocabulary with the drafted brief). +// ponytail: keyword-overlap select; upgrade to embedding similarity if excerpts feel off +fn context_excerpts( + fields: &BriefFields, + segments: &[TranscriptSegment], + speakers: &[SpeakerInfo], +) -> Vec { + let mut query = keywords(&fields.problem); + query.extend(keywords(&fields.acceptance_criteria.join(" "))); + + let substantive: Vec<&TranscriptSegment> = segments + .iter() + .filter(|s| s.text.trim().len() >= MIN_EXCERPT_LEN) + .collect(); + + if !query.is_empty() { + let mut scored: Vec<(usize, &TranscriptSegment)> = substantive + .iter() + .map(|&s| (keywords(&s.text).intersection(&query).count(), s)) + .filter(|(overlap, _)| *overlap > 0) + .collect(); + if !scored.is_empty() { + // Stable sort keeps original (chronological) order among ties. + scored.sort_by(|a, b| b.0.cmp(&a.0)); + return scored + .into_iter() + .take(MAX_EXCERPTS) + .map(|(_, s)| ContextExcerpt { + speaker: display_name(&s.speaker, speakers), + text: s.text.clone(), + }) + .collect(); + } + } + + substantive + .into_iter() + .take(FALLBACK_EXCERPTS) + .map(|s| ContextExcerpt { + speaker: display_name(&s.speaker, speakers), + text: s.text.clone(), + }) + .collect() +} + +/// Core distillation: prompt -> LLM round trip -> parse -> ground. Takes +/// transcript data directly rather than a `MeetingId`, so it needs no +/// `Store` — `LlmFeatureBriefBuilder::build` below is the `Store`-aware +/// wrapper that looks the meeting up first. +async fn distill( + llm: &dyn LlmProvider, + meeting_id: &MeetingId, + meeting_title: &str, + participants: &[String], + target_repo: Option<&str>, + transcript_md: &str, + segments: &[TranscriptSegment], + speakers: &[SpeakerInfo], +) -> Result { + let (system, user) = + build_brief_messages(meeting_title, participants, target_repo, transcript_md); + let reply = llm.complete(&system, &user).await?; + let fields = parse_brief(&reply, meeting_title); + let context_excerpts = context_excerpts(&fields, segments, speakers); + Ok(FeatureBrief { + id: uuid::Uuid::new_v4().to_string(), + meeting_id: meeting_id.clone(), + title: fields.title, + problem: fields.problem, + desired_outcome: fields.desired_outcome, + acceptance_criteria: fields.acceptance_criteria, + target_repo: target_repo.map(str::to_string), + context_excerpts, + }) +} + +/// `FeatureBriefBuilder` impl used by `commands::create_feature_brief`: +/// loads the meeting via `store`, then distills it with `llm`. +pub struct LlmFeatureBriefBuilder { + pub store: Arc, + pub llm: Box, +} + +#[async_trait] +impl FeatureBriefBuilder for LlmFeatureBriefBuilder { + async fn build( + &self, + meeting_id: &MeetingId, + target_repo: Option<&str>, + ) -> Result { + let meeting = self.store.get_meeting(meeting_id).await?; + let participants: Vec = meeting + .speakers + .iter() + .map(|s| s.display_name.clone().unwrap_or_else(|| s.label.clone())) + .collect(); + distill( + self.llm.as_ref(), + meeting_id, + &meeting.title, + &participants, + target_repo, + &meeting.notes_markdown, + &meeting.segments, + &meeting.speakers, + ) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::llm::{LlmStatus, Prompt, Summary, TokenSink}; + + /// No-network stand-in for a real provider (T10.6 test — the golden- + /// transcript builder test must never touch a socket). Only `complete` + /// is exercised by `distill`; the rest are unused stubs. + struct MockLlmProvider { + reply: String, + } + + #[async_trait] + impl LlmProvider for MockLlmProvider { + async fn status(&self) -> LlmStatus { + LlmStatus { + provider: "mock".to_string(), + reachable: true, + is_local: true, + models: Vec::new(), + } + } + async fn summarize(&self, _prompt: Prompt, _out: TokenSink) -> Result { + unimplemented!("not exercised by the brief-builder test") + } + async fn suggest_tags(&self, _transcript: &str) -> Result, LlmError> { + Ok(Vec::new()) + } + async fn complete(&self, _system: &str, _user: &str) -> Result { + Ok(self.reply.clone()) + } + fn is_local(&self) -> bool { + true + } + } + + const GOLDEN_REPLY: &str = "## Title\n\ + Bulk CSV export for the reporting view\n\n\ + ## Problem\n\ + Customers can't get their filtered report data out for offline analysis.\n\n\ + ## Desired Outcome\n\ + One-click CSV export of the current filtered report.\n\n\ + ## Acceptance Criteria\n\ + - Export button on the report toolbar\n\ + - Respects active filters and column order\n\ + - Streams large exports without blocking the UI\n"; + + fn seg(id: u64, speaker: &str, text: &str) -> TranscriptSegment { + TranscriptSegment { + id, + start_ms: id * 1000, + end_ms: id * 1000 + 900, + speaker: speaker.to_string(), + text: text.to_string(), + confidence: Some(0.9), + interim: false, + } + } + + fn golden_segments() -> Vec { + vec![ + seg(0, "S1", "Let's start with the reporting view."), + seg( + 1, + "S2", + "We really need to pull this filtered report into our own spreadsheets for offline analysis.", + ), + seg(2, "S1", "Makes sense — what would the export button need to respect?"), + seg( + 3, + "S2", + "It has to respect the active filters and the column order we've already set up.", + ), + seg(4, "S1", "And it can't block the UI while a large export streams out."), + seg(5, "S2", "Right, exactly."), + ] + } + + fn golden_speakers() -> Vec { + vec![ + SpeakerInfo { + label: "S1".to_string(), + display_name: Some("Alex".to_string()), + participant_id: None, + }, + SpeakerInfo { + label: "S2".to_string(), + display_name: Some("Customer".to_string()), + participant_id: None, + }, + ] + } + + // ---- parse_brief ---- + + #[test] + fn parse_brief_splits_the_four_requested_sections() { + let fields = parse_brief(GOLDEN_REPLY, "fallback title"); + assert_eq!(fields.title, "Bulk CSV export for the reporting view"); + assert_eq!( + fields.problem, + "Customers can't get their filtered report data out for offline analysis." + ); + assert_eq!( + fields.desired_outcome, + "One-click CSV export of the current filtered report." + ); + assert_eq!( + fields.acceptance_criteria, + vec![ + "Export button on the report toolbar", + "Respects active filters and column order", + "Streams large exports without blocking the UI", + ] + ); + } + + #[test] + fn parse_brief_falls_back_to_the_meeting_title_when_no_title_section() { + let text = "## Problem\nSomething broke.\n"; + let fields = parse_brief(text, "Sprint planning"); + assert_eq!(fields.title, "Sprint planning"); + assert_eq!(fields.problem, "Something broke."); + assert_eq!(fields.desired_outcome, ""); + assert!(fields.acceptance_criteria.is_empty()); + } + + #[test] + fn parse_brief_of_empty_or_malformed_input_is_empty_and_does_not_panic() { + let fields = parse_brief("", "Meeting title"); + assert_eq!(fields.title, "Meeting title"); + assert_eq!(fields.problem, ""); + assert_eq!(fields.desired_outcome, ""); + assert!(fields.acceptance_criteria.is_empty()); + + // No recognized headings at all — everything before the first `#` + // (there is none) is just unattributed prose, so nothing is captured. + let fields = parse_brief("just some prose with no headings", "Meeting title"); + assert_eq!(fields.title, "Meeting title"); + assert!(fields.problem.is_empty()); + assert!(fields.acceptance_criteria.is_empty()); + } + + #[test] + fn parse_brief_ignores_unrecognized_extra_sections() { + let text = "## Title\nFix the thing\n\n## Notes\nirrelevant chatter\n\n\ + ## Acceptance Criteria\n- It works\n"; + let fields = parse_brief(text, "fallback"); + assert_eq!(fields.title, "Fix the thing"); + assert_eq!(fields.acceptance_criteria, vec!["It works"]); + } + + // ---- context_excerpts / grounding invariant ---- + + #[test] + fn context_excerpts_are_verbatim_substrings_of_a_transcript_segment() { + let fields = parse_brief(GOLDEN_REPLY, "fallback"); + let segments = golden_segments(); + let speakers = golden_speakers(); + let excerpts = context_excerpts(&fields, &segments, &speakers); + assert!(!excerpts.is_empty()); + for excerpt in &excerpts { + assert!( + segments.iter().any(|s| s.text.contains(&excerpt.text)), + "excerpt {:?} is not a verbatim substring of any transcript segment", + excerpt.text + ); + } + } + + #[test] + fn context_excerpts_resolve_speaker_display_names() { + let fields = parse_brief(GOLDEN_REPLY, "fallback"); + let segments = golden_segments(); + let speakers = golden_speakers(); + let excerpts = context_excerpts(&fields, &segments, &speakers); + assert!(excerpts.iter().any(|e| e.speaker == "Customer")); + } + + #[test] + fn context_excerpts_falls_back_to_first_substantive_segments_with_no_keyword_overlap() { + let fields = BriefFields { + title: "t".to_string(), + problem: "zzzzz qqqqq".to_string(), // shares no vocabulary with the transcript + desired_outcome: String::new(), + acceptance_criteria: vec!["wwwww".to_string()], + }; + let segments = golden_segments(); + let excerpts = context_excerpts(&fields, &segments, &golden_speakers()); + assert_eq!(excerpts.len(), FALLBACK_EXCERPTS); + for excerpt in &excerpts { + assert!(segments.iter().any(|s| s.text.contains(&excerpt.text))); + } + } + + // ---- distill (the FeatureBriefBuilder golden-transcript test) ---- + + #[tokio::test] + async fn distill_over_a_golden_transcript_yields_grounded_non_empty_criteria() { + let llm = MockLlmProvider { + reply: GOLDEN_REPLY.to_string(), + }; + let segments = golden_segments(); + let speakers = golden_speakers(); + let participants: Vec = speakers + .iter() + .map(|s| s.display_name.clone().unwrap()) + .collect(); + let transcript_md = segments + .iter() + .map(|s| format!("**{}:** {}", s.speaker, s.text)) + .collect::>() + .join("\n"); + + let brief = distill( + &llm, + &"m1".to_string(), + "Reporting sync", + &participants, + Some("acme/reporting-web"), + &transcript_md, + &segments, + &speakers, + ) + .await + .expect("distill should succeed against the mock provider"); + + assert_eq!(brief.meeting_id, "m1"); + assert_eq!(brief.title, "Bulk CSV export for the reporting view"); + assert!(!brief.acceptance_criteria.is_empty()); + assert_eq!(brief.target_repo.as_deref(), Some("acme/reporting-web")); + assert!(!brief.context_excerpts.is_empty()); + for excerpt in &brief.context_excerpts { + assert!( + segments.iter().any(|s| s.text.contains(&excerpt.text)), + "grounding invariant violated: {:?}", + excerpt.text + ); + } + } + + #[tokio::test] + async fn distill_propagates_an_llm_error_without_panicking() { + struct FailingProvider; + #[async_trait] + impl LlmProvider for FailingProvider { + async fn status(&self) -> LlmStatus { + LlmStatus { + provider: "mock".to_string(), + reachable: false, + is_local: true, + models: Vec::new(), + } + } + async fn summarize( + &self, + _prompt: Prompt, + _out: TokenSink, + ) -> Result { + unimplemented!() + } + async fn suggest_tags(&self, _transcript: &str) -> Result, LlmError> { + unimplemented!() + } + async fn complete(&self, _system: &str, _user: &str) -> Result { + Err(LlmError::Unreachable("connection refused".to_string())) + } + fn is_local(&self) -> bool { + true + } + } + + let result = distill( + &FailingProvider, + &"m1".to_string(), + "Meeting", + &[], + None, + "transcript", + &[], + &[], + ) + .await; + assert!(matches!(result, Err(BriefError::Llm(_)))); + } +} -- 2.34.1 From 7e1b7882ebe561b4103063eecccce2a647500733 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:04:41 -0500 Subject: [PATCH 004/103] chore(briefs): register briefs module (T10.6, M1.3) --- src-tauri/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9684caf..a1e730f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ pub mod agent; pub mod audio; +pub mod briefs; pub mod calendar; pub mod commands; pub mod diarization; -- 2.34.1 From c88c04233ce3fcb1d04fa287e919203f17c5fe58 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:10:17 -0500 Subject: [PATCH 005/103] feat(mcp): add MCP settings fields to Settings (T10.4) Adds mcp_enabled/mcp_transport/mcp_port/mcp_expose/mcp_expose_recordings to Settings, all defaulting to off/none so an upgrading settings.json gets a fully-local default (FR-MCP-1). The auth token itself never lives here -- OS credential store only (see mcp::token, next commit). --- src-tauri/src/models.rs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 7ba4a31..8ef842b 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -226,6 +226,33 @@ pub struct Settings { // default capture device. #[serde(default)] pub audio_input_device: Option, + // Local MCP server (Phase 10b, ADR-0011, FR-MCP-1). OFF by default; the + // auth token itself is NEVER stored here — only in the OS credential + // store (see `mcp::token`). `mcp_expose` is one of none|selected|all; + // `mcp_expose_recordings` gates access to meetings with retained audio + // (ADR-0009) regardless of `mcp_expose` (FR-MCP-3). + #[serde(default)] + pub mcp_enabled: bool, + #[serde(default = "default_mcp_transport")] + pub mcp_transport: String, // http|stdio + #[serde(default = "default_mcp_port")] + pub mcp_port: u16, + #[serde(default = "default_mcp_expose")] + pub mcp_expose: String, // none|selected|all + #[serde(default)] + pub mcp_expose_recordings: bool, +} + +fn default_mcp_transport() -> String { + "http".into() +} + +fn default_mcp_port() -> u16 { + 4849 +} + +fn default_mcp_expose() -> String { + "none".into() } /// serde default for a `bool` field that should be `true` when absent from an -- 2.34.1 From 999084ab3f68df1c8517c05e02bdf4ea9feaa0ee Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:10:37 -0500 Subject: [PATCH 006/103] feat(mcp): default_settings() covers the new MCP fields (T10.4) Keeps default_settings() exhaustive now that Settings grew mcp_* fields. --- src-tauri/src/commands.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 0b6cede..059f976 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -65,6 +65,11 @@ fn default_settings() -> Settings { audio_output_device: None, microphone_enabled: true, audio_input_device: None, + mcp_enabled: false, + mcp_transport: "http".into(), + mcp_port: 4849, + mcp_expose: "none".into(), + mcp_expose_recordings: false, } } -- 2.34.1 From e0668e48dfa1042d2f62e620c51e083d371da706 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:13:11 -0500 Subject: [PATCH 007/103] feat(mcp): Store methods for action items + access log (T10.4/10.5) Adds list_action_items (backs the get_action_items MCP tool -- distinct from list_pending_reminders, which is reminder-scoped across all meetings) and record_mcp_access/list_mcp_access_log (FR-MCP-5 audit trail) to the Store trait + SqliteStore, reusing the mcp_access_log table from migrations/0003_ai_mcp.sql. --- src-tauri/src/storage/mod.rs | 81 +++++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/storage/mod.rs b/src-tauri/src/storage/mod.rs index eb10ddc..e216d1d 100644 --- a/src-tauri/src/storage/mod.rs +++ b/src-tauri/src/storage/mod.rs @@ -5,8 +5,8 @@ //! summary) are regenerable; retention never touches an in-progress meeting. use crate::models::{ - ActionItem, CalendarEvent, ImportedEvent, MeetingId, MeetingListItem, MeetingStatus, - Participant, SearchHit, SpeakerInfo, TranscriptSegment, + ActionItem, CalendarEvent, ImportedEvent, McpAccessEntry, MeetingId, MeetingListItem, + MeetingStatus, Participant, SearchHit, SpeakerInfo, TranscriptSegment, }; use crate::paths; use async_trait::async_trait; @@ -297,6 +297,24 @@ pub trait Store: Send + Sync { &self, meeting_id: Option<&MeetingId>, ) -> Result, StoreError>; + + // ---- MCP server (Phase 10b, ADR-0011) ---- + /// Confirmed action items for a meeting (FR-MCP-2 `get_action_items`) — + /// distinct from `list_pending_reminders` (which is filtered to + /// unfired-reminder rows across *all* meetings for the startup reconcile). + async fn list_action_items(&self, meeting_id: &MeetingId) -> Result, StoreError>; + /// Appends one row to the audit log (FR-MCP-5). Every MCP tool read calls + /// this, regardless of whether the read was actually allowed to see + /// anything — the audit trail is "what an agent asked for", not just + /// "what it received". + async fn record_mcp_access( + &self, + tool: &str, + meeting_id: Option<&MeetingId>, + client: Option<&str>, + ) -> Result<(), StoreError>; + /// Most recent audit rows first, optionally capped (`mcp_access_log` command). + async fn list_mcp_access_log(&self, limit: Option) -> Result, StoreError>; } /// SQLite-backed store. Migrations live in `migrations/` (`sqlx::migrate!`). @@ -1484,6 +1502,65 @@ impl Store for SqliteStore { }; Ok(rows) } + + async fn list_action_items(&self, meeting_id: &MeetingId) -> Result, StoreError> { + let rows = sqlx::query( + "SELECT id, text, owner, due_at, confirmed, reminder_set FROM action_items \ + WHERE meeting_id = ? ORDER BY created_at ASC", + ) + .bind(meeting_id) + .fetch_all(&self.pool) + .await?; + Ok(rows + .iter() + .map(|r| ActionItem { + id: Some(r.get("id")), + text: r.get("text"), + owner: r.get("owner"), + due_at: r.get("due_at"), + confirmed: r.get::("confirmed") != 0, + reminder_set: r.get::("reminder_set") != 0, + }) + .collect()) + } + + async fn record_mcp_access( + &self, + tool: &str, + meeting_id: Option<&MeetingId>, + client: Option<&str>, + ) -> Result<(), StoreError> { + sqlx::query( + "INSERT INTO mcp_access_log (id, at, tool, meeting_id, client) VALUES (?,?,?,?,?)", + ) + .bind(uuid::Uuid::new_v4().to_string()) + .bind(now_unix()) + .bind(tool) + .bind(meeting_id) + .bind(client) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn list_mcp_access_log(&self, limit: Option) -> Result, StoreError> { + let cap: i64 = limit.map(i64::from).unwrap_or(200).max(1); + let rows = sqlx::query( + "SELECT at, tool, meeting_id, client FROM mcp_access_log ORDER BY at DESC LIMIT ?", + ) + .bind(cap) + .fetch_all(&self.pool) + .await?; + Ok(rows + .iter() + .map(|r| McpAccessEntry { + at: r.get("at"), + tool: r.get("tool"), + meeting_id: r.get("meeting_id"), + client: r.get("client"), + }) + .collect()) + } } /// Read a derived artifact, transparently decrypting it if the vault sealed it -- 2.34.1 From c73245f293642e9e5ee79a62a9dde5784c4d1fba Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:14:32 -0500 Subject: [PATCH 008/103] feat(mcp): trait/config plumbing for the real server (T10.4) ExposeScope/McpTransport gain as_str/parse (privacy-safe fallback: an unparsed scope becomes None, not All/Selected) so commands.rs can read them out of Settings' string fields. Declares the mcp submodules (scope always compiled; handler/http_transport/server/stdio_transport/ token behind the `mcp` cargo feature) and removes the todo!() RmcpServer stub -- its real implementation moves to mcp::server in the next commits. --- src-tauri/src/mcp/mod.rs | 120 ++++++++++++++++++++++++++------------- 1 file changed, 80 insertions(+), 40 deletions(-) diff --git a/src-tauri/src/mcp/mod.rs b/src-tauri/src/mcp/mod.rs index feade85..9ada033 100644 --- a/src-tauri/src/mcp/mod.rs +++ b/src-tauri/src/mcp/mod.rs @@ -14,10 +14,28 @@ use crate::models::{FeatureBrief, MeetingId}; use async_trait::async_trait; +pub mod scope; + +#[cfg(feature = "mcp")] +pub mod handler; +#[cfg(feature = "mcp")] +pub mod http_transport; +#[cfg(feature = "mcp")] +pub mod server; +#[cfg(feature = "mcp")] +pub mod stdio_transport; +#[cfg(feature = "mcp")] +pub mod token; + +#[cfg(feature = "mcp")] +pub use server::RmcpServer; + #[derive(Debug, thiserror::Error)] pub enum McpError { #[error("refusing to bind non-loopback address")] NonLoopback, + #[error("unauthorized: missing or invalid token")] + Unauthorized, #[error("server error: {0}")] Server(String), } @@ -38,7 +56,7 @@ pub struct McpConfig { pub expose_recordings: bool, } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum McpTransport { /// Streamable HTTP on http://127.0.0.1:/mcp (loopback only). Http, @@ -46,13 +64,51 @@ 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, @@ -64,6 +120,28 @@ pub struct McpToolDescriptor { 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 +160,3 @@ pub trait FeatureBriefBuilder: Send + Sync { target_repo: Option<&str>, ) -> Result; } - -/// Default rmcp-backed server (feature `mcp`). -#[cfg(feature = "mcp")] -pub struct RmcpServer; - -#[cfg(feature = "mcp")] -#[async_trait] -impl McpServer for RmcpServer { - async fn start(&self, _cfg: McpConfig) -> Result { - // T10.4: bind loopback ONLY (reject non-loopback), mint a token, register tools, - // serve over Streamable HTTP (/mcp) or stdio. Never opens an outbound socket. - todo!("Phase 10b — start MCP server (loopback, token)") - } - async fn stop(&self, _handle: McpHandle) -> Result<(), McpError> { - todo!("Phase 10b — stop MCP server") - } - fn tools(&self) -> Vec { - // T10.5: the tools an agent can call. Tools-first for Copilot compatibility. - vec![ - McpToolDescriptor { - name: "list_recent_meetings", - description: "Recent meetings (scoped).", - }, - McpToolDescriptor { - name: "get_transcript", - description: "Transcript for a meeting (scoped).", - }, - McpToolDescriptor { - name: "get_action_items", - description: "Action items for a meeting.", - }, - McpToolDescriptor { - name: "get_feature_brief", - description: "Agent-ready spec distilled from a meeting.", - }, - ] - } -} -- 2.34.1 From fc93f8e14f2e851a04a45200b710fae2d92b5e29 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:14:50 -0500 Subject: [PATCH 009/103] feat(mcp): scope-control logic, unit tested (T10.7, FR-MCP-3) Pure functions (no DB, no cargo feature gate) so scope control is testable without a running server: meetings_visible/brief_visible/ recording_gate_ok/meeting_allowed. Documents the conservative choice for `selected` scope on meetings/transcript/action-items -- there's no per-meeting selection flag in the schema yet (only feature_briefs.exposed does), so `selected` behaves like `none` there until a real "pick which meetings" mechanism exists, rather than silently behaving like `all`. --- src-tauri/src/mcp/scope.rs | 96 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 src-tauri/src/mcp/scope.rs diff --git a/src-tauri/src/mcp/scope.rs b/src-tauri/src/mcp/scope.rs new file mode 100644 index 0000000..614e51a --- /dev/null +++ b/src-tauri/src/mcp/scope.rs @@ -0,0 +1,96 @@ +//! 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 + } +} -- 2.34.1 From 14868d4e5c49d9941b6ec7b86daf600fe9bb022d Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:17:56 -0500 Subject: [PATCH 010/103] feat(mcp): ServerHandler implementing the four MCP tools (T10.5) WaMcpHandler wires list_recent_meetings/get_transcript/get_action_items/ get_feature_brief to Store, re-checking ExposeScope + the recordings gate independently in every handler (FR-MCP-3) and logging every read via record_mcp_access + an "mcp://access" event (FR-MCP-5) before scope is even evaluated, so the audit trail is "what was asked for", not just "what was returned". get_feature_brief calls through the existing commands::get_feature_brief stub per the M1/M2 integration contract -- it always errors right now since M1's brief storage isn't implemented in this worktree yet; the `selected`-scope exposed-flag check is left as a marked TODO for when M1 lands. --- src-tauri/src/mcp/handler.rs | 333 +++++++++++++++++++++++++++++++++++ 1 file changed, 333 insertions(+) create mode 100644 src-tauri/src/mcp/handler.rs diff --git a/src-tauri/src/mcp/handler.rs b/src-tauri/src/mcp/handler.rs new file mode 100644 index 0000000..d934c06 --- /dev/null +++ b/src-tauri/src/mcp/handler.rs @@ -0,0 +1,333 @@ +//! `rmcp::ServerHandler` implementation — the tools-first surface (FR-MCP-2) +//! that a connected coding agent actually calls. Every tool handler: +//! 1. Reads the *current* scope from `Settings` (not a snapshot taken at +//! server start) so `set_mcp_scope` takes effect immediately. +//! 2. Logs the read (FR-MCP-5) — even when the read is denied, so the audit +//! trail reflects what an agent *asked for*. +//! 3. Independently re-checks scope + the recordings gate (FR-MCP-3) — there +//! is deliberately no single choke point upstream of this file. + +use crate::mcp::{scope, ExposeScope}; +use crate::models::MeetingId; +use crate::storage::{MeetingFilter, Store}; +use rmcp::model::{ + CallToolRequestParams, CallToolResult, Implementation, JsonObject, ListToolsResult, + PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool, +}; +use rmcp::service::{RequestContext, RoleServer}; +use rmcp::{ErrorData as McpProtoError, ServerHandler}; +use serde_json::{json, Value}; +use std::sync::Arc; +use tauri::{AppHandle, Emitter}; + +/// Shared handle the HTTP/stdio transports build a fresh `rmcp` service +/// around per-connection (`ServerHandler` methods take `&self`, so this just +/// needs to be `Clone` + cheap — it's an `Arc` and an `AppHandle`). +#[derive(Clone)] +pub struct WaMcpHandler { + store: Arc, + app: AppHandle, +} + +impl WaMcpHandler { + pub fn new(store: Arc, app: 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}"); + } + let _ = self.app.emit( + "mcp://access", + json!({ + "at": now_ms(), + "tool": tool, + "meetingId": meeting_id, + "client": client, + }), + ); + } + + fn client_name(context: &RequestContext) -> Option { + context + .peer + .peer_info() + .map(|info| info.client_info.name.clone()) + } + + async fn tool_list_recent_meetings( + &self, + args: &Option, + client: Option<&str>, + ) -> Result { + self.log_access("list_recent_meetings", None, client).await; + let (scope_val, expose_recordings) = self.current_scope(); + if !scope::meetings_visible(scope_val) { + return Ok(CallToolResult::structured(json!({ "meetings": [] }))); + } + let limit = arg_u64(args, "limit").unwrap_or(20).clamp(1, 100) as usize; + let items = self + .store + .list_meetings(MeetingFilter::default()) + .await + .map_err(store_err)?; + let mut out = Vec::with_capacity(limit); + for item in items { + if out.len() >= limit { + break; + } + let Ok(full) = self.store.get_meeting(&item.id).await else { + continue; + }; + if !scope::recording_gate_ok(expose_recordings, full.recorded) { + continue; + } + out.push(json!({ + "id": item.id, + "title": item.title, + "startedAt": item.started_at, + "durationSecs": item.duration_secs, + "status": item.status.as_str(), + "tags": item.tags, + })); + } + Ok(CallToolResult::structured(json!({ "meetings": out }))) + } + + async fn tool_get_transcript( + &self, + args: &Option, + client: Option<&str>, + ) -> Result { + let meeting_id = arg_str(args, "meetingId") + .ok_or_else(|| McpProtoError::invalid_params("meetingId is required", None))?; + self.log_access("get_transcript", Some(&meeting_id), client) + .await; + let (scope_val, expose_recordings) = self.current_scope(); + if !scope::meetings_visible(scope_val) { + return Ok(denied("get_transcript scope is not `all`")); + } + let meeting = self + .store + .get_meeting(&meeting_id) + .await + .map_err(store_err)?; + if !scope::recording_gate_ok(expose_recordings, meeting.recorded) { + return Ok(denied( + "this meeting retained its recording; expose_recordings is off", + )); + } + Ok(CallToolResult::structured(json!({ + "meetingId": meeting.id, + "title": meeting.title, + "segments": meeting.segments, + }))) + } + + async fn tool_get_action_items( + &self, + args: &Option, + client: Option<&str>, + ) -> Result { + let meeting_id = arg_str(args, "meetingId") + .ok_or_else(|| McpProtoError::invalid_params("meetingId is required", None))?; + self.log_access("get_action_items", Some(&meeting_id), client) + .await; + let (scope_val, expose_recordings) = self.current_scope(); + if !scope::meetings_visible(scope_val) { + return Ok(denied("get_action_items scope is not `all`")); + } + let meeting = self + .store + .get_meeting(&meeting_id) + .await + .map_err(store_err)?; + if !scope::recording_gate_ok(expose_recordings, meeting.recorded) { + return Ok(denied( + "this meeting retained its recording; expose_recordings is off", + )); + } + let items = self + .store + .list_action_items(&meeting_id) + .await + .map_err(store_err)?; + Ok(CallToolResult::structured(json!({ + "meetingId": meeting_id, + "items": items, + }))) + } + + async fn tool_get_feature_brief( + &self, + args: &Option, + client: Option<&str>, + ) -> Result { + let id = arg_str(args, "id") + .ok_or_else(|| McpProtoError::invalid_params("id is required", None))?; + self.log_access("get_feature_brief", None, client).await; + let (scope_val, _expose_recordings) = self.current_scope(); + if matches!(scope_val, ExposeScope::None) { + return Ok(denied("MCP scope is `none`; no briefs are exposed")); + } + // KNOWN GAP (M1 integration): `selected` scope should only serve a + // brief whose own `exposed` flag is true (`feature_briefs.exposed`, + // toggled via `set_brief_exposed`) — see `mcp::scope` docs. That + // check belongs here but M1 (feature-brief storage) is a stub in + // this worktree (`commands::get_feature_brief` always returns + // `not_implemented`), so there is nothing yet to check the flag + // against. Once M1 lands, add: fetch the brief's `exposed` bit and + // call `scope::brief_visible(scope_val, exposed)` before returning. + match crate::commands::get_feature_brief(id).await { + Ok(brief) => Ok(CallToolResult::structured( + serde_json::to_value(brief).map_err(|e| McpProtoError::internal_error(e.to_string(), None))?, + )), + Err(e) => Ok(CallToolResult::structured_error(json!({ + "error": e.kind, + "message": e.message, + }))), + } + } +} + +impl ServerHandler for WaMcpHandler { + fn get_info(&self) -> ServerInfo { + ServerInfo { + capabilities: ServerCapabilities::builder().enable_tools().build(), + server_info: Implementation { + name: "whispassist".into(), + title: Some("WhispAssist".into()), + version: env!("CARGO_PKG_VERSION").into(), + description: None, + icons: None, + website_url: None, + }, + instructions: Some( + "WhispAssist meeting-assistant tools. Served data may be forwarded by this \ + agent to its own model provider outside WhispAssist's control -- WA discloses \ + this in its UI and logs every read (FR-MCP-5). Recordings (.wav) are never \ + served by any tool here." + .into(), + ), + ..Default::default() + } + } + + async fn list_tools( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + let tools = vec![ + Tool::new( + "list_recent_meetings", + "Recent meetings, most recent first (scoped by the user's MCP settings).", + obj_schema(json!({ + "type": "object", + "properties": { "limit": { "type": "integer", "minimum": 1, "maximum": 100 } }, + "additionalProperties": false, + })), + ), + Tool::new( + "get_transcript", + "Full transcript (speaker-labeled segments) for one meeting.", + obj_schema(json!({ + "type": "object", + "properties": { "meetingId": { "type": "string" } }, + "required": ["meetingId"], + "additionalProperties": false, + })), + ), + Tool::new( + "get_action_items", + "Confirmed action items for one meeting.", + obj_schema(json!({ + "type": "object", + "properties": { "meetingId": { "type": "string" } }, + "required": ["meetingId"], + "additionalProperties": false, + })), + ), + Tool::new( + "get_feature_brief", + "Agent-ready spec (problem/outcome/acceptance criteria) distilled from a meeting.", + obj_schema(json!({ + "type": "object", + "properties": { "id": { "type": "string" } }, + "required": ["id"], + "additionalProperties": false, + })), + ), + ]; + Ok(ListToolsResult::with_all_items(tools)) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + let client = Self::client_name(&context); + match request.name.as_ref() { + "list_recent_meetings" => { + self.tool_list_recent_meetings(&request.arguments, client.as_deref()) + .await + } + "get_transcript" => { + self.tool_get_transcript(&request.arguments, client.as_deref()) + .await + } + "get_action_items" => { + self.tool_get_action_items(&request.arguments, client.as_deref()) + .await + } + "get_feature_brief" => { + self.tool_get_feature_brief(&request.arguments, client.as_deref()) + .await + } + other => Err(McpProtoError::invalid_params( + format!("unknown tool: {other}"), + None, + )), + } + } +} + +fn obj_schema(value: Value) -> Arc { + Arc::new(value.as_object().cloned().unwrap_or_default()) +} + +fn arg_str(args: &Option, key: &str) -> Option { + args.as_ref()?.get(key)?.as_str().map(str::to_string) +} + +fn arg_u64(args: &Option, key: &str) -> Option { + args.as_ref()?.get(key)?.as_u64() +} + +fn store_err(e: crate::storage::StoreError) -> McpProtoError { + McpProtoError::internal_error(e.to_string(), None) +} + +fn denied(reason: &str) -> CallToolResult { + CallToolResult::structured_error(json!({ "error": "scope_denied", "message": reason })) +} + +fn now_ms() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or_default() +} -- 2.34.1 From c48c67760d5561a6843a37685c9332f12bf155a0 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:19:15 -0500 Subject: [PATCH 011/103] build(mcp): pull in the HTTP/stdio transport features (T10.4) rmcp gains transport-streamable-http-server + transport-io (the actual serving code -- the base "server" feature only got tool routing) plus client + transport-streamable-http-client-reqwest, used solely by this crate's own in-process MCP-client tests (WA never opens an outbound MCP connection at runtime, so this is not new egress, FR-MCP-7). hyper/ hyper-util/http-body-util/http/bytes/tower-service are the low-level glue to run HTTP/1 over a loopback TcpListener in front of rmcp's StreamableHttpService (a bare tower_service::Service, not an axum app). All are already in Cargo.lock transitively via reqwest/tauri -- no new crates, just promoted to direct deps under the existing `mcp` feature. --- src-tauri/Cargo.toml | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e9adce4..0499ac9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -47,7 +47,25 @@ 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 } # audio / transcription / diarization / calendar are integrated per-phase and are # feature-gated so the CPU-only build always compiles (NFR-MNT-4). @@ -106,7 +124,10 @@ 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", +] # WhispAssist as an MCP server + hosted-AI creds [profile.release] opt-level = "z" # optimize for size — keep the binary small (NFR-RES-1) -- 2.34.1 From 9eed88e5bd76ba4cb034b56d4e4744f11b8506e4 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:20:14 -0500 Subject: [PATCH 012/103] feat(mcp): loopback bind + bearer-token gate for Streamable HTTP (T10.4, FR-MCP-1/6) bind_loopback() refuses to bind anything that doesn't resolve to a loopback address -- unit tested directly (non-loopback IP, unparseable host, and a real 127.0.0.1:0 bind). serve() runs a hyper HTTP/1 accept loop in front of rmcp's StreamableHttpService (a bare tower_service, not an axum app); every request needs `Authorization: Bearer ` (constant-time compared via mcp::token::verify) before it ever reaches the MCP service -- an unauthenticated request never reaches rmcp at all. --- src-tauri/src/mcp/http_transport.rs | 164 ++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 src-tauri/src/mcp/http_transport.rs diff --git a/src-tauri/src/mcp/http_transport.rs b/src-tauri/src/mcp/http_transport.rs new file mode 100644 index 0000000..c831710 --- /dev/null +++ b/src-tauri/src/mcp/http_transport.rs @@ -0,0 +1,164 @@ +//! Streamable HTTP transport (FR-MCP-6): loopback-only bind + a bearer-token +//! gate that runs in front of every connection, before a single byte reaches +//! the MCP service. `rmcp`'s `StreamableHttpService` is a bare +//! `tower_service::Service` (not an axum app), so this module supplies the +//! actual TCP accept loop + HTTP/1 framing via `hyper`. + +use crate::mcp::handler::WaMcpHandler; +use crate::mcp::{token, McpError}; +use bytes::Bytes; +use http_body_util::{combinators::BoxBody, BodyExt, Full}; +use hyper::body::Incoming; +use hyper::service::service_fn; +use hyper::{Request, Response, StatusCode}; +use hyper_util::rt::TokioIo; +use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; +use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService}; +use std::convert::Infallible; +use std::net::{IpAddr, SocketAddr}; +use std::sync::Arc; +use tokio::net::TcpListener; +use tokio_util::sync::CancellationToken; + +/// Binds `host:port`, refusing anything that doesn't resolve to a loopback +/// address (127.0.0.0/8 or ::1) -- the only way this crate ever opens a +/// listening socket for MCP (FR-MCP-1, NFR-SEC-5). Kept generic over `host` +/// purely so the refusal path is directly unit-testable; the only production +/// caller (`mcp::server`) always passes `"127.0.0.1"`. +pub(crate) async fn bind_loopback(host: &str, port: u16) -> Result { + let ip: IpAddr = host.parse().map_err(|_| McpError::NonLoopback)?; + if !ip.is_loopback() { + return Err(McpError::NonLoopback); + } + TcpListener::bind(SocketAddr::new(ip, port)) + .await + .map_err(|e| McpError::Server(e.to_string())) +} + +/// A running HTTP server; `stop()` cancels the accept loop and all live +/// connections and waits for cleanup. +pub(crate) struct HttpServerHandle { + pub local_addr: SocketAddr, + shutdown: CancellationToken, + join: tokio::task::JoinHandle<()>, +} + +impl HttpServerHandle { + pub async fn stop(self) { + self.shutdown.cancel(); + let _ = self.join.await; + } +} + +/// Serves the MCP Streamable HTTP endpoint (`/mcp`, per the config's session +/// routing) on an already-bound loopback listener. Every request must present +/// `Authorization: Bearer ` matching the stored token (constant-time +/// compare, `mcp::token::verify`) or it never reaches `rmcp`. +pub(crate) fn serve(listener: TcpListener, expected_token: String, handler: WaMcpHandler) -> HttpServerHandle { + let local_addr = listener + .local_addr() + .expect("a just-bound TcpListener has a local addr"); + let shutdown = CancellationToken::new(); + + let config = StreamableHttpServerConfig { + stateful_mode: true, + ..Default::default() + }; + let session_manager = Arc::new(LocalSessionManager::default()); + let service = StreamableHttpService::new( + move || Ok(handler.clone()), + session_manager, + config, + ); + + let accept_ct = shutdown.clone(); + let join = tokio::spawn(async move { + loop { + tokio::select! { + _ = accept_ct.cancelled() => break, + accepted = listener.accept() => { + let Ok((stream, _peer)) = accepted else { continue }; + let io = TokioIo::new(stream); + let svc = service.clone(); + let token = expected_token.clone(); + let conn_ct = accept_ct.clone(); + tokio::spawn(async move { + let guarded = service_fn(move |req: Request| { + let mut svc = svc.clone(); + let token = token.clone(); + async move { Ok::<_, Infallible>(handle_request(req, &mut svc, &token).await) } + }); + let conn = hyper::server::conn::http1::Builder::new().serve_connection(io, guarded); + tokio::select! { + _ = conn_ct.cancelled() => {} + _ = conn => {} + } + }); + } + } + } + }); + + HttpServerHandle { local_addr, shutdown, join } +} + +async fn handle_request( + req: Request, + svc: &mut StreamableHttpService, + expected_token: &str, +) -> Response> { + if !is_authorized(&req, expected_token) { + return unauthorized_response(); + } + let (parts, body) = req.into_parts(); + let req = Request::from_parts(parts, body.boxed()); + tower_service::Service::call(svc, req) + .await + .unwrap_or_else(|never: Infallible| match never {}) +} + +fn is_authorized(req: &Request, expected_token: &str) -> bool { + let Some(header) = req.headers().get(hyper::header::AUTHORIZATION) else { + return false; + }; + let Ok(header) = header.to_str() else { + return false; + }; + let Some(presented) = header.strip_prefix("Bearer ") else { + return false; + }; + token::verify(presented, expected_token) +} + +fn unauthorized_response() -> Response> { + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header(hyper::header::CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from_static(b"{\"error\":\"unauthorized\"}")).boxed()) + .expect("valid response") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn refuses_a_non_loopback_bind() { + // A real routable address is never allowed regardless of port + // availability -- the check happens before any socket syscall. + let err = bind_loopback("8.8.8.8", 0).await.unwrap_err(); + assert!(matches!(err, McpError::NonLoopback)); + } + + #[tokio::test] + async fn refuses_an_unparseable_host() { + let err = bind_loopback("not-an-ip", 0).await.unwrap_err(); + assert!(matches!(err, McpError::NonLoopback)); + } + + #[tokio::test] + async fn binds_127_0_0_1_on_an_os_assigned_port() { + let listener = bind_loopback("127.0.0.1", 0).await.expect("loopback bind"); + assert!(listener.local_addr().unwrap().ip().is_loopback()); + } +} -- 2.34.1 From 851e89720dd3e05e6de8fd68187bea961a1d1dcd Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:20:39 -0500 Subject: [PATCH 013/103] feat(mcp): auth token in the OS credential store, not settings/DB (T10.4, FR-MCP-1) Mirrors sync::credentials: mint_and_store() generates a fresh 32-byte random token per set_mcp_enabled call and writes it via `keyring` (Windows Credential Manager); the token is never persisted to settings.json/wa.db/logs. verify() is a constant-time compare so token checking doesn't leak timing information about a partial match (NFR-SEC-5). --- src-tauri/src/mcp/token.rs | 90 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 src-tauri/src/mcp/token.rs diff --git a/src-tauri/src/mcp/token.rs b/src-tauri/src/mcp/token.rs new file mode 100644 index 0000000..0471305 --- /dev/null +++ b/src-tauri/src/mcp/token.rs @@ -0,0 +1,90 @@ +//! MCP auth-token storage (FR-MCP-1/6). The token itself is **never** written +//! to `settings.json`/`wa.db`/logs — only the OS credential store, exactly +//! like sync secrets (`sync::credentials`) and hosted-AI API keys. + +use crate::mcp::McpError; + +const SERVICE: &str = "WhispAssist-mcp"; +const ACCOUNT: &str = "token"; +const TOKEN_BYTES: usize = 32; + +fn entry() -> Result { + keyring::Entry::new(SERVICE, ACCOUNT).map_err(|e| McpError::Server(e.to_string())) +} + +/// Generates a fresh random token (hex-encoded, 64 chars) and persists it, +/// replacing whatever was there before (each `set_mcp_enabled` mints a new +/// one — there is no "reveal the existing token" path, same treatment as a +/// password). +pub fn mint_and_store() -> Result { + let mut buf = [0u8; TOKEN_BYTES]; + getrandom::getrandom(&mut buf).map_err(|e| McpError::Server(e.to_string()))?; + let token = hex_encode(&buf); + entry()? + .set_password(&token) + .map_err(|e| McpError::Server(e.to_string()))?; + Ok(token) +} + +/// Best-effort read for `mcp_status`'s `tokenSet` flag — never returned to +/// the frontend as a value, only whether one exists. +pub fn is_set() -> bool { + entry().and_then(|e| e.get_password().map_err(|e| McpError::Server(e.to_string()))).is_ok() +} + +pub fn get() -> Result { + entry()? + .get_password() + .map_err(|e| McpError::Server(e.to_string())) +} + +/// Best-effort cleanup on disable — a missing entry is not an error. +pub fn delete() { + if let Ok(e) = entry() { + match e.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => {} + Err(err) => tracing::warn!("failed to delete MCP token: {err}"), + } + } +} + +/// Constant-time comparison so token checking doesn't leak timing +/// information about how many leading bytes matched (NFR-SEC-5). +pub fn verify(presented: &str, expected: &str) -> bool { + let a = presented.as_bytes(); + let b = expected.as_bytes(); + if a.len() != b.len() { + return false; + } + let mut diff = 0u8; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + diff == 0 +} + +fn hex_encode(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push_str(&format!("{b:02x}")); + } + s +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn verify_requires_exact_match() { + assert!(verify("abc123", "abc123")); + assert!(!verify("abc123", "abc124")); + assert!(!verify("abc12", "abc123")); + assert!(!verify("", "abc123")); + } + + #[test] + fn hex_encode_is_lowercase_and_fixed_width() { + assert_eq!(hex_encode(&[0, 255, 16]), "00ff10"); + } +} -- 2.34.1 From 5f690f4b74bca622b311378639ae70f264a857c0 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:21:16 -0500 Subject: [PATCH 014/103] feat(mcp): stdio transport adapter (T10.4, FR-MCP-6) serve_once() runs one MCP session over the current process's stdin/ stdout to completion -- the entry point main.rs's --mcp-stdio flag calls into (next commit). No bearer-token check here: spawning this process at all requires the same local-user privilege as any other command, so process-spawn capability is the trust boundary for stdio, not a header. --- src-tauri/src/mcp/stdio_transport.rs | 31 ++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src-tauri/src/mcp/stdio_transport.rs diff --git a/src-tauri/src/mcp/stdio_transport.rs b/src-tauri/src/mcp/stdio_transport.rs new file mode 100644 index 0000000..3447cf6 --- /dev/null +++ b/src-tauri/src/mcp/stdio_transport.rs @@ -0,0 +1,31 @@ +//! stdio transport (FR-MCP-6) — "a thin adapter the agent spawns". A coding +//! agent's MCP client config spawns `whispassist.exe --mcp-stdio` and talks +//! JSON-RPC over that child process's stdin/stdout; `main.rs` checks for that +//! flag before building the Tauri window and calls `serve_once` here instead. +//! +//! There is no bearer-token header to check here (unlike HTTP): the ability +//! to spawn this process at all already requires the same OS-level privilege +//! as running any other local command as the signed-in user, so process-spawn +//! capability is the trust boundary for stdio, same as other local-only MCP +//! servers. `set_mcp_enabled` still mints/stores a token (`mcp::token`) for +//! parity with the HTTP transport and in case a future stdio client wants to +//! pass it, but this transport does not require presenting it. + +use crate::mcp::handler::WaMcpHandler; +use crate::mcp::McpError; +use rmcp::ServiceExt; + +/// Serves one MCP session over the current process's stdin/stdout until the +/// peer disconnects, then returns. +pub async fn serve_once(handler: WaMcpHandler) -> Result<(), McpError> { + let transport = rmcp::transport::io::stdio(); + let running = handler + .serve(transport) + .await + .map_err(|e| McpError::Server(e.to_string()))?; + running + .waiting() + .await + .map_err(|e| McpError::Server(e.to_string()))?; + Ok(()) +} -- 2.34.1 From 58d565907bd3f42b8a1c64857f4e761d1402acc1 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:21:58 -0500 Subject: [PATCH 015/103] feat(mcp): McpToolDescriptor is Copy (T10.4) Needed so RmcpServer::tools() can hand back TOOL_DESCRIPTORS without a manual field-by-field clone (next commit). --- src-tauri/src/mcp/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src-tauri/src/mcp/mod.rs b/src-tauri/src/mcp/mod.rs index 9ada033..be9c67c 100644 --- a/src-tauri/src/mcp/mod.rs +++ b/src-tauri/src/mcp/mod.rs @@ -115,6 +115,7 @@ pub struct McpHandle { pub token: String, } +#[derive(Debug, Clone, Copy)] pub struct McpToolDescriptor { pub name: &'static str, pub description: &'static str, -- 2.34.1 From bd9b5ba3cc4b9cccd9fdd4bbf4f761b408bd33a2 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:22:35 -0500 Subject: [PATCH 016/103] feat(mcp): RmcpServer wires token+transport+handler together (T10.4) start() mints/stores a fresh token, then either binds the loopback HTTP listener (bind_loopback + http_transport::serve) or, for stdio, just records "enabled" and hands back the whishassist.exe --mcp-stdio command line the agent's client config should spawn -- there is nothing to run in-process for stdio (see mcp::stdio_transport). stop() tears down the HTTP listener if any and deletes the stored token. instance() is a process-wide singleton (OnceLock) so separate Tauri command invocations (set_mcp_enabled/mcp_status/set_mcp_scope) share the same running state. --- src-tauri/src/mcp/server.rs | 109 ++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 src-tauri/src/mcp/server.rs diff --git a/src-tauri/src/mcp/server.rs b/src-tauri/src/mcp/server.rs new file mode 100644 index 0000000..cb42186 --- /dev/null +++ b/src-tauri/src/mcp/server.rs @@ -0,0 +1,109 @@ +//! `RmcpServer` — the concrete `McpServer` implementation (T10.4). Owns the +//! one running transport (HTTP listener, if any) so `stop()` can tear it +//! down; a process-wide singleton (`instance`) is what `commands.rs` reaches +//! for, since Tauri command handlers are separate calls with no shared state +//! of their own beyond `AppState`. + +use crate::mcp::handler::WaMcpHandler; +use crate::mcp::{http_transport, token, McpConfig, McpError, McpHandle, McpServer, McpToolDescriptor, McpTransport}; +use crate::storage::Store; +use async_trait::async_trait; +use std::sync::{Arc, OnceLock}; +use tauri::AppHandle; +use tokio::sync::Mutex; + +enum Running { + Http(http_transport::HttpServerHandle), + /// stdio has nothing running *in this process* — the agent spawns its + /// own `--mcp-stdio` child (see `mcp::stdio_transport`); this variant + /// just records "enabled" for `mcp_status`. + Stdio, +} + +pub struct RmcpServer { + store: Arc, + app: AppHandle, + running: Mutex>, +} + +impl RmcpServer { + pub fn new(store: Arc, app: AppHandle) -> Self { + Self { + store, + app, + running: Mutex::new(None), + } + } + + async fn stop_running(&self) { + if let Some(running) = self.running.lock().await.take() { + if let Running::Http(handle) = running { + handle.stop().await; + } + } + } + + /// `true` once a `start()` has actually taken effect (HTTP listener bound + /// or stdio mode recorded) — used by `mcp_status`. + pub async fn is_running(&self) -> bool { + self.running.lock().await.is_some() + } +} + +#[async_trait] +impl McpServer for RmcpServer { + async fn start(&self, cfg: McpConfig) -> Result { + // Re-enabling (or switching transport/port) replaces whatever was running. + self.stop_running().await; + let auth_token = token::mint_and_store()?; + + match cfg.transport { + McpTransport::Http => { + let listener = http_transport::bind_loopback("127.0.0.1", cfg.port).await?; + let local_addr = listener + .local_addr() + .map_err(|e| McpError::Server(e.to_string()))?; + let handler = WaMcpHandler::new(self.store.clone(), self.app.clone()); + let handle = http_transport::serve(listener, auth_token.clone(), handler); + let endpoint = format!("http://{local_addr}/mcp"); + *self.running.lock().await = Some(Running::Http(handle)); + Ok(McpHandle { + endpoint, + token: auth_token, + }) + } + McpTransport::Stdio => { + *self.running.lock().await = Some(Running::Stdio); + let exe = std::env::current_exe() + .ok() + .and_then(|p| p.to_str().map(str::to_string)) + .unwrap_or_else(|| "whispassist.exe".to_string()); + Ok(McpHandle { + endpoint: format!("{exe} --mcp-stdio"), + token: auth_token, + }) + } + } + } + + async fn stop(&self, _handle: McpHandle) -> Result<(), McpError> { + self.stop_running().await; + token::delete(); + Ok(()) + } + + fn tools(&self) -> Vec { + crate::mcp::TOOL_DESCRIPTORS.to_vec() + } +} + +static INSTANCE: OnceLock> = OnceLock::new(); + +/// The process-wide `RmcpServer`. `store`/`app` are only used on the first +/// call (they're the same `AppState`/`AppHandle` for the process's whole +/// life); later calls just return the existing instance. +pub fn instance(store: Arc, app: AppHandle) -> Arc { + INSTANCE + .get_or_init(|| Arc::new(RmcpServer::new(store, app))) + .clone() +} -- 2.34.1 From 49cc1e6cd31266d49c2dcc081b3c6b5d4cc5d37d Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:23:20 -0500 Subject: [PATCH 017/103] fix(mcp): WaMcpHandler's AppHandle is optional (T10.4) The --mcp-stdio child process (main.rs, next commits) has no running Tauri app/window to emit "mcp://access" events to -- only the GUI instance's HTTP transport does. The mcp_access_log DB row is still written unconditionally either way (FR-MCP-5); only the live event is skipped when there's no AppHandle. --- src-tauri/src/mcp/handler.rs | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src-tauri/src/mcp/handler.rs b/src-tauri/src/mcp/handler.rs index d934c06..42fb7e6 100644 --- a/src-tauri/src/mcp/handler.rs +++ b/src-tauri/src/mcp/handler.rs @@ -23,14 +23,18 @@ use tauri::{AppHandle, Emitter}; /// Shared handle the HTTP/stdio transports build a fresh `rmcp` service /// around per-connection (`ServerHandler` methods take `&self`, so this just /// needs to be `Clone` + cheap — it's an `Arc` and an `AppHandle`). +/// `app` is `None` in `--mcp-stdio` mode (a separate process with no Tauri +/// window to emit events to, see `mcp::stdio_transport`/`main.rs`) — the +/// `mcp_access_log` row is still written either way (FR-MCP-5), only the +/// live `"mcp://access"` event has nowhere to go. #[derive(Clone)] pub struct WaMcpHandler { store: Arc, - app: AppHandle, + app: Option, } impl WaMcpHandler { - pub fn new(store: Arc, app: AppHandle) -> Self { + pub fn new(store: Arc, app: Option) -> Self { Self { store, app } } @@ -48,15 +52,17 @@ impl WaMcpHandler { if let Err(e) = self.store.record_mcp_access(tool, meeting_id, client).await { tracing::warn!("failed to record mcp access log row: {e}"); } - let _ = self.app.emit( - "mcp://access", - json!({ - "at": now_ms(), - "tool": tool, - "meetingId": meeting_id, - "client": client, - }), - ); + if let Some(app) = &self.app { + let _ = app.emit( + "mcp://access", + json!({ + "at": now_ms(), + "tool": tool, + "meetingId": meeting_id, + "client": client, + }), + ); + } } fn client_name(context: &RequestContext) -> Option { -- 2.34.1 From eb2aa2a0d65c37e0d11fed0afd91a6c45afe0a19 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:23:27 -0500 Subject: [PATCH 018/103] fix(mcp): pass the GUI's AppHandle as Some(..) to the handler (T10.4) Follows the previous commit's WaMcpHandler::new signature change. --- src-tauri/src/mcp/server.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/mcp/server.rs b/src-tauri/src/mcp/server.rs index cb42186..feb487e 100644 --- a/src-tauri/src/mcp/server.rs +++ b/src-tauri/src/mcp/server.rs @@ -63,7 +63,7 @@ impl McpServer for RmcpServer { let local_addr = listener .local_addr() .map_err(|e| McpError::Server(e.to_string()))?; - let handler = WaMcpHandler::new(self.store.clone(), self.app.clone()); + 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://{local_addr}/mcp"); *self.running.lock().await = Some(Running::Http(handle)); -- 2.34.1 From 949bade13739c0f1e82a74e3a5f55cb635dafa09 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:23:47 -0500 Subject: [PATCH 019/103] feat(mcp): --mcp-stdio launches the headless stdio adapter (T10.4, FR-MCP-6) Checked before whispassist_lib::run() builds the Tauri app -- the flag routes straight to run_mcp_stdio() (next commit) and returns instead of opening a window. --- src-tauri/src/main.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index afe2e08..67d809d 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -3,5 +3,14 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { + // `whispassist.exe --mcp-stdio` (FR-MCP-6): the stdio "adapter the agent + // spawns" is this same binary, in headless mode -- it serves one MCP + // session over its own stdin/stdout and exits, instead of opening the + // GUI window. A coding agent's MCP client config spawns this exact + // command line (see `mcp_status`/`set_mcp_enabled`'s returned endpoint). + if std::env::args().any(|a| a == "--mcp-stdio") { + whispassist_lib::run_mcp_stdio(); + return; + } whispassist_lib::run(); } -- 2.34.1 From f85b78c9eb69c26fcb0655c6987ad8fc0f9272ea Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:24:32 -0500 Subject: [PATCH 020/103] fix(mcp): add tokio-util for CancellationToken (T10.4) http_transport.rs needs tokio_util::sync::CancellationToken directly; rmcp pulls tokio-util transitively but that doesn't make it `use`-able from our own crate without a direct Cargo.toml entry. --- src-tauri/Cargo.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0499ac9..5c3185b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -66,6 +66,7 @@ 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). @@ -127,6 +128,7 @@ sync = ["dep:keyring"] # remote upload (WebDAV + OAuth providers) 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] -- 2.34.1 From f99845ef1b9a989d1f8547122b3dd198495c7eee Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:24:47 -0500 Subject: [PATCH 021/103] feat(mcp): run_mcp_stdio() serves one session over process stdio (T10.4, FR-MCP-6) Builds its own tokio runtime (there is no Tauri app in this mode) and tracing goes to stderr, not stdout -- stdout is the MCP JSON-RPC channel. Connects to the same wa.db as the GUI instance via SqliteStore::connect, builds a WaMcpHandler with no AppHandle (None), and runs it to completion via mcp::stdio_transport::serve_once. A build without the `mcp` feature prints an error and exits(1) instead of silently opening the GUI. --- src-tauri/src/lib.rs | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9684caf..8b186a3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -280,3 +280,45 @@ pub(crate) fn update_tray_tooltip(app: &tauri::AppHandle, text: &str) { let _ = tray.0.set_tooltip(Some(text)); } } + +/// Entry point for `whispassist.exe --mcp-stdio` (FR-MCP-6): serves one MCP +/// session over this process's own stdin/stdout instead of showing a window, +/// against the same `wa.db` the GUI instance uses. There is no Tauri +/// `AppHandle` in this mode, so `mcp://access` events have nowhere to go — +/// the `mcp_access_log` DB row is still written regardless (FR-MCP-5). +pub fn run_mcp_stdio() { + #[cfg(feature = "mcp")] + { + // stderr, not stdout: stdout is the MCP JSON-RPC channel. + let _ = tracing_subscriber::fmt() + .with_env_filter("info") + .with_writer(std::io::stderr) + .try_init(); + let rt = match tokio::runtime::Builder::new_multi_thread().enable_all().build() { + Ok(rt) => rt, + Err(e) => { + eprintln!("whispassist --mcp-stdio: failed to start a runtime: {e}"); + std::process::exit(1); + } + }; + rt.block_on(async { + let store: std::sync::Arc = match storage::SqliteStore::connect().await { + Ok(s) => std::sync::Arc::new(s), + Err(e) => { + eprintln!("whispassist --mcp-stdio: failed to open wa.db: {e}"); + std::process::exit(1); + } + }; + let handler = mcp::handler::WaMcpHandler::new(store, None); + if let Err(e) = mcp::stdio_transport::serve_once(handler).await { + eprintln!("whispassist --mcp-stdio: session ended with an error: {e}"); + std::process::exit(1); + } + }); + } + #[cfg(not(feature = "mcp"))] + { + eprintln!("this build was compiled without MCP support (the `mcp` cargo feature is off)"); + std::process::exit(1); + } +} -- 2.34.1 From 0ecc72f18f4283462b8f9d3c82615f2c78fac224 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:26:11 -0500 Subject: [PATCH 022/103] feat(mcp): wire mcp_status/set_mcp_enabled/set_mcp_scope/mcp_access_log (T10.4) Replaces the four not_implemented() stubs. mcp_status/set_mcp_enabled reach the process-wide RmcpServer singleton (behind `#[cfg(feature = "mcp")]`, with a not_implemented fallback for builds without it); set_mcp_scope/mcp_access_log are plain settings/Store I/O and work in every build regardless of the `mcp` cargo feature. The token is only ever returned once, right when set_mcp_enabled mints it -- it is never re-readable afterwards, same as any other freshly-issued secret. --- src-tauri/src/commands.rs | 125 ++++++++++++++++++++++++++++++++++---- 1 file changed, 114 insertions(+), 11 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 059f976..b4add3d 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -3135,29 +3135,132 @@ pub async fn set_brief_exposed(_id: String, _exposed: bool) -> WaResult<()> { } #[tauri::command] -pub async fn mcp_status() -> WaResult { - Err(not_implemented("mcp_status")) +pub async fn mcp_status(state: State<'_, AppState>, app: AppHandle) -> WaResult { + #[cfg(feature = "mcp")] + { + let settings = load_settings(); + let server = crate::mcp::server::instance(state.store.clone(), app); + let running = server.is_running().await; + let transport = crate::mcp::McpTransport::parse(&settings.mcp_transport); + let endpoint = if running { + mcp_endpoint_for(transport, settings.mcp_port) + } else { + String::new() + }; + Ok(serde_json::json!({ + "enabled": running, + "transport": transport.as_str(), + "endpoint": endpoint, + "tokenSet": crate::mcp::token::is_set(), + "exposeScope": settings.mcp_expose, + })) + } + #[cfg(not(feature = "mcp"))] + { + let _ = (state, app); + Err(not_implemented("mcp_status")) + } +} + +/// Builds the endpoint string surfaced by `mcp_status`/`set_mcp_enabled` — +/// shared so both agree on the shape (`http://127.0.0.1:/mcp` for +/// Streamable HTTP, or the `--mcp-stdio` command line the agent should spawn). +#[cfg(feature = "mcp")] +fn mcp_endpoint_for(transport: crate::mcp::McpTransport, port: u16) -> String { + match transport { + crate::mcp::McpTransport::Http => format!("http://127.0.0.1:{port}/mcp"), + crate::mcp::McpTransport::Stdio => { + let exe = std::env::current_exe() + .ok() + .and_then(|p| p.to_str().map(str::to_string)) + .unwrap_or_else(|| "whispassist.exe".to_string()); + format!("{exe} --mcp-stdio") + } + } } /// Enable/disable the loopback MCP server; returns endpoint + token on enable (FR-MCP-1/6). +/// The token is minted fresh on every enable and lives only in the OS +/// credential store (`mcp::token`) — this command's return value is the one +/// time it's ever surfaced, exactly like a newly-created password. #[tauri::command] pub async fn set_mcp_enabled( - _enabled: bool, - _transport: Option, - _port: Option, + state: State<'_, AppState>, + app: AppHandle, + enabled: bool, + transport: Option, + port: Option, ) -> WaResult { - Err(not_implemented("set_mcp_enabled")) + #[cfg(feature = "mcp")] + { + use crate::mcp::McpServer; + + let mut settings = load_settings(); + if let Some(t) = &transport { + settings.mcp_transport = t.clone(); + } + if let Some(p) = port { + settings.mcp_port = p; + } + settings.mcp_enabled = enabled; + save_settings(&settings)?; + + let server = crate::mcp::server::instance(state.store.clone(), app); + if enabled { + let cfg = crate::mcp::McpConfig { + transport: crate::mcp::McpTransport::parse(&settings.mcp_transport), + port: settings.mcp_port, + expose: crate::mcp::ExposeScope::parse(&settings.mcp_expose), + expose_recordings: settings.mcp_expose_recordings, + }; + let handle = server + .start(cfg) + .await + .map_err(|e| WaError::new("mcp", e.to_string()))?; + Ok(serde_json::json!({ "endpoint": handle.endpoint, "token": handle.token })) + } else { + server + .stop(crate::mcp::McpHandle { + endpoint: String::new(), + token: String::new(), + }) + .await + .map_err(|e| WaError::new("mcp", e.to_string()))?; + Ok(serde_json::json!({ "endpoint": "", "token": "" })) + } + } + #[cfg(not(feature = "mcp"))] + { + let _ = (state, app, enabled, transport, port); + Err(not_implemented("set_mcp_enabled")) + } } -/// Set exposure scope (none|selected|all) and whether recordings may be served (FR-MCP-3). +/// Set exposure scope (none|selected|all) and whether recordings may be served +/// (FR-MCP-3). Pure settings I/O — every MCP tool handler reads this live +/// (`mcp::handler::WaMcpHandler::current_scope`), so a change here takes +/// effect immediately without restarting the server. #[tauri::command] -pub async fn set_mcp_scope(_expose: String, _expose_recordings: Option) -> WaResult<()> { - Err(not_implemented("set_mcp_scope")) +pub async fn set_mcp_scope(expose: String, expose_recordings: Option) -> WaResult<()> { + let mut settings = load_settings(); + settings.mcp_expose = expose; + if let Some(r) = expose_recordings { + settings.mcp_expose_recordings = r; + } + save_settings(&settings) } +/// Audit trail (FR-MCP-5) — every tool read, allowed or denied. #[tauri::command] -pub async fn mcp_access_log(_limit: Option) -> WaResult> { - Err(not_implemented("mcp_access_log")) +pub async fn mcp_access_log( + state: State<'_, AppState>, + limit: Option, +) -> WaResult> { + state + .store + .list_mcp_access_log(limit) + .await + .map_err(|e| WaError::new("mcp", e.to_string())) } // ---- Agent push / task-tracker handoff (Phase 10c, later) ---- -- 2.34.1 From 45e34284e9ae690a589c879dd6af1ecbd257b5c0 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:27:56 -0500 Subject: [PATCH 023/103] fix(mcp): use HttpServerHandle.local_addr instead of a second lookup (T10.4) Fixes a dead_code warning (clippy -D warnings would fail on it) -- server.rs was calling listener.local_addr() itself before handing the listener to http_transport::serve(), leaving the handle's own local_addr field unread. --- src-tauri/src/mcp/server.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src-tauri/src/mcp/server.rs b/src-tauri/src/mcp/server.rs index feb487e..3c08b86 100644 --- a/src-tauri/src/mcp/server.rs +++ b/src-tauri/src/mcp/server.rs @@ -60,12 +60,9 @@ impl McpServer for RmcpServer { match cfg.transport { McpTransport::Http => { let listener = http_transport::bind_loopback("127.0.0.1", cfg.port).await?; - let local_addr = listener - .local_addr() - .map_err(|e| McpError::Server(e.to_string()))?; 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://{local_addr}/mcp"); + let endpoint = format!("http://{}/mcp", handle.local_addr); *self.running.lock().await = Some(Running::Http(handle)); Ok(McpHandle { endpoint, -- 2.34.1 From e0112ec8c4f171e3fd56be4fb854308d5f7170a5 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:29:48 -0500 Subject: [PATCH 024/103] style(mcp): rustfmt --- src-tauri/src/mcp/handler.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/mcp/handler.rs b/src-tauri/src/mcp/handler.rs index 42fb7e6..3636120 100644 --- a/src-tauri/src/mcp/handler.rs +++ b/src-tauri/src/mcp/handler.rs @@ -197,7 +197,8 @@ impl WaMcpHandler { // call `scope::brief_visible(scope_val, exposed)` before returning. match crate::commands::get_feature_brief(id).await { Ok(brief) => Ok(CallToolResult::structured( - serde_json::to_value(brief).map_err(|e| McpProtoError::internal_error(e.to_string(), None))?, + 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, -- 2.34.1 From 07072551c05864acb511fd11333983d5687d516a Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:29:49 -0500 Subject: [PATCH 025/103] style(mcp): rustfmt --- src-tauri/src/mcp/token.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/mcp/token.rs b/src-tauri/src/mcp/token.rs index 0471305..7303f39 100644 --- a/src-tauri/src/mcp/token.rs +++ b/src-tauri/src/mcp/token.rs @@ -29,7 +29,12 @@ pub fn mint_and_store() -> Result { /// 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() + entry() + .and_then(|e| { + e.get_password() + .map_err(|e| McpError::Server(e.to_string())) + }) + .is_ok() } pub fn get() -> Result { -- 2.34.1 From 36a28dc0963599d9bb1e864a28ca335243d5767f Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:29:50 -0500 Subject: [PATCH 026/103] style(mcp): rustfmt --- src-tauri/src/mcp/scope.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/mcp/scope.rs b/src-tauri/src/mcp/scope.rs index 614e51a..6dd7602 100644 --- a/src-tauri/src/mcp/scope.rs +++ b/src-tauri/src/mcp/scope.rs @@ -45,7 +45,11 @@ pub fn recording_gate_ok(expose_recordings: bool, meeting_recorded: bool) -> boo } /// 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 { +pub fn meeting_allowed( + scope: ExposeScope, + expose_recordings: bool, + meeting_recorded: bool, +) -> bool { meetings_visible(scope) && recording_gate_ok(expose_recordings, meeting_recorded) } -- 2.34.1 From 386be756584fe77bff3cd0528c9d5c24bf1fe98a Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:29:52 -0500 Subject: [PATCH 027/103] style(mcp): rustfmt --- src-tauri/src/mcp/http_transport.rs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/mcp/http_transport.rs b/src-tauri/src/mcp/http_transport.rs index c831710..396b1a3 100644 --- a/src-tauri/src/mcp/http_transport.rs +++ b/src-tauri/src/mcp/http_transport.rs @@ -54,7 +54,11 @@ impl HttpServerHandle { /// routing) on an already-bound loopback listener. Every request must present /// `Authorization: Bearer ` matching the stored token (constant-time /// compare, `mcp::token::verify`) or it never reaches `rmcp`. -pub(crate) fn serve(listener: TcpListener, expected_token: String, handler: WaMcpHandler) -> HttpServerHandle { +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"); @@ -65,11 +69,7 @@ pub(crate) fn serve(listener: TcpListener, expected_token: String, handler: WaMc ..Default::default() }; let session_manager = Arc::new(LocalSessionManager::default()); - let service = StreamableHttpService::new( - move || Ok(handler.clone()), - session_manager, - config, - ); + let service = StreamableHttpService::new(move || Ok(handler.clone()), session_manager, config); let accept_ct = shutdown.clone(); let join = tokio::spawn(async move { @@ -99,7 +99,11 @@ pub(crate) fn serve(listener: TcpListener, expected_token: String, handler: WaMc } }); - HttpServerHandle { local_addr, shutdown, join } + HttpServerHandle { + local_addr, + shutdown, + join, + } } async fn handle_request( -- 2.34.1 From 71c4c6ecf006e7fdccc4838cc25549294900455a Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:29:54 -0500 Subject: [PATCH 028/103] style(mcp): rustfmt --- src-tauri/src/mcp/server.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/mcp/server.rs b/src-tauri/src/mcp/server.rs index 3c08b86..a3c57c9 100644 --- a/src-tauri/src/mcp/server.rs +++ b/src-tauri/src/mcp/server.rs @@ -5,7 +5,10 @@ //! of their own beyond `AppState`. use crate::mcp::handler::WaMcpHandler; -use crate::mcp::{http_transport, token, McpConfig, McpError, McpHandle, McpServer, McpToolDescriptor, McpTransport}; +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}; -- 2.34.1 From 8caeb818a748cd270e25dca06cf64767d1a9b1c7 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:29:56 -0500 Subject: [PATCH 029/103] style(mcp): rustfmt run_mcp_stdio --- src-tauri/src/lib.rs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8b186a3..470cebc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -294,7 +294,10 @@ pub fn run_mcp_stdio() { .with_env_filter("info") .with_writer(std::io::stderr) .try_init(); - let rt = match tokio::runtime::Builder::new_multi_thread().enable_all().build() { + 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}"); @@ -302,13 +305,14 @@ pub fn run_mcp_stdio() { } }; rt.block_on(async { - let store: std::sync::Arc = match storage::SqliteStore::connect().await { - Ok(s) => std::sync::Arc::new(s), - Err(e) => { - eprintln!("whispassist --mcp-stdio: failed to open wa.db: {e}"); - std::process::exit(1); - } - }; + let store: std::sync::Arc = + match storage::SqliteStore::connect().await { + Ok(s) => std::sync::Arc::new(s), + Err(e) => { + eprintln!("whispassist --mcp-stdio: failed to open wa.db: {e}"); + std::process::exit(1); + } + }; let handler = mcp::handler::WaMcpHandler::new(store, None); if let Err(e) = mcp::stdio_transport::serve_once(handler).await { eprintln!("whispassist --mcp-stdio: session ended with an error: {e}"); -- 2.34.1 From 59742e4fdffcbdcae58e02f9b36a389b822bc0b6 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:29:58 -0500 Subject: [PATCH 030/103] style(mcp): rustfmt Store trait additions --- src-tauri/src/storage/mod.rs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/storage/mod.rs b/src-tauri/src/storage/mod.rs index e216d1d..73f47f1 100644 --- a/src-tauri/src/storage/mod.rs +++ b/src-tauri/src/storage/mod.rs @@ -302,7 +302,10 @@ pub trait Store: Send + Sync { /// Confirmed action items for a meeting (FR-MCP-2 `get_action_items`) — /// distinct from `list_pending_reminders` (which is filtered to /// unfired-reminder rows across *all* meetings for the startup reconcile). - async fn list_action_items(&self, meeting_id: &MeetingId) -> Result, StoreError>; + async fn list_action_items( + &self, + meeting_id: &MeetingId, + ) -> Result, StoreError>; /// Appends one row to the audit log (FR-MCP-5). Every MCP tool read calls /// this, regardless of whether the read was actually allowed to see /// anything — the audit trail is "what an agent asked for", not just @@ -314,7 +317,10 @@ pub trait Store: Send + Sync { client: Option<&str>, ) -> Result<(), StoreError>; /// Most recent audit rows first, optionally capped (`mcp_access_log` command). - async fn list_mcp_access_log(&self, limit: Option) -> Result, StoreError>; + async fn list_mcp_access_log( + &self, + limit: Option, + ) -> Result, StoreError>; } /// SQLite-backed store. Migrations live in `migrations/` (`sqlx::migrate!`). @@ -1503,7 +1509,10 @@ impl Store for SqliteStore { Ok(rows) } - async fn list_action_items(&self, meeting_id: &MeetingId) -> Result, StoreError> { + async fn list_action_items( + &self, + meeting_id: &MeetingId, + ) -> Result, StoreError> { let rows = sqlx::query( "SELECT id, text, owner, due_at, confirmed, reminder_set FROM action_items \ WHERE meeting_id = ? ORDER BY created_at ASC", @@ -1543,7 +1552,10 @@ impl Store for SqliteStore { Ok(()) } - async fn list_mcp_access_log(&self, limit: Option) -> Result, StoreError> { + async fn list_mcp_access_log( + &self, + limit: Option, + ) -> Result, StoreError> { let cap: i64 = limit.map(i64::from).unwrap_or(200).max(1); let rows = sqlx::query( "SELECT at, tool, meeting_id, client FROM mcp_access_log ORDER BY at DESC LIMIT ?", -- 2.34.1 From bd970325503aea21aa4c598e98d890830500c726 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:30:00 -0500 Subject: [PATCH 031/103] style: rustfmt (incl. one pre-existing long line in serve_recording) --- src-tauri/src/commands.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index b4add3d..77471af 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1740,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)); -- 2.34.1 From 55b520d3737961c2fd7d450fba115b22b999db17 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:30:01 -0500 Subject: [PATCH 032/103] style(storage): rustfmt fixup for list_feature_briefs (M1.1) --- src-tauri/src/storage/mod.rs | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/storage/mod.rs b/src-tauri/src/storage/mod.rs index 609b4da..d18ebba 100644 --- a/src-tauri/src/storage/mod.rs +++ b/src-tauri/src/storage/mod.rs @@ -1582,23 +1582,22 @@ impl Store for SqliteStore { &self, meeting_id: Option<&MeetingId>, ) -> Result, StoreError> { - let rows = match meeting_id { - Some(m) => { - sqlx::query_as::<_, FeatureBriefRow>( + let rows = + match meeting_id { + Some(m) => sqlx::query_as::<_, FeatureBriefRow>( "SELECT * FROM feature_briefs WHERE meeting_id = ? ORDER BY created_at DESC", ) .bind(m) .fetch_all(&self.pool) - .await? - } - None => { - sqlx::query_as::<_, FeatureBriefRow>( - "SELECT * FROM feature_briefs ORDER BY created_at DESC", - ) - .fetch_all(&self.pool) - .await? - } - }; + .await?, + None => { + sqlx::query_as::<_, FeatureBriefRow>( + "SELECT * FROM feature_briefs ORDER BY created_at DESC", + ) + .fetch_all(&self.pool) + .await? + } + }; Ok(rows.into_iter().map(FeatureBriefInfo::from).collect()) } -- 2.34.1 From 4fc3df504a8cbaafae648c62c001d791fc69ca28 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:30:03 -0500 Subject: [PATCH 033/103] refactor(briefs): bundle distill() transcript args to satisfy clippy (M1.3) --- src-tauri/src/briefs/mod.rs | 83 ++++++++++++++++++------------------- 1 file changed, 41 insertions(+), 42 deletions(-) diff --git a/src-tauri/src/briefs/mod.rs b/src-tauri/src/briefs/mod.rs index 3769257..09bac29 100644 --- a/src-tauri/src/briefs/mod.rs +++ b/src-tauri/src/briefs/mod.rs @@ -222,6 +222,17 @@ fn context_excerpts( .collect() } +/// Transcript-derived inputs `distill` needs — bundled into one struct so the +/// function stays under clippy's argument-count lint rather than taking each +/// field positionally. +struct MeetingContext<'a> { + title: &'a str, + participants: &'a [String], + transcript_md: &'a str, + segments: &'a [TranscriptSegment], + speakers: &'a [SpeakerInfo], +} + /// Core distillation: prompt -> LLM round trip -> parse -> ground. Takes /// transcript data directly rather than a `MeetingId`, so it needs no /// `Store` — `LlmFeatureBriefBuilder::build` below is the `Store`-aware @@ -229,18 +240,14 @@ fn context_excerpts( async fn distill( llm: &dyn LlmProvider, meeting_id: &MeetingId, - meeting_title: &str, - participants: &[String], target_repo: Option<&str>, - transcript_md: &str, - segments: &[TranscriptSegment], - speakers: &[SpeakerInfo], + ctx: &MeetingContext<'_>, ) -> Result { let (system, user) = - build_brief_messages(meeting_title, participants, target_repo, transcript_md); + build_brief_messages(ctx.title, ctx.participants, target_repo, ctx.transcript_md); let reply = llm.complete(&system, &user).await?; - let fields = parse_brief(&reply, meeting_title); - let context_excerpts = context_excerpts(&fields, segments, speakers); + let fields = parse_brief(&reply, ctx.title); + let context_excerpts = context_excerpts(&fields, ctx.segments, ctx.speakers); Ok(FeatureBrief { id: uuid::Uuid::new_v4().to_string(), meeting_id: meeting_id.clone(), @@ -273,17 +280,14 @@ impl FeatureBriefBuilder for LlmFeatureBriefBuilder { .iter() .map(|s| s.display_name.clone().unwrap_or_else(|| s.label.clone())) .collect(); - distill( - self.llm.as_ref(), - meeting_id, - &meeting.title, - &participants, - target_repo, - &meeting.notes_markdown, - &meeting.segments, - &meeting.speakers, - ) - .await + let ctx = MeetingContext { + title: &meeting.title, + participants: &participants, + transcript_md: &meeting.notes_markdown, + segments: &meeting.segments, + speakers: &meeting.speakers, + }; + distill(self.llm.as_ref(), meeting_id, target_repo, &ctx).await } } @@ -501,18 +505,16 @@ mod tests { .collect::>() .join("\n"); - let brief = distill( - &llm, - &"m1".to_string(), - "Reporting sync", - &participants, - Some("acme/reporting-web"), - &transcript_md, - &segments, - &speakers, - ) - .await - .expect("distill should succeed against the mock provider"); + let ctx = MeetingContext { + title: "Reporting sync", + participants: &participants, + transcript_md: &transcript_md, + segments: &segments, + speakers: &speakers, + }; + let brief = distill(&llm, &"m1".to_string(), Some("acme/reporting-web"), &ctx) + .await + .expect("distill should succeed against the mock provider"); assert_eq!(brief.meeting_id, "m1"); assert_eq!(brief.title, "Bulk CSV export for the reporting view"); @@ -559,17 +561,14 @@ mod tests { } } - let result = distill( - &FailingProvider, - &"m1".to_string(), - "Meeting", - &[], - None, - "transcript", - &[], - &[], - ) - .await; + let ctx = MeetingContext { + title: "Meeting", + participants: &[], + transcript_md: "transcript", + segments: &[], + speakers: &[], + }; + let result = distill(&FailingProvider, &"m1".to_string(), None, &ctx).await; assert!(matches!(result, Err(BriefError::Llm(_)))); } } -- 2.34.1 From 63bfc293cb6c1f43f9f3152f7bcad09ec3fbaa6e Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:30:04 -0500 Subject: [PATCH 034/103] feat(briefs): create/list/get_feature_brief + set_brief_exposed command bodies (T10.6, M1.4/M1.6) --- src-tauri/src/commands.rs | 230 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 219 insertions(+), 11 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 0b6cede..a792abb 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -3100,33 +3100,173 @@ pub async fn sync_status( Ok(rows.iter().map(job_to_info).collect()) } -// ---- Feature briefs + MCP server (Phase 10b, ADR-0011) ---- +// ---- Feature briefs + MCP server (Phase 10, ADR-0011) ---- +/// Distills a finished meeting into an agent-ready `FeatureBrief` (M1.4, +/// FR-MCP-4, T10.6) via the configured `LlmProvider` — no new egress. Writes +/// the sealed `briefs/.json` + the `feature_briefs` index row only after +/// a successful distill; an LLM failure (off/unreachable) leaves nothing on +/// disk or in the DB. #[tauri::command] pub async fn create_feature_brief( - _meeting_id: MeetingId, - _target_repo: Option, + state: State<'_, AppState>, + meeting_id: MeetingId, + target_repo: Option, ) -> WaResult { - // T10.6: distill transcript → structured brief via the configured LlmProvider. - Err(not_implemented("create_feature_brief")) + let guard = state.session.lock().await; + if guard.as_ref().is_some_and(|s| s.meeting_id == meeting_id) { + return Err(WaError::new( + "briefs", + "cannot create a feature brief while this meeting is still recording — wait until it's stopped", + )); + } + drop(guard); + + create_feature_brief_core( + &state.store, + &meeting_id, + target_repo.as_deref(), + &load_settings(), + ) + .await } +/// Core of `create_feature_brief`, factored out of the `State`-taking command +/// so it's callable directly from tests with an in-memory `Store` (T10.6 +/// tests, `06-test-strategy.md` P10) without needing a live Tauri app. +async fn create_feature_brief_core( + store: &std::sync::Arc, + meeting_id: &MeetingId, + target_repo: Option<&str>, + settings: &Settings, +) -> WaResult { + use crate::briefs::FeatureBriefBuilder; + + let provider = llm_provider_from_settings(settings).ok_or_else(|| { + WaError::new( + "llm", + "no LLM provider is configured — enable one in Settings first", + ) + })?; + + let builder = crate::briefs::LlmFeatureBriefBuilder { + store: store.clone(), + llm: provider, + }; + // Nothing is written until this succeeds — an LLM failure (off/ + // unreachable) returns here with disk/DB untouched. + let brief = builder + .build(meeting_id, target_repo) + .await + .map_err(|e| WaError::new("briefs", e.to_string()))?; + + let meeting_title = store + .get_meeting(meeting_id) + .await + .map(|m| m.title) + .map_err(|e| WaError::new("storage", e.to_string()))?; + + let generated_at = now_unix(); + let brief_file = crate::storage::BriefFile { + schema: 1, + id: brief.id.clone(), + meeting_id: meeting_id.clone(), + generated_at, + provider: settings.llm_provider.clone(), + model: settings.llm_model.clone(), + title: brief.title.clone(), + problem: brief.problem.clone(), + desired_outcome: brief.desired_outcome.clone(), + acceptance_criteria: brief.acceptance_criteria.clone(), + target_repo: brief.target_repo.clone(), + context_excerpts: brief.context_excerpts.clone(), + source: crate::storage::BriefSource { + meeting_title, + at: generated_at, + }, + }; + let json = serde_json::to_string_pretty(&brief_file) + .map_err(|e| WaError::new("briefs", e.to_string()))?; + let sealed = + crate::vault::seal(json.as_bytes()).map_err(|e| WaError::new("vault", e.to_string()))?; + + let briefs_dir = meeting_dir(meeting_id).join("briefs"); + std::fs::create_dir_all(&briefs_dir).map_err(|e| WaError::new("briefs", e.to_string()))?; + let rel_path = format!("briefs/{}.json", brief.id); + let abs_path = briefs_dir.join(format!("{}.json", brief.id)); + std::fs::write(&abs_path, sealed).map_err(|e| WaError::new("briefs", e.to_string()))?; + + let row = crate::storage::FeatureBriefRow { + id: brief.id.clone(), + meeting_id: meeting_id.clone(), + title: brief.title.clone(), + target_repo: brief.target_repo.clone(), + path: rel_path, + exposed: false, + created_at: generated_at, + }; + if let Err(e) = store.insert_feature_brief(row).await { + // The distill + file write already succeeded; don't leave an orphan + // file with no DB index behind if the row insert itself fails. + let _ = std::fs::remove_file(&abs_path); + return Err(WaError::new("storage", e.to_string())); + } + + Ok(brief) +} + +/// Lists briefs newest-first, optionally scoped to one meeting (M1.4). #[tauri::command] pub async fn list_feature_briefs( - _meeting_id: Option, + state: State<'_, AppState>, + meeting_id: Option, ) -> WaResult> { - Err(not_implemented("list_feature_briefs")) + state + .store + .list_feature_briefs(meeting_id.as_ref()) + .await + .map_err(|e| WaError::new("storage", e.to_string())) } +/// Resolves the index row to its sealed `briefs/.json` file and returns +/// the IPC subset (M1.4) — the file is the source of truth, the row is the +/// index (`docs/03-data-model.md`). #[tauri::command] -pub async fn get_feature_brief(_id: String) -> WaResult { - Err(not_implemented("get_feature_brief")) +pub async fn get_feature_brief(state: State<'_, AppState>, id: String) -> WaResult { + let row = state + .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); + let bytes = std::fs::read(&abs_path).map_err(|e| WaError::new("storage", e.to_string()))?; + let opened = crate::vault::open(&bytes).map_err(|e| WaError::new("vault", e.to_string()))?; + let brief_file: crate::storage::BriefFile = + serde_json::from_slice(&opened).map_err(|e| WaError::new("briefs", e.to_string()))?; + Ok(FeatureBrief { + id: brief_file.id, + meeting_id: brief_file.meeting_id, + title: brief_file.title, + problem: brief_file.problem, + desired_outcome: brief_file.desired_outcome, + acceptance_criteria: brief_file.acceptance_criteria, + target_repo: brief_file.target_repo, + context_excerpts: brief_file.context_excerpts, + }) } /// Scope control: include/exclude a brief from the MCP server (FR-MCP-3). #[tauri::command] -pub async fn set_brief_exposed(_id: String, _exposed: bool) -> WaResult<()> { - Err(not_implemented("set_brief_exposed")) +pub async fn set_brief_exposed( + state: State<'_, AppState>, + id: String, + exposed: bool, +) -> WaResult<()> { + state + .store + .set_brief_exposed(&id, exposed) + .await + .map_err(|e| WaError::new("storage", e.to_string())) } #[tauri::command] @@ -3517,4 +3657,72 @@ mod tests { let b = list_item("22222222-bbbb", "Untitled meeting"); assert_ne!(bulk_export_stem(&a), bulk_export_stem(&b)); } + + // ---- create_feature_brief_core (T10.6, M1.6 command-level test) ---- + // `06-test-strategy.md` P10: LLM off/unreachable -> Err, nothing written + // to disk/DB. Exercised directly (not through the `#[tauri::command]` + // wrapper) with an in-memory store so it needs no live Tauri app. + + #[tokio::test] + async fn create_feature_brief_core_errs_and_writes_nothing_when_llm_is_off() { + let store: std::sync::Arc = std::sync::Arc::new( + crate::storage::SqliteStore::connect_in_memory() + .await + .unwrap(), + ); + let mut settings = default_settings(); + settings.llm_provider = "off".to_string(); + + let result = + create_feature_brief_core(&store, &"nonexistent".to_string(), None, &settings).await; + assert!(result.is_err()); + assert!(store.list_feature_briefs(None).await.unwrap().is_empty()); + } + + #[tokio::test] + async fn create_feature_brief_core_errs_and_writes_nothing_when_llm_is_unreachable() { + let store: std::sync::Arc = std::sync::Arc::new( + crate::storage::SqliteStore::connect_in_memory() + .await + .unwrap(), + ); + let meeting_id = store + .create_meeting(NewMeeting { + title: "Test meeting".to_string(), + calendar_event_id: None, + template_id: None, + }) + .await + .unwrap(); + store + .finalize_meeting( + &meeting_id, + FinalizeMeeting { + segments: Vec::new(), + speakers: Vec::new(), + duration_secs: 60, + recorded: false, + language: None, + backend_used: None, + model_used: None, + }, + ) + .await + .unwrap(); + + let mut settings = default_settings(); + settings.llm_provider = "ollama".to_string(); + // Nothing listens here (loopback, not a real egress) — the LLM call + // fails fast with a connection error, same shape as "Ollama isn't + // running". + settings.llm_endpoint = "http://127.0.0.1:1".to_string(); + + let result = create_feature_brief_core(&store, &meeting_id, None, &settings).await; + assert!(result.is_err()); + assert!(store + .list_feature_briefs(Some(&meeting_id)) + .await + .unwrap() + .is_empty()); + } } -- 2.34.1 From cd6997265fcd57abf7e2ca278bdf8a43a8de1101 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:30:07 -0500 Subject: [PATCH 035/103] build(mcp): Cargo.lock for the new mcp-feature dependencies (T10.4) Regenerated by `cargo check --features mcp` after promoting hyper/ hyper-util/http/http-body-util/bytes/tower-service/tokio-util to direct deps and widening rmcp's feature set (see the earlier Cargo.toml commits). --- src-tauri/Cargo.lock | 87 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 81 insertions(+), 6 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ee6d6e8..aa092ff 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -71,7 +71,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ "base64ct", "blake2", - "cpufeatures", + "cpufeatures 0.2.17", "password-hash", ] @@ -475,7 +475,18 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", ] [[package]] @@ -485,7 +496,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", - "chacha20", + "chacha20 0.9.1", "cipher", "poly1305", "zeroize", @@ -626,6 +637,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.4.0" @@ -1492,6 +1512,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", ] [[package]] @@ -1795,6 +1816,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.10.1" @@ -1808,6 +1835,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec 1.15.2", @@ -3140,7 +3168,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -3439,6 +3467,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20 0.10.1", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -3477,6 +3516,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3699,18 +3744,28 @@ checksum = "cc4c9c94680f75470ee8083a0667988b5d7b5beb70b9f998a8e51de7c682ce60" dependencies = [ "async-trait", "base64 0.22.1", + "bytes", "chrono", "futures", + "http", + "http-body", + "http-body-util", "pastey", "pin-project-lite", + "rand 0.10.2", + "reqwest 0.13.4", "rmcp-macros", "schemars 1.2.1", "serde", "serde_json", + "sse-stream", "thiserror 2.0.18", "tokio", + "tokio-stream", "tokio-util", + "tower-service", "tracing", + "uuid", ] [[package]] @@ -4158,7 +4213,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -4169,7 +4224,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -4545,6 +4600,19 @@ dependencies = [ "url", ] +[[package]] +name = "sse-stream" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3962b63f038885f15bce2c6e02c0e7925c072f1ac86bb60fd44c5c6b762fb72" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -5979,12 +6047,17 @@ version = "0.2.0" dependencies = [ "argon2", "async-trait", + "bytes", "chacha20poly1305", "chrono", "docx-rs", "futures-util", "getrandom 0.2.17", "hound", + "http", + "http-body-util", + "hyper", + "hyper-util", "keyring", "ort", "printpdf", @@ -6003,6 +6076,8 @@ dependencies = [ "tauri-plugin-dialog", "thiserror 1.0.69", "tokio", + "tokio-util", + "tower-service", "tracing", "tracing-subscriber", "uuid", -- 2.34.1 From ec8b12f63643b3567072911385e70d83db19ba57 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:31:32 -0500 Subject: [PATCH 036/103] fix(mcp): collapse nested if-let per clippy (T10.4) --- src-tauri/src/mcp/server.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/mcp/server.rs b/src-tauri/src/mcp/server.rs index a3c57c9..f9be83e 100644 --- a/src-tauri/src/mcp/server.rs +++ b/src-tauri/src/mcp/server.rs @@ -39,10 +39,8 @@ impl RmcpServer { } async fn stop_running(&self) { - if let Some(running) = self.running.lock().await.take() { - if let Running::Http(handle) = running { - handle.stop().await; - } + if let Some(Running::Http(handle)) = self.running.lock().await.take() { + handle.stop().await; } } -- 2.34.1 From ae0ef563e9aa2f2cf39ebee4fd595e28e8d436f9 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:32:22 -0500 Subject: [PATCH 037/103] feat(mcp): frontend AppSettings/McpStatus types for the new fields (T10.4) Adds mcp_transport/mcp_port/mcp_expose/mcp_expose_recordings to AppSettings and a typed McpStatus for mcp_status()'s response; types onMcpAccess's payload correctly (the live event is camelCase per docs/04-api-contracts.md, unlike the snake_case McpAccessEntry rows mcpAccessLog() returns). --- src/lib/api.ts | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/lib/api.ts b/src/lib/api.ts index bd734b6..428d998 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -289,6 +289,10 @@ export interface AppSettings { consent_acknowledged: boolean; sync_enabled: boolean; mcp_enabled: boolean; + mcp_transport: string; // http|stdio + mcp_port: number; + mcp_expose: string; // none|selected|all + mcp_expose_recordings: boolean; retention_max_age_days: number | null; retention_max_size_gb: number | null; pst_last_path: string | null; @@ -325,6 +329,15 @@ export interface McpAccessEntry { client: string | null; } +// mcp_status() response (FR-MCP-1/6). `endpoint` is empty while disabled. +export interface McpStatus { + enabled: boolean; + transport: "http" | "stdio"; + endpoint: string; + tokenSet: boolean; + exposeScope: "none" | "selected" | "all"; +} + // ---- Commands ---- export const api = { // `record` controls audio RETENTION (default false / off — ADR-0009). @@ -455,7 +468,7 @@ export const api = { getFeatureBrief: (id: string) => invoke("get_feature_brief", { id }), setBriefExposed: (id: string, exposed: boolean) => invoke("set_brief_exposed", { id, exposed }), - mcpStatus: () => invoke("mcp_status"), + mcpStatus: () => invoke("mcp_status"), setMcpEnabled: (enabled: boolean, transport?: "http" | "stdio", port?: number) => invoke<{ endpoint: string; token: string }>("set_mcp_enabled", { enabled, transport, port }), setMcpScope: (expose: "none" | "selected" | "all", exposeRecordings?: boolean) => @@ -538,8 +551,11 @@ export const events = { onSyncLinked: ( cb: (p: { ok: boolean; kind: string; error?: string }) => void, ): Promise => listen("sync://linked", (e) => cb(e.payload as never)), - onMcpAccess: (cb: (p: McpAccessEntry & { client?: string }) => void): Promise => - listen("mcp://access", (e) => cb(e.payload as never)), + // Live tail of the FR-MCP-5 audit log (camelCase on the wire, unlike the + // snake_case McpAccessEntry rows `mcpAccessLog()` returns). + onMcpAccess: ( + cb: (p: { at: number; tool: string; meetingId?: MeetingId; client?: string }) => void, + ): Promise => listen("mcp://access", (e) => cb(e.payload as never)), onAgentProgress: ( cb: (p: { briefId: string; tool: string; line: string }) => void, ): Promise => listen("agent://progress", (e) => cb(e.payload as never)), -- 2.34.1 From 70b49523669888c8c70fea39f1678ab0257776c3 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:33:16 -0500 Subject: [PATCH 038/103] feat(mcp): settings store gains MCP state/actions (T10.4/10.5) loadMcpStatus/loadMcpAccessLog/setMcpEnabled/setMcpScope mirror the existing sync-target patterns; a live "mcp://access" subscription tails the FR-MCP-5 audit log into the store while the panel is open, on top of the on-demand loadMcpAccessLog() refresh. mcpLastToken holds the freshly-minted token from the most recent enable, for the one-time reveal UI (next commit). --- src/lib/stores/settings.svelte.ts | 68 +++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/src/lib/stores/settings.svelte.ts b/src/lib/stores/settings.svelte.ts index bd3b0e9..57aa534 100644 --- a/src/lib/stores/settings.svelte.ts +++ b/src/lib/stores/settings.svelte.ts @@ -15,6 +15,8 @@ import { type LlmStatus, type ModelInfo, type PrivacySelfCheck, + type McpStatus, + type McpAccessEntry, } from "../api"; const DEFAULT_SETTINGS: AppSettings = { @@ -30,6 +32,10 @@ const DEFAULT_SETTINGS: AppSettings = { consent_acknowledged: false, sync_enabled: false, // sync OFF by default (ADR-0010) mcp_enabled: false, // MCP server OFF by default (ADR-0011) + mcp_transport: "http", + mcp_port: 4849, + mcp_expose: "none", // scope OFF by default (FR-MCP-3) + mcp_expose_recordings: false, retention_max_age_days: null, // no cap by default (FR-STORE-2) retention_max_size_gb: null, pst_last_path: null, @@ -58,6 +64,15 @@ class SettingsStore { // Privacy self-check (T7.6, FR-SEC-2). privacy = $state(null); + // MCP server (Phase 10b, ADR-0011). + mcpStatus = $state(null); + mcpAccessLog = $state([]); + mcpSaving = $state(false); + /** The freshly-minted token from the last `setMcpEnabled(true)` call — + * shown exactly once (it is never re-readable afterwards, same as any + * other newly-issued secret). Cleared on disable or when the panel closes. */ + mcpLastToken = $state(null); + // LLM provider status (T5.2, FR-LLM-1). llmStatus = $state(null); llmSaving = $state(false); @@ -82,6 +97,16 @@ class SettingsStore { await this.loadModels(); await this.loadPrivacy(); await this.loadLlmStatus(); + await this.loadMcpStatus(); + await this.loadMcpAccessLog(); + // Live tail of the FR-MCP-5 audit log — every tool read an agent makes + // while the panel is open shows up immediately, not just on refresh. + await events.onMcpAccess(({ at, tool, meetingId, client }) => { + this.mcpAccessLog = [ + { at, tool, meeting_id: meetingId ?? null, client: client ?? null }, + ...this.mcpAccessLog, + ].slice(0, 50); + }); await events.onHardwareChanged(({ active }) => { if (this.hardware) this.hardware.active = active; }); @@ -175,6 +200,49 @@ class SettingsStore { } } + async loadMcpStatus() { + try { + this.mcpStatus = await api.mcpStatus(); + } catch { + this.mcpStatus = null; + } + } + + async loadMcpAccessLog(limit = 50) { + try { + this.mcpAccessLog = await api.mcpAccessLog(limit); + } catch { + this.mcpAccessLog = []; + } + } + + /** Enable/disable the loopback MCP server (FR-MCP-1/6). On enable, the + * returned token is stashed in `mcpLastToken` for the one-time reveal. */ + async setMcpEnabled(enabled: boolean, transport?: "http" | "stdio", port?: number) { + this.mcpSaving = true; + try { + const res = await api.setMcpEnabled(enabled, transport, port); + this.mcpLastToken = enabled ? res.token : null; + } catch { + this.backendStub = true; + } finally { + this.mcpSaving = false; + } + await this.loadMcpStatus(); + await this.loadPrivacy(); + } + + /** Scope control (FR-MCP-3) — takes effect immediately, no restart needed. */ + async setMcpScope(expose: "none" | "selected" | "all", exposeRecordings?: boolean) { + try { + await api.setMcpScope(expose, exposeRecordings); + } catch { + this.backendStub = true; + } + await this.loadMcpStatus(); + await this.loadPrivacy(); + } + async loadLlmStatus() { try { this.llmStatus = await api.llmStatus(); -- 2.34.1 From 02fb7de6a8227cccfb255e54a1455b221613531a Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:36:20 -0500 Subject: [PATCH 039/103] feat(briefs): Feature briefs UI in SummaryPanel (T10.6, M1.5) --- src/lib/views/SummaryPanel.svelte | 296 ++++++++++++++++++++++++++++++ 1 file changed, 296 insertions(+) diff --git a/src/lib/views/SummaryPanel.svelte b/src/lib/views/SummaryPanel.svelte index 9d71653..a62d63f 100644 --- a/src/lib/views/SummaryPanel.svelte +++ b/src/lib/views/SummaryPanel.svelte @@ -9,6 +9,8 @@ errorMessage, type ActionItem, type CalendarEventDetail, + type FeatureBrief, + type FeatureBriefInfo, type LlmStatus, } from "../api"; import { renderMarkdown } from "../markdown"; @@ -22,6 +24,9 @@ Mic2, Bell, UploadCloud, + FileText, + Copy, + Check, } from "@lucide/svelte"; onMount(() => calendar.load()); @@ -250,6 +255,120 @@ generatingTags = false; } } + + // ---- Feature briefs (Phase 10 M1, ADR-0011, FR-MCP-4) ---- + // Agent-ready specs distilled from the meeting via the configured LLM + // provider. `exposed` (MCP scope control, FR-MCP-3) lives only on the list + // row (FeatureBriefInfo) — the full brief the viewer shows doesn't carry it. + let briefs = $state([]); + let selectedBriefId = $state(null); + let selectedBrief = $state(null); + let briefTargetRepo = $state(""); + let creatingBrief = $state(false); + let briefError = $state(null); + let loadingBriefsForId: string | null = null; + + $effect(() => { + const m = meetings.selected; + if (!m) { + briefs = []; + selectedBriefId = null; + selectedBrief = null; + loadingBriefsForId = null; + return; + } + if (m.id === loadingBriefsForId) return; + loadingBriefsForId = m.id; + selectedBriefId = null; + selectedBrief = null; + api + .listFeatureBriefs(m.id) + .then((list) => (briefs = list)) + .catch(() => (briefs = [])); + }); + + async function createBrief() { + const m = meetings.selected; + if (!m) return; + creatingBrief = true; + briefError = null; + try { + const brief = await api.createFeatureBrief(m.id, briefTargetRepo.trim() || undefined); + briefs = [ + { + id: brief.id, + meeting_id: brief.meeting_id, + title: brief.title, + target_repo: brief.target_repo, + exposed: false, + }, + ...briefs, + ]; + selectedBriefId = brief.id; + selectedBrief = brief; + briefTargetRepo = ""; + } catch (e) { + briefError = errorMessage(e); + } finally { + creatingBrief = false; + } + } + + async function openBrief(id: string) { + selectedBriefId = id; + briefError = null; + try { + selectedBrief = await api.getFeatureBrief(id); + } catch (e) { + selectedBrief = null; + briefError = errorMessage(e); + } + } + + async function toggleBriefExposed(brief: FeatureBriefInfo) { + const next = !brief.exposed; + try { + await api.setBriefExposed(brief.id, next); + briefs = briefs.map((b) => (b.id === brief.id ? { ...b, exposed: next } : b)); + } catch (e) { + briefError = errorMessage(e); + } + } + + function briefAsMarkdown(b: FeatureBrief): string { + const criteria = b.acceptance_criteria.length + ? b.acceptance_criteria.map((c) => `- ${c}`).join("\n") + : "- None"; + let md = + `## Title\n${b.title}\n\n` + + `## Problem\n${b.problem}\n\n` + + `## Desired Outcome\n${b.desired_outcome}\n\n` + + `## Acceptance Criteria\n${criteria}`; + if (b.context_excerpts.length) { + md += `\n\n## Context\n${b.context_excerpts.map((e) => `> **${e.speaker}:** ${e.text}`).join("\n\n")}`; + } + return md; + } + + // Brief copy feedback (T10.6, M1.5): a transient checkmark rather than a + // toast — consistent with this panel having no toast system elsewhere. + let copiedFormat = $state<"md" | "json" | null>(null); + let copiedTimer: ReturnType | undefined; + function flashCopied(format: "md" | "json") { + copiedFormat = format; + clearTimeout(copiedTimer); + copiedTimer = setTimeout(() => (copiedFormat = null), 1500); + } + async function copyBriefMarkdown() { + if (!selectedBrief) return; + await navigator.clipboard.writeText(briefAsMarkdown(selectedBrief)); + flashCopied("md"); + } + async function copyBriefJson() { + if (!selectedBrief) return; + await navigator.clipboard.writeText(JSON.stringify(selectedBrief, null, 2)); + flashCopied("json"); + }
@@ -391,6 +510,91 @@

{meetings.summaryError}

{/if} +

+ {#if !meetings.selected} +

Select a meeting to create or view feature briefs.

+ {:else} +

+ Distills this meeting into an agent-ready spec (requires a local LLM provider) — hand it to a + coding agent, or serve it over the MCP server once that's on. +

+
+ + +
+ {#if briefError} +

{briefError}

+ {/if} + + {#if briefs.length > 0} +
    + {#each briefs as b (b.id)} +
  • + + +
  • + {/each} +
+ {/if} + + {#if selectedBrief} +
+
+ + +
+

Problem

+

{selectedBrief.problem}

+

Desired outcome

+

{selectedBrief.desired_outcome}

+

Acceptance criteria

+ {#if selectedBrief.acceptance_criteria.length} +
    + {#each selectedBrief.acceptance_criteria as c, i (i)} +
  • {c}
  • + {/each} +
+ {:else} +

None captured.

+ {/if} + {#if selectedBrief.context_excerpts.length} +

Context

+
    + {#each selectedBrief.context_excerpts as e, i (i)} +
  • {e.speaker}: "{e.text}"
  • + {/each} +
+ {/if} +
+ {/if} + {/if} +

{#if !meetings.selected}

Select a meeting to see its action items.

@@ -761,4 +965,96 @@ .err { cursor: help; } + + /* ---- Feature briefs (Phase 10 M1, ADR-0011) ---- */ + .brief-create { + display: flex; + gap: 0.5rem; + align-items: center; + margin: 0.3rem 0; + } + .brief-create input { + flex: 1; + min-width: 0; + } + ul.briefs { + list-style: none; + padding: 0; + margin: 0.4rem 0; + display: flex; + flex-direction: column; + gap: 0.2rem; + } + ul.briefs li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + border-radius: var(--radius-sm); + border: 1px solid transparent; + } + ul.briefs li.active { + border-color: var(--accent); + background: var(--bg-hover); + } + .brief-title { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.1rem; + background: none; + border: none; + cursor: pointer; + text-align: left; + padding: 0.35rem 0.4rem; + font-size: 0.85rem; + color: var(--fg); + } + .expose { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0 0.4rem; + cursor: pointer; + } + .brief-viewer { + margin-top: 0.5rem; + padding: 0.6rem 0.7rem; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + } + .brief-actions { + display: flex; + gap: 0.9rem; + margin-bottom: 0.4rem; + } + .brief-actions .link { + display: inline-flex; + align-items: center; + gap: 0.3rem; + } + .brief-text { + font-size: 0.85rem; + line-height: 1.5; + margin: 0.2rem 0 0.4rem; + white-space: pre-wrap; + } + ul.excerpts { + list-style: none; + padding: 0; + margin: 0.2rem 0; + font-size: 0.82rem; + color: var(--muted); + } + ul.excerpts li { + padding: 0.2rem 0; + font-style: italic; + } + ul.excerpts .speaker { + font-style: normal; + font-weight: 600; + color: var(--fg); + } -- 2.34.1 From 1ade08a60eb8817b696880fd8f35e770e47249a6 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:36:54 -0500 Subject: [PATCH 040/103] feat(mcp): MCP server settings section + privacy panel integration (T10.4/10.5, M2.5/M2.6) New "MCP server" tab: persistent disclosure banner (agents may forward served data to their own provider; WA itself adds no egress), enable toggle, transport/port/scope controls, a reveal-once auth-token callout (aria-live, never re-shown), and a live access-log list backed by the settings store's mcp:// access subscription (FR-MCP-5). Privacy tab gains a compact MCP status row + an explicit "adds nothing to the egress list" confirmation line (FR-MCP-7, FR-SEC-2) next to the existing sync/LLM egress rows. Built with ui-ux-pro-max guidance (disclosure banner wording, reveal- once secret pattern, aria-live for the token, badge semantics) mapped onto this file's existing minimal/utilitarian style (banner/badge/row/ confirm classes already used by the Sync and Privacy sections) rather than introducing a new visual language. --- src/lib/views/Settings.svelte | 208 +++++++++++++++++++++++++++++++++- 1 file changed, 207 insertions(+), 1 deletion(-) diff --git a/src/lib/views/Settings.svelte b/src/lib/views/Settings.svelte index a559ea3..2757d86 100644 --- a/src/lib/views/Settings.svelte +++ b/src/lib/views/Settings.svelte @@ -25,6 +25,9 @@ ChevronRight, RotateCcw, Info, + Bot, + Copy, + KeyRound, } from "@lucide/svelte"; import { OLLAMA_OPTIONS, @@ -37,7 +40,7 @@ let { onClose }: { onClose: () => void } = $props(); let section = $state< - "recording" | "hardware" | "storage" | "calendar" | "sync" | "ai" | "privacy" | "about" + "recording" | "hardware" | "storage" | "calendar" | "sync" | "ai" | "mcp" | "privacy" | "about" >("recording"); // ---- About (version + build commit + source) ---- @@ -83,6 +86,47 @@ } } + // ---- 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 +533,9 @@ + @@ -1191,6 +1238,141 @@
{/if} + {:else if section === "mcp"} +
+

MCP server

+ + + +

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

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

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

+ {/if} + +

Scope

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

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

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

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

+ {/if} + +

Access log

+

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

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

No agent has read anything yet.

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

Privacy

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

+

+ {/if}

Egress allowlist

{#if settings.privacy.allowlistedHosts.length === 0} @@ -1660,6 +1854,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 { -- 2.34.1 From e424ecd8d388aad23badb78d8b7464ff891c3794 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 00:42:01 -0500 Subject: [PATCH 041/103] style(mcp): prettier --- src/lib/views/Settings.svelte | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/src/lib/views/Settings.svelte b/src/lib/views/Settings.svelte index 2757d86..c4de083 100644 --- a/src/lib/views/Settings.svelte +++ b/src/lib/views/Settings.svelte @@ -87,7 +87,9 @@ } // ---- MCP server (Phase 10b, ADR-0011) ---- - let mcpTransport = $state<"http" | "stdio">(settings.settings.mcp_transport === "stdio" ? "stdio" : "http"); + 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", @@ -1245,10 +1247,10 @@
{:else if section === "storage"}
@@ -1775,6 +1811,12 @@ border-radius: 5px; padding: 0.35rem; } + input:disabled, + select:disabled, + textarea:disabled { + opacity: 0.5; + cursor: not-allowed; + } .artifacts { border: 1px solid var(--border); border-radius: 6px; -- 2.34.1 From 7bff39271c29a551bd528a5d38f16f05e5de129d Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 13:12:46 -0500 Subject: [PATCH 078/103] feat(ui): thread optional language override through meetings.reprocess (T8.7, M4.2) --- src/lib/stores/meetings.svelte.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/lib/stores/meetings.svelte.ts b/src/lib/stores/meetings.svelte.ts index e2ea25d..06c099b 100644 --- a/src/lib/stores/meetings.svelte.ts +++ b/src/lib/stores/meetings.svelte.ts @@ -218,9 +218,11 @@ class MeetingsStore { if (this.selectedId === id) await this.select(id); } - /** Batch re-transcribe with a different (typically larger) model (T3.8). */ - async reprocess(id: MeetingId, model: string) { - await api.reprocessTranscript(id, model); + /** Batch re-transcribe with a different (typically larger) model (T3.8). + * `language` (T8.7, FR-TRX-4): omitted reuses the meeting's current + * language rather than resetting it to auto. */ + async reprocess(id: MeetingId, model: string, language?: string) { + await api.reprocessTranscript(id, model, language); await this.load(); if (this.selectedId === id) await this.select(id); } -- 2.34.1 From 353d582e96613be6bd028ba666bff953d283dbff Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 13:12:51 -0500 Subject: [PATCH 079/103] feat(ui): show meeting language badge + reprocess language picker (T8.7, M4.2) --- src/lib/views/TranscriptNotes.svelte | 43 ++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/src/lib/views/TranscriptNotes.svelte b/src/lib/views/TranscriptNotes.svelte index 68fdc09..f44744f 100644 --- a/src/lib/views/TranscriptNotes.svelte +++ b/src/lib/views/TranscriptNotes.svelte @@ -29,6 +29,14 @@ return speakers.find((s) => s.label === label)?.display_name ?? label; } + // T8.7/FR-TRX-4: the meeting view shows the language actually used, not + // just the raw ISO code — falls back to the code itself if it's not in + // the (curated) catalog, and to "auto-detecting…" before any is known. + function languageLabel(code: string | null): string { + if (!code) return "auto-detecting…"; + return settings.languages.find((l) => l.code === code)?.label ?? code; + } + let notesText = $state(""); // Notes is a single pane: raw markdown ("Editor") or the rendered result // ("Preview"), toggled by one button whose label flips to the other mode. @@ -130,13 +138,16 @@ } let reprocessModel = $state(""); + // T8.7/FR-TRX-4: "" reuses the meeting's current language (backend default + // when `language` is omitted) rather than resetting it to auto. + let reprocessLanguage = $state(""); let reprocessing = $state(false); async function reprocess() { const m = meetings.selected; if (!m || !reprocessModel) return; reprocessing = true; try { - await meetings.reprocess(m.id, reprocessModel); + await meetings.reprocess(m.id, reprocessModel, reprocessLanguage || undefined); } finally { reprocessing = false; } @@ -154,8 +165,13 @@ />
-

+

+

{#if m.recorded && settings.models.some((mo) => mo.installed)} + {@const reprocessModelInfo = settings.models.find((mo) => mo.id === reprocessModel)}
+ {#if reprocessModelInfo?.multilingual} + + {/if}