Skip to content

Commit 2af5d4c

Browse files
committed
feat(meta-tools): debug auto-approve to self-test mcpmux_* writes
Self-testing the workspace-root routing means driving write meta tools (mcpmux_create_feature_set, mcpmux_bind_current_workspace) from a real client — but every write blocks on a native approval dialog, which a headless/scripted client can't satisfy. Add a session-only DEBUG bypass on ApprovalBroker: - `auto_approve: AtomicBool`, seeded from MCPMUX_DEBUG_AUTO_APPROVE=1|true at startup; `set_auto_approve` / `auto_approve_enabled` toggle it live. - request_approval() step 0: if on, return AllowOnce before any dialog, rate-limit, or publisher check — so even a headless gateway approves. - Tauri commands set_meta_tools_auto_approve / get_meta_tools_auto_approve (registered in invoke_handler) + metaTools.ts bindings. - A clearly-marked amber "Auto-approve writes (debug)" switch in MetaToolGrantsPanel so it's discoverable and obviously dev-only. Not persisted: resets to the env default on gateway restart, so the safe "every write needs a fresh nod" default always returns. Test: approval::tests::auto_approve_bypasses_dialog — on => AllowOnce with no publisher; off => restores ApprovalRequiredNoDesktop. Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent 014be24 commit 2af5d4c

5 files changed

Lines changed: 189 additions & 26 deletions

File tree

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,3 +109,44 @@ pub async fn revoke_meta_tool_grant(
109109
};
110110
Ok(broker.revoke_always_allow(&client_id, &tool_name))
111111
}
112+
113+
/// DEBUG/dev only: toggle auto-approval of all write meta tools.
114+
///
115+
/// When on, every `mcpmux_*` write tool (create_feature_set,
116+
/// bind_current_workspace, …) is approved without a dialog. This exists so a
117+
/// developer (or the in-app assistant) can self-create feature sets / bindings
118+
/// and exercise the routing end-to-end without clicking through approvals.
119+
/// Session-only — it is **not** persisted and resets to the
120+
/// `MCPMUX_DEBUG_AUTO_APPROVE` env default on gateway restart.
121+
#[tauri::command]
122+
pub async fn set_meta_tools_auto_approve(
123+
enabled: bool,
124+
gateway_state: State<'_, Arc<RwLock<GatewayAppState>>>,
125+
) -> Result<bool, String> {
126+
let broker = {
127+
let state = gateway_state.read().await;
128+
state.approval_broker.clone()
129+
};
130+
let Some(broker) = broker else {
131+
warn!("[meta-tool] set_meta_tools_auto_approve called but gateway is not running");
132+
return Err("gateway is not running".into());
133+
};
134+
broker.set_auto_approve(enabled);
135+
warn!(enabled, "[meta-tool] auto-approve toggled (DEBUG)");
136+
Ok(enabled)
137+
}
138+
139+
/// Whether write meta tools are currently auto-approved (DEBUG mode state).
140+
#[tauri::command]
141+
pub async fn get_meta_tools_auto_approve(
142+
gateway_state: State<'_, Arc<RwLock<GatewayAppState>>>,
143+
) -> Result<bool, String> {
144+
let broker = {
145+
let state = gateway_state.read().await;
146+
state.approval_broker.clone()
147+
};
148+
let Some(broker) = broker else {
149+
return Ok(false);
150+
};
151+
Ok(broker.auto_approve_enabled())
152+
}

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::set_meta_tools_auto_approve,
914+
commands::get_meta_tools_auto_approve,
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: 59 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
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 { FlaskConical, KeyRound, Loader2, Trash2 } from 'lucide-react';
3+
import { Button, Card, CardContent, CardHeader, CardTitle, Switch } from '@mcpmux/ui';
44
import {
5+
getMetaToolsAutoApprove,
56
listMetaToolGrants,
67
revokeMetaToolGrant,
8+
setMetaToolsAutoApprove,
79
type MetaToolGrantEntry,
810
} from '@/lib/api/metaTools';
911

@@ -21,6 +23,7 @@ export function MetaToolGrantsPanel() {
2123
const [grants, setGrants] = useState<MetaToolGrantEntry[] | null>(null);
2224
const [error, setError] = useState<string | null>(null);
2325
const [revoking, setRevoking] = useState<string | null>(null);
26+
const [autoApprove, setAutoApprove] = useState<boolean | null>(null);
2427

2528
const load = useCallback(async () => {
2629
try {
@@ -38,6 +41,23 @@ export function MetaToolGrantsPanel() {
3841
return () => clearInterval(i);
3942
}, [load]);
4043

44+
useEffect(() => {
45+
getMetaToolsAutoApprove()
46+
.then(setAutoApprove)
47+
.catch(() => setAutoApprove(false));
48+
}, []);
49+
50+
const handleToggleAutoApprove = async (next: boolean) => {
51+
const prev = autoApprove;
52+
setAutoApprove(next);
53+
try {
54+
await setMetaToolsAutoApprove(next);
55+
} catch (e) {
56+
setAutoApprove(prev);
57+
setError(e instanceof Error ? e.message : String(e));
58+
}
59+
};
60+
4161
const handleRevoke = async (g: MetaToolGrantEntry) => {
4262
const key = `${g.client_id}:${g.tool_name}`;
4363
setRevoking(key);
@@ -54,28 +74,51 @@ export function MetaToolGrantsPanel() {
5474
return (
5575
<Card data-testid="meta-tool-grants-panel">
5676
<CardHeader>
57-
<CardTitle className="text-base flex items-center gap-2">
77+
<CardTitle className="flex items-center gap-2 text-base">
5878
<KeyRound className="h-4 w-4" />
5979
Meta-tool auto-approvals
6080
</CardTitle>
61-
<p className="text-xs text-[rgb(var(--muted))] mt-1">
62-
&quot;Always for this session&quot; approvals granted to clients for
63-
specific <code className="font-mono">mcpmux_*</code> tools. Wipes on
64-
gateway restart.
81+
<p className="mt-1 text-xs text-[rgb(var(--muted))]">
82+
&quot;Always for this session&quot; approvals granted to clients for specific{' '}
83+
<code className="font-mono">mcpmux_*</code> tools. Wipes on gateway restart.
6584
</p>
6685
</CardHeader>
6786
<CardContent>
68-
{error && (
69-
<div className="text-sm text-red-600 dark:text-red-400 mb-2">
70-
{error}
87+
{error && <div className="mb-2 text-sm text-red-600 dark:text-red-400">{error}</div>}
88+
89+
{/* DEBUG: bypass the approval dialog entirely. For developers driving
90+
mcpmux_* writes (create feature sets / bindings) from a client to
91+
self-test routing. Session-only; resets on gateway restart. */}
92+
<div
93+
className="mb-4 flex items-start justify-between gap-3 rounded-lg border border-amber-300/60 bg-amber-50 p-3 dark:border-amber-700/50 dark:bg-amber-900/20"
94+
data-testid="meta-tool-auto-approve"
95+
>
96+
<div className="flex min-w-0 gap-2">
97+
<FlaskConical className="mt-0.5 h-4 w-4 flex-shrink-0 text-amber-600 dark:text-amber-400" />
98+
<div className="min-w-0">
99+
<div className="text-sm font-medium text-amber-900 dark:text-amber-200">
100+
Auto-approve writes (debug)
101+
</div>
102+
<p className="mt-0.5 text-xs text-amber-800/80 dark:text-amber-300/80">
103+
Skip the approval dialog for every <code className="font-mono">mcpmux_*</code>{' '}
104+
write. For self-testing only — resets on gateway restart.
105+
</p>
106+
</div>
71107
</div>
72-
)}
108+
<Switch
109+
checked={autoApprove ?? false}
110+
disabled={autoApprove === null}
111+
onCheckedChange={(v) => void handleToggleAutoApprove(v)}
112+
data-testid="meta-tool-auto-approve-toggle"
113+
/>
114+
</div>
115+
73116
{grants === null ? (
74117
<div className="flex items-center gap-2 text-sm text-[rgb(var(--muted))]">
75118
<Loader2 className="h-4 w-4 animate-spin" /> Loading…
76119
</div>
77120
) : grants.length === 0 ? (
78-
<p className="text-sm text-[rgb(var(--muted))] italic">
121+
<p className="text-sm italic text-[rgb(var(--muted))]">
79122
No auto-approvals yet. Each dialog defaults to &quot;Allow once&quot;.
80123
</p>
81124
) : (
@@ -88,11 +131,9 @@ export function MetaToolGrantsPanel() {
88131
className="flex items-center justify-between py-2 text-sm"
89132
data-testid={`meta-tool-grant-${g.tool_name}`}
90133
>
91-
<div className="flex flex-col min-w-0 mr-3">
92-
<span className="font-mono text-xs truncate">
93-
{g.tool_name}
94-
</span>
95-
<span className="text-[11px] text-[rgb(var(--muted))] truncate">
134+
<div className="mr-3 flex min-w-0 flex-col">
135+
<span className="truncate font-mono text-xs">{g.tool_name}</span>
136+
<span className="truncate text-[11px] text-[rgb(var(--muted))]">
96137
client {g.client_id.slice(0, 8)}
97138
</span>
98139
</div>
@@ -107,7 +148,7 @@ export function MetaToolGrantsPanel() {
107148
<Loader2 className="h-3 w-3 animate-spin" />
108149
) : (
109150
<>
110-
<Trash2 className="h-3 w-3 mr-1" /> Revoke
151+
<Trash2 className="mr-1 h-3 w-3" /> Revoke
111152
</>
112153
)}
113154
</Button>

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

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,31 @@ export async function listMetaToolGrants(): Promise<MetaToolGrantEntry[]> {
2525
}
2626

2727
/** Revoke a single "always allow" entry. */
28-
export async function revokeMetaToolGrant(
29-
clientId: string,
30-
toolName: string
31-
): Promise<boolean> {
28+
export async function revokeMetaToolGrant(clientId: string, toolName: string): Promise<boolean> {
3229
return invoke('revoke_meta_tool_grant', { clientId, toolName });
3330
}
3431

3532
// The mcpmux_* enablement switch is now per-Space — see
3633
// `@/lib/api/builtinServers` (listBuiltinServers / setBuiltinServerEnabled /
3734
// setBuiltinToolEnabled). The old global get/set_meta_tools_enabled were removed.
3835

36+
/**
37+
* DEBUG/dev only: toggle auto-approval of every write meta tool.
38+
*
39+
* When on, `mcpmux_create_feature_set` / `mcpmux_bind_current_workspace` and
40+
* friends are approved without a dialog — so a developer can self-create
41+
* feature sets and bindings and exercise routing end-to-end. Session-only:
42+
* resets on gateway restart (to the `MCPMUX_DEBUG_AUTO_APPROVE` env default).
43+
*/
44+
export async function setMetaToolsAutoApprove(enabled: boolean): Promise<boolean> {
45+
return invoke('set_meta_tools_auto_approve', { enabled });
46+
}
47+
48+
/** Whether write meta tools are currently auto-approved (DEBUG mode state). */
49+
export async function getMetaToolsAutoApprove(): Promise<boolean> {
50+
return invoke('get_meta_tools_auto_approve');
51+
}
52+
3953
/**
4054
* Respond to a pending approval request. Normally called by
4155
* `<MetaToolApprovalDialog>`; exported here for tests and advanced flows.
@@ -44,10 +58,7 @@ export async function respondToMetaToolApproval(
4458
requestId: string,
4559
clientId: string,
4660
toolName: string,
47-
decision:
48-
| 'allow_once'
49-
| 'always_for_this_session_and_client'
50-
| 'deny'
61+
decision: 'allow_once' | 'always_for_this_session_and_client' | 'deny'
5162
): Promise<boolean> {
5263
return invoke('respond_to_meta_tool_approval', {
5364
requestId,

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

Lines changed: 68 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,12 @@ pub struct ApprovalBroker {
117118
/// Published to the desktop layer; `None` means headless.
118119
publisher: Mutex<Option<ApprovalPublisher>>,
119120
timeout: Duration,
121+
/// DEBUG/dev only: when set, every write meta-tool is auto-approved without
122+
/// a dialog. Lets self-tests drive `mcpmux_create_feature_set` /
123+
/// `mcpmux_bind_current_workspace` headlessly. Off by default; enabled via
124+
/// `MCPMUX_DEBUG_AUTO_APPROVE=1` at startup or the
125+
/// `set_meta_tools_auto_approve` command at runtime.
126+
auto_approve: AtomicBool,
120127
}
121128

122129
impl Default for ApprovalBroker {
@@ -127,12 +134,21 @@ impl Default for ApprovalBroker {
127134

128135
impl ApprovalBroker {
129136
pub fn new() -> Self {
137+
let auto = std::env::var("MCPMUX_DEBUG_AUTO_APPROVE")
138+
.map(|v| matches!(v.as_str(), "1" | "true"))
139+
.unwrap_or(false);
140+
if auto {
141+
warn!(
142+
"[ApprovalBroker] MCPMUX_DEBUG_AUTO_APPROVE set — meta-tool writes auto-approved"
143+
);
144+
}
130145
Self {
131146
pending: DashMap::new(),
132147
always_allow: DashMap::new(),
133148
rate_limit: DashMap::new(),
134149
publisher: Mutex::new(None),
135150
timeout: DEFAULT_TIMEOUT,
151+
auto_approve: AtomicBool::new(auto),
136152
}
137153
}
138154

@@ -141,6 +157,17 @@ impl ApprovalBroker {
141157
self
142158
}
143159

160+
/// DEBUG/dev only: toggle auto-approval of all write meta tools.
161+
pub fn set_auto_approve(&self, on: bool) {
162+
self.auto_approve.store(on, Ordering::Relaxed);
163+
warn!(on, "[ApprovalBroker] auto-approve toggled (DEBUG)");
164+
}
165+
166+
/// Whether write meta tools are currently auto-approved.
167+
pub fn auto_approve_enabled(&self) -> bool {
168+
self.auto_approve.load(Ordering::Relaxed)
169+
}
170+
144171
/// Attach the desktop subscriber. Call once at app startup.
145172
pub async fn set_publisher(&self, publisher: ApprovalPublisher) {
146173
*self.publisher.lock().await = Some(publisher);
@@ -212,6 +239,16 @@ impl ApprovalBroker {
212239
tool_name: &str,
213240
payload: ApprovalPayload,
214241
) -> Result<ApprovalDecision, MetaToolError> {
242+
// 0. DEBUG auto-approve — bypass the dialog entirely (dev/self-test).
243+
if self.auto_approve.load(Ordering::Relaxed) {
244+
warn!(
245+
%client_id,
246+
tool = tool_name,
247+
"[ApprovalBroker] DEBUG auto-approve — bypassing dialog",
248+
);
249+
return Ok(ApprovalDecision::AllowOnce);
250+
}
251+
215252
// 1. Always-allow short-circuit.
216253
if self
217254
.always_allow
@@ -332,6 +369,37 @@ mod tests {
332369
assert!(matches!(err, MetaToolError::ApprovalRequiredNoDesktop));
333370
}
334371

372+
#[tokio::test]
373+
async fn auto_approve_bypasses_dialog() {
374+
// DEBUG mode: even with NO publisher attached (which normally yields
375+
// ApprovalRequiredNoDesktop), auto-approve returns AllowOnce so
376+
// self-tests can drive write meta tools headlessly. Toggling off
377+
// restores the safe headless deny.
378+
let broker = ApprovalBroker::new();
379+
broker.set_auto_approve(true);
380+
assert!(broker.auto_approve_enabled());
381+
let d = broker
382+
.request_approval(
383+
&Uuid::new_v4().to_string(),
384+
"mcpmux_bind_current_workspace",
385+
make_payload(),
386+
)
387+
.await
388+
.unwrap();
389+
assert_eq!(d, ApprovalDecision::AllowOnce);
390+
391+
broker.set_auto_approve(false);
392+
let err = broker
393+
.request_approval(
394+
&Uuid::new_v4().to_string(),
395+
"mcpmux_bind_current_workspace",
396+
make_payload(),
397+
)
398+
.await
399+
.unwrap_err();
400+
assert!(matches!(err, MetaToolError::ApprovalRequiredNoDesktop));
401+
}
402+
335403
#[tokio::test]
336404
async fn always_allow_short_circuits() {
337405
let broker = ApprovalBroker::new();

0 commit comments

Comments
 (0)