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