Skip to content

Commit 6048999

Browse files
committed
feat(meta-tools): persisted "require approval" switch
Replaces the removed debug auto-approve with a real, production control: a global "Require approval for tool changes" switch (default ON). OFF auto-approves every `mcpmux_*` write without a dialog — the explicit "trust this local machine" choice the debug toggle only pretended to offer. - ApprovalBroker gains `require_approval` (default true) + set/get; `request_approval` short-circuits to AllowOnce when OFF, ahead of the always-allow / rate-limit / dialog path. - Persisted in app settings (`meta_tools.require_approval`) and restored onto the broker on every gateway start via `attach_approval_publisher` — the one chokepoint both start paths funnel through — so the broker (recreated per start) always reflects the saved choice. - New Tauri commands `get/set_meta_tools_require_approval` persist the setting and apply it to the live broker immediately. - UI: repurpose the "Meta-tool auto-approvals" card → "Tool-management approvals", hosting the master switch (amber warning when off) above the session "always allow" grants list. - Test: broker auto-approves when off (even headless) and re-prompts when on. Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent d6c9600 commit 6048999

6 files changed

Lines changed: 240 additions & 13 deletions

File tree

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,26 @@ pub(crate) async fn attach_approval_publisher<R: tauri::Runtime>(
144144
approval_broker: &Arc<mcpmux_gateway::services::ApprovalBroker>,
145145
app_handle: tauri::AppHandle<R>,
146146
) {
147+
// Restore the persisted "require approval" switch onto the broker (which is
148+
// recreated on every gateway start). Default ON when unset. This is the
149+
// single chokepoint both start paths (auto-start + start_gateway command)
150+
// funnel through, so the setting always survives a restart.
151+
{
152+
use tauri::Manager;
153+
let required = match app_handle.try_state::<AppState>() {
154+
Some(app_state) => app_state
155+
.settings_repository
156+
.get("meta_tools.require_approval")
157+
.await
158+
.ok()
159+
.flatten()
160+
.map(|v| v != "false")
161+
.unwrap_or(true),
162+
None => true,
163+
};
164+
approval_broker.set_require_approval(required);
165+
}
166+
147167
let publisher: mcpmux_gateway::services::meta_tools::ApprovalPublisher = Arc::new(move |req| {
148168
let app_handle = app_handle.clone();
149169
Box::pin(async move {

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

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ use tokio::sync::RwLock;
1616
use tracing::{info, warn};
1717

1818
use crate::commands::gateway::GatewayAppState;
19+
use crate::AppState;
20+
21+
/// App-settings key for the global "require approval for tool-management
22+
/// writes" switch. Persisted (survives restart); the gateway restores it onto
23+
/// the in-memory broker on every start.
24+
const REQUIRE_APPROVAL_KEY: &str = "meta_tools.require_approval";
1925

2026
#[derive(Debug, Serialize)]
2127
pub struct MetaToolGrantEntry {
@@ -109,3 +115,48 @@ pub async fn revoke_meta_tool_grant(
109115
};
110116
Ok(broker.revoke_always_allow(&client_id, &tool_name))
111117
}
118+
119+
/// Whether write meta-tools currently require approval (default `true`).
120+
/// Reads the persisted setting so the UI shows the right state even before
121+
/// the gateway has started.
122+
#[tauri::command]
123+
pub async fn get_meta_tools_require_approval(
124+
app_state: State<'_, AppState>,
125+
) -> Result<bool, String> {
126+
let stored = app_state
127+
.settings_repository
128+
.get(REQUIRE_APPROVAL_KEY)
129+
.await
130+
.map_err(|e| e.to_string())?;
131+
// Default ON: a missing setting means "require approval".
132+
Ok(stored.map(|v| v != "false").unwrap_or(true))
133+
}
134+
135+
/// Set the global "require approval for tool-management writes" switch.
136+
///
137+
/// `required = false` makes every `mcpmux_*` write auto-approve without a
138+
/// dialog — the user's explicit "trust this machine" choice. Persisted to app
139+
/// settings AND applied to the live broker (if the gateway is running) so it
140+
/// takes effect immediately and survives restart.
141+
#[tauri::command]
142+
pub async fn set_meta_tools_require_approval(
143+
required: bool,
144+
app_state: State<'_, AppState>,
145+
gateway_state: State<'_, Arc<RwLock<GatewayAppState>>>,
146+
) -> Result<bool, String> {
147+
app_state
148+
.settings_repository
149+
.set(REQUIRE_APPROVAL_KEY, &required.to_string())
150+
.await
151+
.map_err(|e| e.to_string())?;
152+
153+
let broker = {
154+
let state = gateway_state.read().await;
155+
state.approval_broker.clone()
156+
};
157+
if let Some(broker) = broker {
158+
broker.set_require_approval(required);
159+
}
160+
warn!(required, "[meta-tool] require-approval switch updated");
161+
Ok(required)
162+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -910,6 +910,8 @@ pub fn run() {
910910
commands::respond_to_meta_tool_approval,
911911
commands::list_meta_tool_grants,
912912
commands::revoke_meta_tool_grant,
913+
commands::get_meta_tools_require_approval,
914+
commands::set_meta_tools_require_approval,
913915
// Built-in servers (per-Space enablement + per-tool toggles)
914916
commands::list_builtin_servers,
915917
commands::set_builtin_server_enabled,

apps/desktop/src/features/metaTools/MetaToolGrantsPanel.tsx

Lines changed: 80 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,30 @@
11
import { useCallback, useEffect, useState } from 'react';
2-
import { KeyRound, Loader2, Trash2 } from 'lucide-react';
3-
import { Button, Card, CardContent, CardHeader, CardTitle } from '@mcpmux/ui';
2+
import { AlertTriangle, KeyRound, Loader2, ShieldCheck, Trash2 } from 'lucide-react';
3+
import { Button, Card, CardContent, CardHeader, CardTitle, Switch } from '@mcpmux/ui';
44
import {
5+
getMetaToolsRequireApproval,
56
listMetaToolGrants,
67
revokeMetaToolGrant,
8+
setMetaToolsRequireApproval,
79
type MetaToolGrantEntry,
810
} from '@/lib/api/metaTools';
911

1012
/**
11-
* Session-scoped "always allow (client, tool)" grants. These live in the
12-
* gateway's in-memory `ApprovalBroker` and are wiped on gateway restart —
13-
* so showing the list is both for awareness AND for a panic-revoke button
14-
* when a user regrets ticking "Always for this session".
13+
* Approvals for the `mcpmux_*` self-management writes:
14+
* 1. The master "Require approval" switch — persisted; OFF auto-approves
15+
* every write on this (trusted, local) machine.
16+
* 2. The session-scoped "always allow (client, tool)" grants, which live in
17+
* the gateway's in-memory broker and wipe on restart — shown for
18+
* awareness with a panic-revoke button.
1519
*
16-
* Drop this anywhere. It refetches on mount and polls every 10s because the
17-
* underlying broker state can change from either side (dialog clicks or
18-
* calls to `revokeMetaToolGrant`).
20+
* Refetches on mount and polls every 10s because the broker state can change
21+
* from either side (dialog clicks or calls to `revokeMetaToolGrant`).
1922
*/
2023
export function MetaToolGrantsPanel() {
2124
const [grants, setGrants] = useState<MetaToolGrantEntry[] | null>(null);
2225
const [error, setError] = useState<string | null>(null);
2326
const [revoking, setRevoking] = useState<string | null>(null);
27+
const [requireApproval, setRequireApproval] = useState<boolean | null>(null);
2428

2529
const load = useCallback(async () => {
2630
try {
@@ -38,6 +42,23 @@ export function MetaToolGrantsPanel() {
3842
return () => clearInterval(i);
3943
}, [load]);
4044

45+
useEffect(() => {
46+
getMetaToolsRequireApproval()
47+
.then(setRequireApproval)
48+
.catch(() => setRequireApproval(true));
49+
}, []);
50+
51+
const handleToggleRequireApproval = async (required: boolean) => {
52+
const prev = requireApproval;
53+
setRequireApproval(required);
54+
try {
55+
await setMetaToolsRequireApproval(required);
56+
} catch (e) {
57+
setRequireApproval(prev);
58+
setError(e instanceof Error ? e.message : String(e));
59+
}
60+
};
61+
4162
const handleRevoke = async (g: MetaToolGrantEntry) => {
4263
const key = `${g.client_id}:${g.tool_name}`;
4364
setRevoking(key);
@@ -55,17 +76,63 @@ export function MetaToolGrantsPanel() {
5576
<Card data-testid="meta-tool-grants-panel">
5677
<CardHeader>
5778
<CardTitle className="flex items-center gap-2 text-base">
58-
<KeyRound className="h-4 w-4" />
59-
Meta-tool auto-approvals
79+
<ShieldCheck className="h-4 w-4" />
80+
Tool-management approvals
6081
</CardTitle>
6182
<p className="mt-1 text-xs text-[rgb(var(--muted))]">
62-
&quot;Always for this session&quot; approvals granted to clients for specific{' '}
63-
<code className="font-mono">mcpmux_*</code> tools. Wipes on gateway restart.
83+
Control approval for the <code className="font-mono">mcpmux_*</code> writes a connected
84+
AI can make (create/update/delete feature sets, bind a workspace).
6485
</p>
6586
</CardHeader>
6687
<CardContent>
6788
{error && <div className="mb-2 text-sm text-red-600 dark:text-red-400">{error}</div>}
6889

90+
{/* Master switch — persisted across restarts. OFF auto-approves every
91+
write on this machine. */}
92+
<div
93+
className={`mb-4 flex items-start justify-between gap-3 rounded-lg border p-3 ${
94+
requireApproval === false
95+
? 'border-amber-300/60 bg-amber-50 dark:border-amber-700/50 dark:bg-amber-900/20'
96+
: 'border-[rgb(var(--border-subtle))] bg-[rgb(var(--surface))]'
97+
}`}
98+
data-testid="meta-tool-require-approval"
99+
>
100+
<div className="flex min-w-0 gap-2">
101+
{requireApproval === false ? (
102+
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-amber-600 dark:text-amber-400" />
103+
) : (
104+
<ShieldCheck className="mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-600 dark:text-emerald-400" />
105+
)}
106+
<div className="min-w-0">
107+
<div className="text-sm font-medium">Require approval for tool changes</div>
108+
<p className="mt-0.5 text-xs text-[rgb(var(--muted))]">
109+
{requireApproval === false ? (
110+
<span className="text-amber-800 dark:text-amber-300">
111+
Off — every <code className="font-mono">mcpmux_*</code> write is applied without
112+
asking. Only leave this off on a machine where you trust every connected client.
113+
</span>
114+
) : (
115+
<>
116+
On — each <code className="font-mono">mcpmux_*</code> write prompts you to Allow
117+
or Deny. Turn off to auto-approve on a trusted machine.
118+
</>
119+
)}
120+
</p>
121+
</div>
122+
</div>
123+
<Switch
124+
checked={requireApproval ?? true}
125+
disabled={requireApproval === null}
126+
onCheckedChange={(v) => void handleToggleRequireApproval(v)}
127+
data-testid="meta-tool-require-approval-toggle"
128+
/>
129+
</div>
130+
131+
<div className="mb-2 flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-[rgb(var(--muted))]">
132+
<KeyRound className="h-3 w-3" />
133+
Session &quot;always allow&quot; grants
134+
</div>
135+
69136
{grants === null ? (
70137
<div className="flex items-center gap-2 text-sm text-[rgb(var(--muted))]">
71138
<Loader2 className="h-4 w-4 animate-spin" /> Loading…

apps/desktop/src/lib/api/metaTools.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,22 @@ export async function revokeMetaToolGrant(clientId: string, toolName: string): P
2929
return invoke('revoke_meta_tool_grant', { clientId, toolName });
3030
}
3131

32+
/** Whether write meta tools require approval (default true). Persisted. */
33+
export async function getMetaToolsRequireApproval(): Promise<boolean> {
34+
return invoke('get_meta_tools_require_approval');
35+
}
36+
37+
/**
38+
* Set the global "require approval for tool-management writes" switch.
39+
*
40+
* `required = false` makes every `mcpmux_*` write auto-approve without a
41+
* dialog — an explicit "trust this machine" choice. Persisted (survives
42+
* restart) and applied to the running gateway immediately.
43+
*/
44+
export async function setMetaToolsRequireApproval(required: boolean): Promise<boolean> {
45+
return invoke('set_meta_tools_require_approval', { required });
46+
}
47+
3248
// The mcpmux_* enablement switch is now per-Space — see
3349
// `@/lib/api/builtinServers` (listBuiltinServers / setBuiltinServerEnabled /
3450
// setBuiltinToolEnabled). The old global get/set_meta_tools_enabled were removed.

crates/mcpmux-gateway/src/services/meta_tools/approval.rs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
//! client_metadata URL for DCR-registered clients like Claude Code). The
2525
//! broker doesn't parse it; equality + hashing is enough.
2626
27+
use std::sync::atomic::{AtomicBool, Ordering};
2728
use std::sync::Arc;
2829
use std::time::{Duration, Instant};
2930

@@ -117,6 +118,13 @@ pub struct ApprovalBroker {
117118
/// Published to the desktop layer; `None` means headless.
118119
publisher: Mutex<Option<ApprovalPublisher>>,
119120
timeout: Duration,
121+
/// Whether write meta-tools require human approval at all. Default `true`
122+
/// (every write prompts). A user can turn this OFF in Settings to trust a
123+
/// local machine — then writes are auto-approved without a dialog. The
124+
/// authoritative value is **persisted** in app settings
125+
/// (`meta_tools.require_approval`); this in-memory flag is restored from
126+
/// there on every gateway start (the broker is recreated per start).
127+
require_approval: AtomicBool,
120128
}
121129

122130
impl Default for ApprovalBroker {
@@ -133,6 +141,7 @@ impl ApprovalBroker {
133141
rate_limit: DashMap::new(),
134142
publisher: Mutex::new(None),
135143
timeout: DEFAULT_TIMEOUT,
144+
require_approval: AtomicBool::new(true),
136145
}
137146
}
138147

@@ -141,6 +150,23 @@ impl ApprovalBroker {
141150
self
142151
}
143152

153+
/// Set whether write meta-tools require approval. `false` = auto-approve
154+
/// every write (no dialog) — the user's explicit "trust this machine"
155+
/// choice. Persisted by the caller; applied to the broker here.
156+
pub fn set_require_approval(&self, required: bool) {
157+
self.require_approval.store(required, Ordering::Relaxed);
158+
if !required {
159+
warn!(
160+
"[ApprovalBroker] approval requirement DISABLED — meta-tool writes auto-approved"
161+
);
162+
}
163+
}
164+
165+
/// Whether write meta-tools currently require approval (default `true`).
166+
pub fn require_approval_enabled(&self) -> bool {
167+
self.require_approval.load(Ordering::Relaxed)
168+
}
169+
144170
/// Attach the desktop subscriber. Call once at app startup.
145171
pub async fn set_publisher(&self, publisher: ApprovalPublisher) {
146172
*self.publisher.lock().await = Some(publisher);
@@ -202,6 +228,7 @@ impl ApprovalBroker {
202228
/// Core entry point for write meta tools.
203229
///
204230
/// Order of checks:
231+
/// 0. Approval requirement disabled (user opt-out) → `AllowOnce`.
205232
/// 1. Always-allow hit → immediate `AllowOnce` (no dialog).
206233
/// 2. Rate limit overflow → `RateLimited`.
207234
/// 3. No publisher attached → `ApprovalRequiredNoDesktop`.
@@ -212,6 +239,17 @@ impl ApprovalBroker {
212239
tool_name: &str,
213240
payload: ApprovalPayload,
214241
) -> Result<ApprovalDecision, MetaToolError> {
242+
// 0. Global "require approval" switch OFF — the user has opted to
243+
// auto-approve every write on this (trusted, local) machine.
244+
if !self.require_approval.load(Ordering::Relaxed) {
245+
debug!(
246+
%client_id,
247+
tool = tool_name,
248+
"[ApprovalBroker] approval requirement disabled; approving without dialog",
249+
);
250+
return Ok(ApprovalDecision::AllowOnce);
251+
}
252+
215253
// 1. Always-allow short-circuit.
216254
if self
217255
.always_allow
@@ -344,6 +382,39 @@ mod tests {
344382
assert_eq!(d, ApprovalDecision::AllowOnce);
345383
}
346384

385+
#[tokio::test]
386+
async fn require_approval_off_auto_approves_without_publisher() {
387+
// Default is ON (require approval).
388+
let broker = ApprovalBroker::new();
389+
assert!(broker.require_approval_enabled());
390+
391+
// OFF → writes auto-approve even with no desktop attached (which would
392+
// otherwise be ApprovalRequiredNoDesktop).
393+
broker.set_require_approval(false);
394+
assert!(!broker.require_approval_enabled());
395+
let d = broker
396+
.request_approval(
397+
&Uuid::new_v4().to_string(),
398+
"mcpmux_manage_feature_set",
399+
make_payload(),
400+
)
401+
.await
402+
.unwrap();
403+
assert_eq!(d, ApprovalDecision::AllowOnce);
404+
405+
// Back ON → no publisher → safe headless deny again.
406+
broker.set_require_approval(true);
407+
let err = broker
408+
.request_approval(
409+
&Uuid::new_v4().to_string(),
410+
"mcpmux_manage_feature_set",
411+
make_payload(),
412+
)
413+
.await
414+
.unwrap_err();
415+
assert!(matches!(err, MetaToolError::ApprovalRequiredNoDesktop));
416+
}
417+
347418
#[tokio::test]
348419
async fn url_client_id_works() {
349420
// Regression for the bug where DCR-registered clients (which use

0 commit comments

Comments
 (0)