Feature chore bug 005 #19

Merged
iamdoubz merged 106 commits from feature_chore_bug_005 into main 2026-07-07 22:02:41 -05:00
Showing only changes of commit 14868d4e5c - Show all commits
+333
View File
@@ -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<Store>` and an `AppHandle`).
#[derive(Clone)]
pub struct WaMcpHandler {
store: Arc<dyn Store>,
app: AppHandle,
}
impl WaMcpHandler {
pub fn new(store: Arc<dyn Store>, 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<RoleServer>) -> Option<String> {
context
.peer
.peer_info()
.map(|info| info.client_info.name.clone())
}
async fn tool_list_recent_meetings(
&self,
args: &Option<JsonObject>,
client: Option<&str>,
) -> Result<CallToolResult, McpProtoError> {
self.log_access("list_recent_meetings", None, client).await;
let (scope_val, expose_recordings) = self.current_scope();
if !scope::meetings_visible(scope_val) {
return Ok(CallToolResult::structured(json!({ "meetings": [] })));
}
let limit = arg_u64(args, "limit").unwrap_or(20).clamp(1, 100) as usize;
let items = self
.store
.list_meetings(MeetingFilter::default())
.await
.map_err(store_err)?;
let mut out = Vec::with_capacity(limit);
for item in items {
if out.len() >= limit {
break;
}
let Ok(full) = self.store.get_meeting(&item.id).await else {
continue;
};
if !scope::recording_gate_ok(expose_recordings, full.recorded) {
continue;
}
out.push(json!({
"id": item.id,
"title": item.title,
"startedAt": item.started_at,
"durationSecs": item.duration_secs,
"status": item.status.as_str(),
"tags": item.tags,
}));
}
Ok(CallToolResult::structured(json!({ "meetings": out })))
}
async fn tool_get_transcript(
&self,
args: &Option<JsonObject>,
client: Option<&str>,
) -> Result<CallToolResult, McpProtoError> {
let meeting_id = arg_str(args, "meetingId")
.ok_or_else(|| McpProtoError::invalid_params("meetingId is required", None))?;
self.log_access("get_transcript", Some(&meeting_id), client)
.await;
let (scope_val, expose_recordings) = self.current_scope();
if !scope::meetings_visible(scope_val) {
return Ok(denied("get_transcript scope is not `all`"));
}
let meeting = self
.store
.get_meeting(&meeting_id)
.await
.map_err(store_err)?;
if !scope::recording_gate_ok(expose_recordings, meeting.recorded) {
return Ok(denied(
"this meeting retained its recording; expose_recordings is off",
));
}
Ok(CallToolResult::structured(json!({
"meetingId": meeting.id,
"title": meeting.title,
"segments": meeting.segments,
})))
}
async fn tool_get_action_items(
&self,
args: &Option<JsonObject>,
client: Option<&str>,
) -> Result<CallToolResult, McpProtoError> {
let meeting_id = arg_str(args, "meetingId")
.ok_or_else(|| McpProtoError::invalid_params("meetingId is required", None))?;
self.log_access("get_action_items", Some(&meeting_id), client)
.await;
let (scope_val, expose_recordings) = self.current_scope();
if !scope::meetings_visible(scope_val) {
return Ok(denied("get_action_items scope is not `all`"));
}
let meeting = self
.store
.get_meeting(&meeting_id)
.await
.map_err(store_err)?;
if !scope::recording_gate_ok(expose_recordings, meeting.recorded) {
return Ok(denied(
"this meeting retained its recording; expose_recordings is off",
));
}
let items = self
.store
.list_action_items(&meeting_id)
.await
.map_err(store_err)?;
Ok(CallToolResult::structured(json!({
"meetingId": meeting_id,
"items": items,
})))
}
async fn tool_get_feature_brief(
&self,
args: &Option<JsonObject>,
client: Option<&str>,
) -> Result<CallToolResult, McpProtoError> {
let id = arg_str(args, "id")
.ok_or_else(|| McpProtoError::invalid_params("id is required", None))?;
self.log_access("get_feature_brief", None, client).await;
let (scope_val, _expose_recordings) = self.current_scope();
if matches!(scope_val, ExposeScope::None) {
return Ok(denied("MCP scope is `none`; no briefs are exposed"));
}
// 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<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListToolsResult, McpProtoError> {
let tools = vec![
Tool::new(
"list_recent_meetings",
"Recent meetings, most recent first (scoped by the user's MCP settings).",
obj_schema(json!({
"type": "object",
"properties": { "limit": { "type": "integer", "minimum": 1, "maximum": 100 } },
"additionalProperties": false,
})),
),
Tool::new(
"get_transcript",
"Full transcript (speaker-labeled segments) for one meeting.",
obj_schema(json!({
"type": "object",
"properties": { "meetingId": { "type": "string" } },
"required": ["meetingId"],
"additionalProperties": false,
})),
),
Tool::new(
"get_action_items",
"Confirmed action items for one meeting.",
obj_schema(json!({
"type": "object",
"properties": { "meetingId": { "type": "string" } },
"required": ["meetingId"],
"additionalProperties": false,
})),
),
Tool::new(
"get_feature_brief",
"Agent-ready spec (problem/outcome/acceptance criteria) distilled from a meeting.",
obj_schema(json!({
"type": "object",
"properties": { "id": { "type": "string" } },
"required": ["id"],
"additionalProperties": false,
})),
),
];
Ok(ListToolsResult::with_all_items(tools))
}
async fn call_tool(
&self,
request: CallToolRequestParams,
context: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpProtoError> {
let client = Self::client_name(&context);
match request.name.as_ref() {
"list_recent_meetings" => {
self.tool_list_recent_meetings(&request.arguments, client.as_deref())
.await
}
"get_transcript" => {
self.tool_get_transcript(&request.arguments, client.as_deref())
.await
}
"get_action_items" => {
self.tool_get_action_items(&request.arguments, client.as_deref())
.await
}
"get_feature_brief" => {
self.tool_get_feature_brief(&request.arguments, client.as_deref())
.await
}
other => Err(McpProtoError::invalid_params(
format!("unknown tool: {other}"),
None,
)),
}
}
}
fn obj_schema(value: Value) -> Arc<JsonObject> {
Arc::new(value.as_object().cloned().unwrap_or_default())
}
fn arg_str(args: &Option<JsonObject>, key: &str) -> Option<String> {
args.as_ref()?.get(key)?.as_str().map(str::to_string)
}
fn arg_u64(args: &Option<JsonObject>, key: &str) -> Option<u64> {
args.as_ref()?.get(key)?.as_u64()
}
fn store_err(e: crate::storage::StoreError) -> McpProtoError {
McpProtoError::internal_error(e.to_string(), None)
}
fn denied(reason: &str) -> CallToolResult {
CallToolResult::structured_error(json!({ "error": "scope_denied", "message": reason }))
}
fn now_ms() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or_default()
}