Feature chore bug 005 #19
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user