Skip to content

Commit 382c374

Browse files
committed
feat(meta-tools): audit log + master switch + grants UI + E2E spec
Closes out the deferred scope from the previous meta-tools commit. Audit trail — DomainEvent::MetaToolInvoked * Added to core DomainEvent enum with payload { client_id, session_id, tool_name, decision, resolved_feature_set_id, summary }. * MetaToolRegistry::call emits one event per invocation (read → "read"; write → "allow_once" | "deny" | "timeout" | "approval_required" | "rate_limited" | "invalid_args" | "error"), so every tool is logged without each tool having to remember to do it. * Desktop domain-event bridge maps it to a new `meta-tool-invoked` Tauri channel distinct from backend server notifications. Master switch — `gateway.meta_tools_enabled` * New setting key (default ON). Read via MetaToolRegistry::is_enabled() which the MCP handler checks in both list_tools (hide tools) and call_tool (fall through to feature-set routing → "tool not found"). * Two new Tauri commands: get_meta_tools_enabled / set_meta_tools_enabled. * Wired through dependencies.rs + service_container.rs + build_default_registry — `settings_repo: Option<Arc<dyn AppSettingsRepository>>` is threaded as a new context field. Desktop UI (SettingsPage gains a Self-management Tools section) * Master-switch toggle with copy explaining scope. * <MetaToolGrantsPanel> — lists session-scoped "always-allow" grants backed by list_meta_tool_grants / revoke_meta_tool_grant. Polls every 10s in case a dialog click or an external revoke changes state. * <MetaToolAuditLog> — global listener for the `meta-tool-invoked` event; ring-buffer of the last 50 calls rendered with per-decision iconography (Eye/green/red/amber) and elapsed timestamps. * lib/api/metaTools.ts — typed wrappers for the new commands + respondToMetaToolApproval for tests. E2E (WebDriverIO) * tests/e2e/specs/meta-tools.wdio.ts — three specs: - master-switch round-trips via Tauri invoke (settings page) - grants panel + audit log mount in the settings section - synthetic `meta-tool-approval-request` surfaces the dialog; clicking Deny dismisses it. Covers the exact bridge tested in production (event → React → respond_to_meta_tool_approval). Rust integration tests (3 new, 17 passing total in the suite) * read_tool_emits_meta_tool_invoked_with_decision_read — verifies a read-tool call drops "read" on the bus. * denied_write_emits_meta_tool_invoked_with_decision_deny — verifies a write without publisher surfaces "approval_required" on the bus. * master_switch_toggles_registry_visibility — flips the setting on/off and confirms is_enabled() tracks it; missing key defaults on. Test totals now: 9 (mcpmux), 123 (core), 104 (gateway lib), 79 (database), 31 (gateway) + 69 (integration incl. 17 meta-tool tests) + 16 (streamable_http) + 53 (oauth) + 17 (security) + mcpmux-mcp 7, all green. pnpm validate: fmt + clippy + check + eslint + typecheck all clean. Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent 8c740a5 commit 382c374

17 files changed

Lines changed: 834 additions & 10 deletions

File tree

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,27 @@ fn map_domain_event_to_ui(event: &DomainEvent) -> (&'static str, serde_json::Val
456456
"server_id": server_id,
457457
}),
458458
),
459+
DomainEvent::MetaToolInvoked {
460+
client_id,
461+
session_id,
462+
tool_name,
463+
decision,
464+
resolved_feature_set_id,
465+
summary,
466+
} => (
467+
// New channel so the Connection Log can render a dedicated row
468+
// type without interleaving with regular backend events.
469+
"meta-tool-invoked",
470+
serde_json::json!({
471+
"client_id": client_id,
472+
"session_id": session_id,
473+
"tool_name": tool_name,
474+
"decision": decision,
475+
"resolved_feature_set_id": resolved_feature_set_id,
476+
"summary": summary,
477+
"timestamp": chrono::Utc::now().to_rfc3339(),
478+
}),
479+
),
459480
}
460481
}
461482

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,43 @@ pub fn should_start_hidden() -> bool {
128128
args.contains(&"--hidden".to_string())
129129
}
130130

131+
/// Get the current value of the meta-tools master switch.
132+
///
133+
/// When disabled, the gateway hides the entire `mcpmux_*` namespace from
134+
/// connected MCP clients — no introspection, no self-management. Default
135+
/// ON.
136+
#[tauri::command]
137+
pub async fn get_meta_tools_enabled(app_state: State<'_, AppState>) -> Result<bool, String> {
138+
match app_state
139+
.settings_repository
140+
.get("gateway.meta_tools_enabled")
141+
.await
142+
{
143+
Ok(Some(v)) => Ok(!matches!(v.as_str(), "false" | "0")),
144+
_ => Ok(true),
145+
}
146+
}
147+
148+
/// Flip the meta-tools master switch. The change takes effect on the NEXT
149+
/// `list_tools` / `call_tool` from any connected client — existing cached
150+
/// tool lists are invalidated by the usual `tools/list_changed` push.
151+
#[tauri::command]
152+
pub async fn set_meta_tools_enabled(
153+
enabled: bool,
154+
app_state: State<'_, AppState>,
155+
) -> Result<(), String> {
156+
app_state
157+
.settings_repository
158+
.set(
159+
"gateway.meta_tools_enabled",
160+
if enabled { "true" } else { "false" },
161+
)
162+
.await
163+
.map_err(|e| format!("Failed to save meta_tools_enabled: {}", e))?;
164+
info!("[Settings] meta_tools_enabled = {}", enabled);
165+
Ok(())
166+
}
167+
131168
#[cfg(test)]
132169
mod tests {
133170
use super::*;

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -805,6 +805,8 @@ pub fn run() {
805805
commands::respond_to_meta_tool_approval,
806806
commands::list_meta_tool_grants,
807807
commands::revoke_meta_tool_grant,
808+
commands::get_meta_tools_enabled,
809+
commands::set_meta_tools_enabled,
808810
// Config export commands
809811
commands::preview_config_export,
810812
commands::export_config_to_file,
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { useEffect, useState } from 'react';
2+
import { listen } from '@tauri-apps/api/event';
3+
import { CheckCircle2, Eye, ShieldAlert, XCircle } from 'lucide-react';
4+
import { Card, CardContent, CardHeader, CardTitle } from '@mcpmux/ui';
5+
import type { MetaToolAuditEvent } from '@/lib/api/metaTools';
6+
7+
/** Ring-buffer size — keeps the most recent N audit rows in memory. */
8+
const MAX_ROWS = 50;
9+
10+
/**
11+
* In-memory audit log of every `mcpmux_*` invocation (read or write,
12+
* success or failure). Subscribes to the gateway's `meta-tool-invoked`
13+
* event channel; rows are kept only for the current UI session — the
14+
* persistent audit stream lives in the gateway's tracing logs.
15+
*/
16+
export function MetaToolAuditLog() {
17+
const [rows, setRows] = useState<MetaToolAuditEvent[]>([]);
18+
19+
useEffect(() => {
20+
const unlisten = listen<MetaToolAuditEvent>(
21+
'meta-tool-invoked',
22+
(event) => {
23+
setRows((prev) => {
24+
// Most-recent-first; trim to MAX_ROWS.
25+
const next = [event.payload, ...prev];
26+
return next.length > MAX_ROWS ? next.slice(0, MAX_ROWS) : next;
27+
});
28+
}
29+
);
30+
return () => {
31+
unlisten.then((fn) => fn()).catch(() => {});
32+
};
33+
}, []);
34+
35+
return (
36+
<Card data-testid="meta-tool-audit-log">
37+
<CardHeader>
38+
<CardTitle className="text-base flex items-center gap-2">
39+
<Eye className="h-4 w-4" />
40+
Recent meta-tool activity
41+
</CardTitle>
42+
<p className="text-xs text-[rgb(var(--muted))] mt-1">
43+
Every call to <code className="font-mono">mcpmux_*</code> made by a
44+
connected MCP client. Live — last {MAX_ROWS} entries.
45+
</p>
46+
</CardHeader>
47+
<CardContent>
48+
{rows.length === 0 ? (
49+
<p className="text-sm text-[rgb(var(--muted))] italic">
50+
No activity yet. Rows appear as MCP clients call meta tools.
51+
</p>
52+
) : (
53+
<ul className="divide-y divide-[rgb(var(--border-subtle))] max-h-80 overflow-y-auto">
54+
{rows.map((r, i) => (
55+
<li
56+
key={`${r.timestamp}:${i}`}
57+
className="flex items-start gap-2 py-2 text-xs"
58+
data-testid={`meta-tool-audit-row-${r.tool_name}`}
59+
>
60+
<DecisionIcon decision={r.decision} />
61+
<div className="flex-1 min-w-0">
62+
<div className="flex items-center gap-2">
63+
<code className="font-mono font-medium truncate">
64+
{r.tool_name}
65+
</code>
66+
<span className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-[rgb(var(--surface))] border border-[rgb(var(--border-subtle))]">
67+
{r.decision}
68+
</span>
69+
</div>
70+
<div className="text-[11px] text-[rgb(var(--muted))] mt-0.5 truncate">
71+
client {r.client_id.slice(0, 8)}… •{' '}
72+
{new Date(r.timestamp).toLocaleTimeString()}
73+
</div>
74+
{r.summary && (
75+
<div className="text-[11px] text-[rgb(var(--muted))] mt-0.5 truncate">
76+
{r.summary}
77+
</div>
78+
)}
79+
</div>
80+
</li>
81+
))}
82+
</ul>
83+
)}
84+
</CardContent>
85+
</Card>
86+
);
87+
}
88+
89+
function DecisionIcon({ decision }: { decision: string }) {
90+
const className = 'h-4 w-4 mt-0.5 flex-shrink-0';
91+
switch (decision) {
92+
case 'read':
93+
return <Eye className={`${className} text-[rgb(var(--muted))]`} />;
94+
case 'allow_once':
95+
case 'always_for_this_session_and_client':
96+
return <CheckCircle2 className={`${className} text-green-500`} />;
97+
case 'deny':
98+
case 'timeout':
99+
case 'rate_limited':
100+
case 'approval_required':
101+
return <XCircle className={`${className} text-red-500`} />;
102+
case 'invalid_args':
103+
case 'error':
104+
default:
105+
return <ShieldAlert className={`${className} text-amber-500`} />;
106+
}
107+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { useCallback, useEffect, useState } from 'react';
2+
import { KeyRound, Loader2, Trash2 } from 'lucide-react';
3+
import { Button, Card, CardContent, CardHeader, CardTitle } from '@mcpmux/ui';
4+
import {
5+
listMetaToolGrants,
6+
revokeMetaToolGrant,
7+
type MetaToolGrantEntry,
8+
} from '@/lib/api/metaTools';
9+
10+
/**
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".
15+
*
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`).
19+
*/
20+
export function MetaToolGrantsPanel() {
21+
const [grants, setGrants] = useState<MetaToolGrantEntry[] | null>(null);
22+
const [error, setError] = useState<string | null>(null);
23+
const [revoking, setRevoking] = useState<string | null>(null);
24+
25+
const load = useCallback(async () => {
26+
try {
27+
const data = await listMetaToolGrants();
28+
setGrants(data);
29+
setError(null);
30+
} catch (e) {
31+
setError(e instanceof Error ? e.message : String(e));
32+
}
33+
}, []);
34+
35+
useEffect(() => {
36+
load();
37+
const i = setInterval(load, 10_000);
38+
return () => clearInterval(i);
39+
}, [load]);
40+
41+
const handleRevoke = async (g: MetaToolGrantEntry) => {
42+
const key = `${g.client_id}:${g.tool_name}`;
43+
setRevoking(key);
44+
try {
45+
await revokeMetaToolGrant(g.client_id, g.tool_name);
46+
await load();
47+
} catch (e) {
48+
setError(e instanceof Error ? e.message : String(e));
49+
} finally {
50+
setRevoking(null);
51+
}
52+
};
53+
54+
return (
55+
<Card data-testid="meta-tool-grants-panel">
56+
<CardHeader>
57+
<CardTitle className="text-base flex items-center gap-2">
58+
<KeyRound className="h-4 w-4" />
59+
Meta-tool auto-approvals
60+
</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.
65+
</p>
66+
</CardHeader>
67+
<CardContent>
68+
{error && (
69+
<div className="text-sm text-red-600 dark:text-red-400 mb-2">
70+
{error}
71+
</div>
72+
)}
73+
{grants === null ? (
74+
<div className="flex items-center gap-2 text-sm text-[rgb(var(--muted))]">
75+
<Loader2 className="h-4 w-4 animate-spin" /> Loading…
76+
</div>
77+
) : grants.length === 0 ? (
78+
<p className="text-sm text-[rgb(var(--muted))] italic">
79+
No auto-approvals yet. Each dialog defaults to &quot;Allow once&quot;.
80+
</p>
81+
) : (
82+
<ul className="divide-y divide-[rgb(var(--border-subtle))]">
83+
{grants.map((g) => {
84+
const key = `${g.client_id}:${g.tool_name}`;
85+
return (
86+
<li
87+
key={key}
88+
className="flex items-center justify-between py-2 text-sm"
89+
data-testid={`meta-tool-grant-${g.tool_name}`}
90+
>
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">
96+
client {g.client_id.slice(0, 8)}
97+
</span>
98+
</div>
99+
<Button
100+
variant="secondary"
101+
size="sm"
102+
onClick={() => handleRevoke(g)}
103+
disabled={revoking === key}
104+
data-testid={`meta-tool-grant-revoke-${g.tool_name}`}
105+
>
106+
{revoking === key ? (
107+
<Loader2 className="h-3 w-3 animate-spin" />
108+
) : (
109+
<>
110+
<Trash2 className="h-3 w-3 mr-1" /> Revoke
111+
</>
112+
)}
113+
</Button>
114+
</li>
115+
);
116+
})}
117+
</ul>
118+
)}
119+
</CardContent>
120+
</Card>
121+
);
122+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
export { MetaToolApprovalDialog } from './MetaToolApprovalDialog';
22
export type { ApprovalRequest } from './MetaToolApprovalDialog';
3+
export { MetaToolGrantsPanel } from './MetaToolGrantsPanel';
4+
export { MetaToolAuditLog } from './MetaToolAuditLog';

0 commit comments

Comments
 (0)