Skip to content

Commit 8c740a5

Browse files
committed
feat(gateway,desktop): mcpmux_* self-management meta tools with native approval
Exposes a small built-in toolset (`mcpmux_*`) alongside every backend tool so LLMs can introspect and, with explicit user approval, reshape their own session's FeatureSet. Enabled by default. Read tools (no approval — always advertised): * mcpmux_list_all_tools — unfiltered view across connected servers * mcpmux_list_feature_sets — space's FSes w/ is_active + is_pinned * mcpmux_describe_resolution — current FS + why (pin | binding | active) * mcpmux_describe_workspace — reported MCP roots + matching binding Write tools (each gated by native desktop approval + diff preview): * mcpmux_pin_this_session — caller-scope, sets pinned_feature_set_id * mcpmux_create_feature_set — compose a custom FS from qualified names * mcpmux_bind_current_workspace — persistent WorkspaceBinding (space-wide) * mcpmux_set_space_active — flips space fallback (affects everyone) Gateway plumbing (`crates/mcpmux-gateway/src/services/meta_tools/`): * MetaTool trait + MetaToolRegistry. Handler intercepts `mcpmux_*` before routing, so meta tools are always visible regardless of the caller's resolved FS. * ApprovalBroker: session-scoped rate limit (10/min/client), oneshot request/response with 60s default timeout, session-only "always allow" cache keyed by (client_id, tool_name) — deliberately NOT persisted so gateway restarts re-prompt. * ToolDiff: before/after qualified-name comparison (via FeatureService) so approval dialogs show "68 tools removed, 0 added" instead of abstract FeatureSet names. * Write path emits `FeatureSetMembersChanged` → MCPNotifier pushes `tools/list_changed` → caller re-fetches the trimmed toolset in the next turn. Desktop (Tauri + React): * `commands/meta_tool_approval.rs` Tauri commands: - respond_to_meta_tool_approval(request_id, decision) - list_meta_tool_grants / revoke_meta_tool_grant * `start_gateway` attaches a publisher that emits `meta-tool-approval-request` events to the frontend. * `<MetaToolApprovalDialog>` — global React component (mounted once from `App.tsx`). Renders the summary + affect-other-clients warning + tool-list diff (+added / −removed, color-coded), with [Allow once] / [Always for this session] / [Deny] buttons. Queues concurrent requests. * GatewayAppState gains `approval_broker: Option<Arc<ApprovalBroker>>`, populated on gateway start. Tests (20 new passing): * `services::meta_tools::approval::tests` — 6 unit tests covering always-allow short-circuit, publisher allow/deny/timeout, headless no-desktop, and always-scope persistence across calls. * `tests/integration/meta_tools.rs` — 14 end-to-end tests with real SQLite repos + auto-approving publisher: - list_all_tools / list_feature_sets / describe_resolution / describe_workspace return correct payloads - write w/o publisher → approval_required - pin_this_session allow → pin persists; deny → unchanged - always-allow decision bypasses subsequent publisher calls - create_feature_set persists members only after approval - bind_current_workspace fails without roots; normalizes on success - set_space_active updates Space fallback - invalid UUID arg rejected - registry advertises all 8 tools with destructive_hint annotations Other: * `gateway_notifications::test_client_can_list_tools_after_notification` updated to filter `mcpmux_*` from its "empty toolset" assertion — meta tools are always present. * Total test count: 9 (mcpmux), 123 (core), 104 (gateway lib incl. the 6 approval tests), 66 (integration incl. 14 meta-tool tests), plus all existing suites green. Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent 5416d86 commit 8c740a5

19 files changed

Lines changed: 2594 additions & 15 deletions

File tree

apps/desktop/src-tauri/src/commands/gateway.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ pub struct GatewayAppState {
5656
pub event_emitter: Option<Arc<mcpmux_gateway::EventEmitter>>,
5757
/// Grant service for centralized grant management with auto-notifications
5858
pub grant_service: Option<Arc<mcpmux_gateway::GrantService>>,
59+
/// Approval broker for meta-tool writes (publisher attached on gateway start)
60+
pub approval_broker: Option<Arc<mcpmux_gateway::services::ApprovalBroker>>,
5961
}
6062

6163
/// Start domain event bridge from Gateway to Tauri
@@ -596,6 +598,34 @@ pub async fn start_gateway(
596598
let grant_service = server.grant_service();
597599
info!("[Gateway] Got grant_service: {:p}", &*grant_service);
598600

601+
// Meta-tool approval broker — attach a Tauri-event publisher so
602+
// incoming approval requests reach the React dialog.
603+
let approval_broker = server.approval_broker();
604+
{
605+
let app_handle_for_broker = app_handle.clone();
606+
let publisher: mcpmux_gateway::services::meta_tools::ApprovalPublisher =
607+
std::sync::Arc::new(move |req| {
608+
let app_handle = app_handle_for_broker.clone();
609+
Box::pin(async move {
610+
// Emit the request; the React layer owns rendering +
611+
// collecting the user's decision. Failure to emit means
612+
// no desktop frontend is listening — broker maps that to
613+
// "approval_required" to the calling tool.
614+
match app_handle.emit("meta-tool-approval-request", &req) {
615+
Ok(()) => true,
616+
Err(e) => {
617+
tracing::warn!(
618+
error = %e,
619+
"[meta-tool] failed to emit approval request"
620+
);
621+
false
622+
}
623+
}
624+
})
625+
});
626+
approval_broker.set_publisher(publisher).await;
627+
}
628+
599629
// Start domain event bridge (clean architecture)
600630
start_domain_event_bridge(&app_handle, gw_state.clone());
601631

@@ -615,6 +645,7 @@ pub async fn start_gateway(
615645
&*grant_service
616646
);
617647
state.grant_service = Some(grant_service);
648+
state.approval_broker = Some(approval_broker);
618649
info!(
619650
"[Gateway] grant_service set! Checking: {}",
620651
state.grant_service.is_some()
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
//! Tauri commands for meta-tool approval dialogs.
2+
//!
3+
//! Flow:
4+
//! 1. Gateway's [`ApprovalBroker`] emits `meta-tool-approval-request`
5+
//! event (see gateway.rs `start_gateway`).
6+
//! 2. React dialog renders it, user picks once/always/deny.
7+
//! 3. Dialog calls [`respond_to_meta_tool_approval`], which resolves the
8+
//! broker's oneshot channel and unblocks the calling tool.
9+
10+
use std::sync::Arc;
11+
12+
use mcpmux_gateway::services::ApprovalDecision;
13+
use serde::Serialize;
14+
use tauri::State;
15+
use tokio::sync::RwLock;
16+
use tracing::{info, warn};
17+
use uuid::Uuid;
18+
19+
use crate::commands::gateway::GatewayAppState;
20+
21+
#[derive(Debug, Serialize)]
22+
pub struct MetaToolGrantEntry {
23+
pub client_id: String,
24+
pub tool_name: String,
25+
}
26+
27+
/// Resolve a pending approval dialog.
28+
///
29+
/// `decision` is one of `"allow_once" | "always_for_this_session_and_client" | "deny"`.
30+
/// Called from the React dialog. If the broker doesn't recognize the
31+
/// request_id (e.g. it already timed out), returns a no-op success so the
32+
/// UI can close its dialog cleanly.
33+
#[tauri::command]
34+
pub async fn respond_to_meta_tool_approval(
35+
request_id: String,
36+
client_id: String,
37+
tool_name: String,
38+
decision: String,
39+
gateway_state: State<'_, Arc<RwLock<GatewayAppState>>>,
40+
) -> Result<bool, String> {
41+
let decision = match decision.as_str() {
42+
"allow_once" => ApprovalDecision::AllowOnce,
43+
"always_for_this_session_and_client" => ApprovalDecision::AlwaysForThisSessionAndClient,
44+
"deny" => ApprovalDecision::Deny,
45+
other => return Err(format!("unknown decision: {other}")),
46+
};
47+
let client_uuid = Uuid::parse_str(&client_id).map_err(|e| format!("bad client_id: {e}"))?;
48+
49+
let broker = {
50+
let state = gateway_state.read().await;
51+
state.approval_broker.clone()
52+
};
53+
let Some(broker) = broker else {
54+
warn!("[meta-tool] respond called but gateway is not running");
55+
return Ok(false);
56+
};
57+
58+
let resolved = broker.respond(&request_id, client_uuid, &tool_name, decision);
59+
info!(
60+
%request_id,
61+
%client_id,
62+
tool = %tool_name,
63+
?decision,
64+
resolved,
65+
"[meta-tool] approval decision recorded"
66+
);
67+
Ok(resolved)
68+
}
69+
70+
/// List every active "always allow from this client for this tool" grant.
71+
///
72+
/// Entries are session-only (cleared on gateway restart by design). The
73+
/// Connections page uses this to show a revoke list.
74+
#[tauri::command]
75+
pub async fn list_meta_tool_grants(
76+
gateway_state: State<'_, Arc<RwLock<GatewayAppState>>>,
77+
) -> Result<Vec<MetaToolGrantEntry>, String> {
78+
let broker = {
79+
let state = gateway_state.read().await;
80+
state.approval_broker.clone()
81+
};
82+
let Some(broker) = broker else {
83+
return Ok(vec![]);
84+
};
85+
Ok(broker
86+
.list_always_allow()
87+
.into_iter()
88+
.map(|(client_id, tool_name)| MetaToolGrantEntry {
89+
client_id: client_id.to_string(),
90+
tool_name,
91+
})
92+
.collect())
93+
}
94+
95+
/// Revoke an "always allow" entry.
96+
#[tauri::command]
97+
pub async fn revoke_meta_tool_grant(
98+
client_id: String,
99+
tool_name: String,
100+
gateway_state: State<'_, Arc<RwLock<GatewayAppState>>>,
101+
) -> Result<bool, String> {
102+
let client_uuid = Uuid::parse_str(&client_id).map_err(|e| format!("bad client_id: {e}"))?;
103+
let broker = {
104+
let state = gateway_state.read().await;
105+
state.approval_broker.clone()
106+
};
107+
let Some(broker) = broker else {
108+
return Ok(false);
109+
};
110+
Ok(broker.revoke_always_allow(client_uuid, &tool_name))
111+
}

apps/desktop/src-tauri/src/commands/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ pub mod feature_members;
1212
pub mod feature_set;
1313
pub mod gateway;
1414
pub mod logs;
15+
pub mod meta_tool_approval;
1516
pub mod oauth;
1617
pub mod server;
1718
pub mod server_discovery;
@@ -30,6 +31,7 @@ pub use feature_members::*;
3031
pub use feature_set::*;
3132
pub use gateway::*;
3233
pub use logs::*;
34+
pub use meta_tool_approval::*;
3335
pub use oauth::*;
3436
pub use server::*;
3537
pub use server_discovery::*;

apps/desktop/src-tauri/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -801,6 +801,10 @@ pub fn run() {
801801
commands::create_workspace_binding,
802802
commands::update_workspace_binding,
803803
commands::delete_workspace_binding,
804+
// Meta-tool approval (self-management mcpmux_* tools)
805+
commands::respond_to_meta_tool_approval,
806+
commands::list_meta_tool_grants,
807+
commands::revoke_meta_tool_grant,
804808
// Config export commands
805809
commands::preview_config_export,
806810
commands::export_config_to_file,

apps/desktop/src/App.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import { ClientsPage } from '@/features/clients';
4242
import { ServersPage } from '@/features/servers';
4343
import { SpacesPage } from '@/features/spaces';
4444
import { SettingsPage } from '@/features/settings';
45+
import { MetaToolApprovalDialog } from '@/features/metaTools';
4546
import { useGatewayEvents, useServerStatusEvents } from '@/hooks/useDomainEvents';
4647

4748
/** McpMux title-bar icon — miniature cat icon */
@@ -353,6 +354,8 @@ function App() {
353354
<OAuthConsentModal />
354355
{/* Server install modal - shown when install deep link is received */}
355356
<ServerInstallModal />
357+
{/* Meta-tool approval dialog — gates every mcpmux_* write tool */}
358+
<MetaToolApprovalDialog />
356359
</ThemeProvider>
357360
);
358361
}

0 commit comments

Comments
 (0)