Skip to content

Commit ee995b6

Browse files
committed
feat(ui): per-workspace install panel + disable-auth toggle
Frontend for the per-workspace MCP install: - WorkspaceInstallPanel: a "Connect apps to this folder" section in the Workspaces inspector. Lists the supported clients (Cursor, Claude Code, VS Code/Copilot, opencode, Zed), copies a per-client snippet, and writes the configs into the folder with this folder's path as the X-Mcpmux-Workspace header. Self-introductory: shows the system-wide auth state and offers an inline "Disable authentication" action, since turning it off makes the written config a pure URL + header. - Settings → Security: a "Disable authentication" toggle. - workspaceInstall API wrappers for the new commands. Tested (vitest): lists clients, installs the default-selected clients with the gateway /mcp url, flips auth inline, copies a snippet, and blocks install until the gateway is running. Claude-Session: https://claude.ai/code/session_01Baan9JmzR43uxxRUh7CAMF Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent 72f310f commit ee995b6

5 files changed

Lines changed: 564 additions & 0 deletions

File tree

apps/desktop/src/features/settings/SettingsPage.tsx

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
Network,
3232
RotateCcw,
3333
AlertCircle,
34+
ShieldOff,
3435
} from 'lucide-react';
3536
import { useAppStore, useTheme, useAnalyticsEnabled } from '@/stores';
3637
import { UpdateChecker } from './UpdateChecker';
@@ -77,6 +78,11 @@ export function SettingsPage() {
7778
const [mappingPromptEnabled, setMappingPromptEnabled] = useState(true);
7879
const [savingMappingPrompt, setSavingMappingPrompt] = useState(false);
7980

81+
// System-wide inbound auth toggle. When disabled, local apps connect to the
82+
// gateway with no access key — used by the one-click per-workspace install.
83+
const [authDisabled, setAuthDisabled] = useState(false);
84+
const [savingAuthDisabled, setSavingAuthDisabled] = useState(false);
85+
8086
// Meta-tools master switch — gates the entire `mcpmux_*` namespace.
8187

8288
// Gateway port — persisted user override, the default the app ships
@@ -245,6 +251,34 @@ export function SettingsPage() {
245251
}
246252
};
247253

254+
// Load the system-wide inbound-auth toggle on mount.
255+
useEffect(() => {
256+
invoke<boolean>('get_gateway_auth_disabled')
257+
.then(setAuthDisabled)
258+
.catch((err) => console.error('Failed to load auth setting:', err));
259+
}, []);
260+
261+
const updateAuthDisabled = async (disabled: boolean) => {
262+
const prev = authDisabled;
263+
setAuthDisabled(disabled);
264+
setSavingAuthDisabled(true);
265+
try {
266+
await invoke('set_gateway_auth_disabled', { disabled });
267+
success(
268+
'Settings saved',
269+
disabled
270+
? 'Authentication is off — local apps can connect with no access key.'
271+
: 'Authentication is required again for inbound connections.'
272+
);
273+
} catch (err) {
274+
const msg = err instanceof Error ? err.message : 'Unknown error';
275+
error('Failed to save setting', msg);
276+
setAuthDisabled(prev);
277+
} finally {
278+
setSavingAuthDisabled(false);
279+
}
280+
};
281+
248282
// Save startup settings when they change
249283
const updateStartupSetting = async (key: keyof StartupSettings, value: boolean) => {
250284
console.log(`[Settings] Updating ${key} to ${value}`);
@@ -591,6 +625,41 @@ export function SettingsPage() {
591625
</CardContent>
592626
</Card>
593627

628+
{/* Security Section */}
629+
<Card data-testid="settings-security-section">
630+
<CardHeader>
631+
<CardTitle className="flex items-center gap-2">
632+
<ShieldOff className="h-5 w-5" />
633+
Security
634+
</CardTitle>
635+
<CardDescription>
636+
How McpMux authenticates apps connecting to the local gateway.
637+
</CardDescription>
638+
</CardHeader>
639+
<CardContent>
640+
<div className="flex items-center justify-between gap-4">
641+
<div className="flex min-w-0 flex-1 items-start gap-3">
642+
<ShieldOff className="mt-0.5 h-5 w-5 flex-shrink-0 text-[rgb(var(--muted))]" />
643+
<div>
644+
<label className="text-sm font-medium">Disable authentication</label>
645+
<p className="mt-1 text-xs text-[rgb(var(--muted))]">
646+
Let local apps connect to the gateway with no access key — just the URL and a
647+
workspace header. Makes one-click per-workspace setup trivial. The gateway only
648+
listens on localhost, but any app on this machine can then reach it. Leave on
649+
unless you want the simplest setup.
650+
</p>
651+
</div>
652+
</div>
653+
<Switch
654+
checked={authDisabled}
655+
onCheckedChange={updateAuthDisabled}
656+
disabled={savingAuthDisabled}
657+
data-testid="disable-auth-switch"
658+
/>
659+
</div>
660+
</CardContent>
661+
</Card>
662+
594663
{/* Appearance Section */}
595664
<Card>
596665
<CardHeader>
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
import { useCallback, useEffect, useState } from 'react';
2+
import { Check, Copy, Download, Loader2, ShieldCheck, ShieldOff, AlertCircle } from 'lucide-react';
3+
import { Button } from '@mcpmux/ui';
4+
import { getGatewayStatus } from '@/lib/api/gateway';
5+
import {
6+
generateWorkspaceConfigSnippet,
7+
getGatewayAuthDisabled,
8+
installWorkspaceMcpConfig,
9+
listWorkspaceInstallClients,
10+
setGatewayAuthDisabled,
11+
type WorkspaceInstallClient,
12+
type WorkspaceInstallResult,
13+
} from '@/lib/api/workspaceInstall';
14+
15+
/** Clients selected by default — the most common three. */
16+
const DEFAULT_SELECTED = ['cursor', 'claude-code', 'vscode'];
17+
18+
/**
19+
* "Connect apps to this folder" — writes (or extends) project-local MCP configs
20+
* inside `workspaceRoot`, injecting `X-Mcpmux-Workspace: <folder path>` so the
21+
* gateway routes those apps to this folder's binding deterministically, even
22+
* when the client doesn't report MCP roots. Also surfaces (and can flip) the
23+
* system-wide auth toggle inline, since disabling it makes the config a pure
24+
* URL + header with no access key.
25+
*/
26+
export function WorkspaceInstallPanel({ workspaceRoot }: { workspaceRoot: string }) {
27+
const [clients, setClients] = useState<WorkspaceInstallClient[]>([]);
28+
const [selected, setSelected] = useState<Set<string>>(() => new Set(DEFAULT_SELECTED));
29+
const [mcpUrl, setMcpUrl] = useState<string | null>(null);
30+
const [authDisabled, setAuthDisabled] = useState<boolean | null>(null);
31+
const [installing, setInstalling] = useState(false);
32+
const [results, setResults] = useState<WorkspaceInstallResult[] | null>(null);
33+
const [copiedId, setCopiedId] = useState<string | null>(null);
34+
const [error, setError] = useState<string | null>(null);
35+
const [togglingAuth, setTogglingAuth] = useState(false);
36+
37+
useEffect(() => {
38+
let cancelled = false;
39+
void (async () => {
40+
try {
41+
const [list, status, disabled] = await Promise.all([
42+
listWorkspaceInstallClients(),
43+
getGatewayStatus().catch(() => ({ running: false, url: null as string | null })),
44+
getGatewayAuthDisabled().catch(() => false),
45+
]);
46+
if (cancelled) return;
47+
setClients(list);
48+
setAuthDisabled(disabled);
49+
setMcpUrl(status.url ? `${status.url}/mcp` : null);
50+
} catch (e) {
51+
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
52+
}
53+
})();
54+
return () => {
55+
cancelled = true;
56+
};
57+
}, []);
58+
59+
const toggleClient = (id: string) => {
60+
setSelected((prev) => {
61+
const next = new Set(prev);
62+
if (next.has(id)) next.delete(id);
63+
else next.add(id);
64+
return next;
65+
});
66+
setResults(null);
67+
};
68+
69+
const handleCopy = useCallback(
70+
async (clientId: string) => {
71+
if (!mcpUrl) return;
72+
try {
73+
const snip = await generateWorkspaceConfigSnippet({
74+
client: clientId,
75+
serverUrl: mcpUrl,
76+
workspaceRoot,
77+
});
78+
await navigator.clipboard.writeText(snip.content);
79+
setCopiedId(clientId);
80+
setTimeout(() => setCopiedId((c) => (c === clientId ? null : c)), 1500);
81+
} catch (e) {
82+
setError(e instanceof Error ? e.message : String(e));
83+
}
84+
},
85+
[mcpUrl, workspaceRoot]
86+
);
87+
88+
const handleInstall = async () => {
89+
if (!mcpUrl || selected.size === 0) return;
90+
setInstalling(true);
91+
setError(null);
92+
setResults(null);
93+
try {
94+
const res = await installWorkspaceMcpConfig({
95+
workspaceRoot,
96+
serverUrl: mcpUrl,
97+
clients: Array.from(selected),
98+
});
99+
setResults(res);
100+
} catch (e) {
101+
setError(e instanceof Error ? e.message : String(e));
102+
} finally {
103+
setInstalling(false);
104+
}
105+
};
106+
107+
const handleDisableAuth = async () => {
108+
setTogglingAuth(true);
109+
try {
110+
const v = await setGatewayAuthDisabled(true);
111+
setAuthDisabled(v);
112+
} catch (e) {
113+
setError(e instanceof Error ? e.message : String(e));
114+
} finally {
115+
setTogglingAuth(false);
116+
}
117+
};
118+
119+
return (
120+
<div className="space-y-4" data-testid="workspace-install-panel">
121+
<p className="text-sm text-[rgb(var(--muted))]">
122+
Drop McpMux into this folder&apos;s MCP config for the apps you use. Each gets a{' '}
123+
<code className="text-xs">X-Mcpmux-Workspace</code> header set to this path, so it routes
124+
here automatically — even apps that don&apos;t report the folder (like Cursor).
125+
</p>
126+
127+
{/* Self-introductory auth nudge — disabling auth makes the written config
128+
a pure URL + header with no access key to manage. */}
129+
{authDisabled === false && (
130+
<div
131+
className="flex items-start gap-2.5 rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs dark:border-amber-800/60 dark:bg-amber-900/20"
132+
data-testid="workspace-install-auth-nudge"
133+
>
134+
<ShieldOff className="mt-0.5 h-4 w-4 flex-shrink-0 text-amber-600 dark:text-amber-400" />
135+
<div className="min-w-0 flex-1">
136+
<p className="font-medium text-amber-800 dark:text-amber-300">
137+
Apps will need an access key to connect.
138+
</p>
139+
<p className="mt-0.5 text-amber-700 dark:text-amber-400">
140+
For zero-config setup, disable system-wide authentication — apps then connect with
141+
just the URL and this workspace header.
142+
</p>
143+
<Button
144+
variant="secondary"
145+
size="sm"
146+
className="mt-2 h-7 text-xs"
147+
disabled={togglingAuth}
148+
onClick={handleDisableAuth}
149+
data-testid="workspace-install-disable-auth"
150+
>
151+
{togglingAuth ? (
152+
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
153+
) : (
154+
<ShieldOff className="mr-1.5 h-3 w-3" />
155+
)}
156+
Disable authentication
157+
</Button>
158+
</div>
159+
</div>
160+
)}
161+
{authDisabled === true && (
162+
<div className="flex items-center gap-2 rounded-lg border border-emerald-200 bg-emerald-50 p-2.5 text-xs text-emerald-700 dark:border-emerald-800/60 dark:bg-emerald-900/20 dark:text-emerald-300">
163+
<ShieldCheck className="h-4 w-4 flex-shrink-0" />
164+
Authentication is off — apps connect with just the URL and workspace header.
165+
</div>
166+
)}
167+
168+
{/* Client checklist with per-row copy. */}
169+
<div className="overflow-hidden rounded-lg border border-[rgb(var(--border))]">
170+
{clients.map((c, i) => {
171+
const checked = selected.has(c.id);
172+
return (
173+
<label
174+
key={c.id}
175+
className={`flex cursor-pointer items-center gap-3 px-3 py-2.5 transition-colors hover:bg-[rgb(var(--surface-hover))] ${
176+
i > 0 ? 'border-t border-[rgb(var(--border-subtle))]' : ''
177+
}`}
178+
data-testid={`workspace-install-client-${c.id}`}
179+
>
180+
<input
181+
type="checkbox"
182+
checked={checked}
183+
onChange={() => toggleClient(c.id)}
184+
className="h-4 w-4 flex-shrink-0 accent-primary-500"
185+
/>
186+
<div className="min-w-0 flex-1">
187+
<div className="text-sm font-medium text-[rgb(var(--foreground))]">{c.label}</div>
188+
<div className="truncate font-mono text-[11px] text-[rgb(var(--muted))]">
189+
{c.config_path}
190+
</div>
191+
</div>
192+
<button
193+
type="button"
194+
title="Copy this client's config"
195+
disabled={!mcpUrl}
196+
onClick={(e) => {
197+
e.preventDefault();
198+
void handleCopy(c.id);
199+
}}
200+
className="flex-shrink-0 rounded-md p-1.5 text-[rgb(var(--muted))] transition-colors hover:bg-[rgb(var(--surface))] hover:text-[rgb(var(--foreground))] disabled:opacity-40"
201+
data-testid={`workspace-install-copy-${c.id}`}
202+
>
203+
{copiedId === c.id ? (
204+
<Check className="h-3.5 w-3.5 text-green-600" />
205+
) : (
206+
<Copy className="h-3.5 w-3.5" />
207+
)}
208+
</button>
209+
</label>
210+
);
211+
})}
212+
</div>
213+
214+
{error && (
215+
<div className="flex items-start gap-2 rounded-lg border border-red-200 bg-red-50 p-2.5 text-xs text-red-600 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400">
216+
<AlertCircle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
217+
<span>{error}</span>
218+
</div>
219+
)}
220+
221+
{results && (
222+
<div className="space-y-1.5" data-testid="workspace-install-results">
223+
{results.map((r) => (
224+
<div
225+
key={r.client}
226+
className="flex items-center gap-2 rounded-md border border-[rgb(var(--border-subtle))] bg-[rgb(var(--surface))] px-2.5 py-1.5 text-xs"
227+
>
228+
{r.action === 'error' ? (
229+
<AlertCircle className="h-3.5 w-3.5 flex-shrink-0 text-red-500" />
230+
) : (
231+
<Check className="h-3.5 w-3.5 flex-shrink-0 text-green-600" />
232+
)}
233+
<span className="font-medium">{r.label}</span>
234+
<span className="text-[rgb(var(--muted))]">
235+
{r.action === 'error' ? r.error : `${r.action} ${r.path}`}
236+
</span>
237+
</div>
238+
))}
239+
</div>
240+
)}
241+
242+
<Button
243+
variant="primary"
244+
size="sm"
245+
className="w-full"
246+
disabled={installing || selected.size === 0 || !mcpUrl}
247+
onClick={handleInstall}
248+
data-testid="workspace-install-button"
249+
>
250+
{installing ? (
251+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
252+
) : (
253+
<Download className="mr-2 h-4 w-4" />
254+
)}
255+
{mcpUrl
256+
? `Install into ${selected.size} app${selected.size === 1 ? '' : 's'}`
257+
: 'Start the gateway to install'}
258+
</Button>
259+
</div>
260+
);
261+
}

apps/desktop/src/features/workspaces/WorkspacesPage.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
listFeatureSets,
5151
type FeatureSet,
5252
} from '@/lib/api/featureSets';
53+
import { WorkspaceInstallPanel } from './WorkspaceInstallPanel';
5354
import { useSpaces } from '@/stores';
5455
import type { Space } from '@/lib/api/spaces';
5556

@@ -1017,6 +1018,19 @@ function InspectorPanel({
10171018
/>
10181019
</CollapsibleSection>
10191020

1021+
{entry && !isNew && (
1022+
<CollapsibleSection
1023+
icon={<Wrench className="h-5 w-5" />}
1024+
tone="primary"
1025+
title="Connect apps to this folder"
1026+
subtitle="Write the McpMux config into this folder for the apps you use, with this folder's workspace header."
1027+
defaultOpen={!isMapped}
1028+
testId="workspace-install-section"
1029+
>
1030+
<WorkspaceInstallPanel workspaceRoot={entry.root} />
1031+
</CollapsibleSection>
1032+
)}
1033+
10201034
{entry && !isNew && (
10211035
<CollapsibleSection
10221036
icon={<Layers className="h-5 w-5" />}

0 commit comments

Comments
 (0)