Skip to content

Commit e48885c

Browse files
committed
feat(cursor-bridge): Phase 2 — Desktop UI generator
Autonomous decisions: - Split config builder into cursor-bridge-config.helpers.ts — keeps section component under 200 lines and testable without UI - Show CursorBridgeSection on all Clients page states — not only empty onboarding, since existing users need the global bridge path too Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 1b970f9 commit e48885c

4 files changed

Lines changed: 220 additions & 2 deletions

File tree

apps/desktop/src/features/clients/ClientsPage.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
} from '@/lib/api/gateway';
4242
import { RegisterApiKeyClientModal } from './RegisterApiKeyClientModal';
4343
import { ClientApiKeysSection } from './ClientApiKeysSection';
44+
import { CursorBridgeSection } from './CursorBridgeSection';
4445
import {
4546
isStarterFeatureSet,
4647
listFeatureSetsBySpace,
@@ -331,7 +332,13 @@ export default function ClientsPage() {
331332
)}
332333

333334
<div className="flex-1 overflow-auto px-8 py-8">
334-
<div className="mx-auto max-w-[2000px]">
335+
<div className="mx-auto max-w-[2000px] space-y-8">
336+
<CursorBridgeSection
337+
gatewayUrl={gatewayStatus.url || 'http://localhost:45818'}
338+
gatewayRunning={gatewayStatus.running}
339+
onRegistered={() => void refreshClients()}
340+
/>
341+
335342
{isLoading ? (
336343
<div className="flex h-64 items-center justify-center">
337344
<Loader2 className="text-primary-500 h-8 w-8 animate-spin" />
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/**
2+
* Global Cursor setup via mcp-remote — one `~/.cursor/mcp.json` entry for all repos.
3+
*/
4+
5+
import { useState } from 'react';
6+
import { useTranslation } from 'react-i18next';
7+
import { AlertTriangle, Check, Copy, KeyRound, Loader2 } from 'lucide-react';
8+
import { Button, Card, CardContent } from '@mcpmux/ui';
9+
import { registerApiKeyClient } from '@/lib/api/gateway';
10+
import cursorIcon from '@/assets/client-icons/cursor.svg';
11+
import {
12+
buildCursorBridgeMcpJson,
13+
CURSOR_BRIDGE_CLIENT_NAME,
14+
} from './cursor-bridge-config.helpers';
15+
16+
interface CursorBridgeSectionProps {
17+
gatewayUrl: string;
18+
gatewayRunning: boolean;
19+
onRegistered?: () => void;
20+
}
21+
22+
/**
23+
* Mint an API key and render a ready-to-paste global Cursor bridge config.
24+
*/
25+
export function CursorBridgeSection({
26+
gatewayUrl,
27+
gatewayRunning,
28+
onRegistered,
29+
}: CursorBridgeSectionProps) {
30+
const { t } = useTranslation('clients');
31+
const [snippet, setSnippet] = useState<string | null>(null);
32+
const [isGenerating, setIsGenerating] = useState(false);
33+
const [error, setError] = useState<string | null>(null);
34+
const [copied, setCopied] = useState(false);
35+
36+
const handleGenerate = async () => {
37+
setIsGenerating(true);
38+
setError(null);
39+
try {
40+
const client = await registerApiKeyClient(CURSOR_BRIDGE_CLIENT_NAME, null);
41+
setSnippet(buildCursorBridgeMcpJson(client.apiKey, gatewayUrl));
42+
onRegistered?.();
43+
} catch (e) {
44+
setError(e instanceof Error ? e.message : String(e));
45+
} finally {
46+
setIsGenerating(false);
47+
}
48+
};
49+
50+
const handleCopy = async () => {
51+
if (!snippet) return;
52+
try {
53+
await navigator.clipboard.writeText(snippet);
54+
setCopied(true);
55+
setTimeout(() => setCopied(false), 2000);
56+
} catch {
57+
// Snippet is selectable as a fallback.
58+
}
59+
};
60+
61+
return (
62+
<Card data-testid="cursor-bridge-section">
63+
<CardContent className="p-6">
64+
<div className="mb-4 flex items-start gap-4">
65+
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl border border-[rgb(var(--border-subtle))] bg-[rgb(var(--surface))] p-2">
66+
<img src={cursorIcon} alt="Cursor" className="h-full w-full object-contain" />
67+
</div>
68+
<div className="min-w-0 flex-1">
69+
<h2 className="text-lg font-semibold">{t('cursorBridge.title')}</h2>
70+
<p className="mt-1 text-sm text-[rgb(var(--muted))]">{t('cursorBridge.description')}</p>
71+
</div>
72+
</div>
73+
74+
{!gatewayRunning && (
75+
<div className="mb-4 flex items-start gap-2 rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs dark:border-amber-700/60 dark:bg-amber-900/20">
76+
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-amber-600 dark:text-amber-400" />
77+
<p className="text-amber-800 dark:text-amber-200">{t('cursorBridge.gatewayStopped')}</p>
78+
</div>
79+
)}
80+
81+
{snippet ? (
82+
<div className="space-y-4">
83+
<div>
84+
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-[rgb(var(--muted))]">
85+
{t('cursorBridge.pasteInto')}
86+
</p>
87+
<pre
88+
data-testid="cursor-bridge-snippet"
89+
className="max-h-72 overflow-auto rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--surface))] p-3 font-mono text-xs"
90+
>
91+
{snippet}
92+
</pre>
93+
</div>
94+
95+
<div className="flex items-start gap-3 rounded-xl border border-amber-300 bg-amber-50 p-3.5 dark:border-amber-700/60 dark:bg-amber-900/20">
96+
<AlertTriangle className="mt-0.5 h-5 w-5 flex-shrink-0 text-amber-600 dark:text-amber-400" />
97+
<p className="text-sm text-amber-800 dark:text-amber-200">
98+
{t('cursorBridge.keyOnceWarning')}
99+
</p>
100+
</div>
101+
102+
<div className="flex flex-wrap gap-2">
103+
<Button variant="primary" size="md" onClick={handleCopy} data-testid="cursor-bridge-copy">
104+
{copied ? (
105+
<>
106+
<Check className="mr-2 h-4 w-4 text-emerald-500" />
107+
{t('cursorBridge.copied')}
108+
</>
109+
) : (
110+
<>
111+
<Copy className="mr-2 h-4 w-4" />
112+
{t('cursorBridge.copy')}
113+
</>
114+
)}
115+
</Button>
116+
<Button
117+
variant="secondary"
118+
size="md"
119+
onClick={() => void handleGenerate()}
120+
disabled={isGenerating || !gatewayRunning}
121+
data-testid="cursor-bridge-regenerate"
122+
>
123+
{isGenerating ? (
124+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
125+
) : (
126+
<KeyRound className="mr-2 h-4 w-4" />
127+
)}
128+
{t('cursorBridge.regenerate')}
129+
</Button>
130+
</div>
131+
132+
<p className="text-xs text-[rgb(var(--muted))]">{t('cursorBridge.fallbackNote')}</p>
133+
</div>
134+
) : (
135+
<div className="space-y-3">
136+
<p className="text-sm text-[rgb(var(--muted))]">{t('cursorBridge.generateHint')}</p>
137+
{error && (
138+
<p className="text-sm text-red-600 dark:text-red-400" data-testid="cursor-bridge-error">
139+
{error}
140+
</p>
141+
)}
142+
<Button
143+
variant="primary"
144+
size="md"
145+
onClick={() => void handleGenerate()}
146+
disabled={isGenerating || !gatewayRunning}
147+
data-testid="cursor-bridge-generate"
148+
>
149+
{isGenerating ? (
150+
<>
151+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
152+
{t('cursorBridge.generating')}
153+
</>
154+
) : (
155+
<>
156+
<KeyRound className="mr-2 h-4 w-4" />
157+
{t('cursorBridge.generate')}
158+
</>
159+
)}
160+
</Button>
161+
</div>
162+
)}
163+
</CardContent>
164+
</Card>
165+
);
166+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/** Default API-key client name for the global Cursor mcp-remote bridge. */
2+
export const CURSOR_BRIDGE_CLIENT_NAME = 'cursor-global-bridge';
3+
4+
/**
5+
* Build the `~/.cursor/mcp.json` snippet for the global mcp-remote bridge.
6+
*
7+
* Cursor resolves `${workspaceFolder}` in `args` at spawn time, so one global
8+
* entry routes each window to the correct workspace header.
9+
*/
10+
export function buildCursorBridgeMcpJson(apiKey: string, gatewayUrl: string): string {
11+
const mcpUrl = `${gatewayUrl.replace(/\/$/, '')}/mcp`;
12+
const config = {
13+
mcpServers: {
14+
mcpmux: {
15+
command: 'npx',
16+
args: [
17+
'-y',
18+
'mcp-remote',
19+
mcpUrl,
20+
'--allow-http',
21+
'--header',
22+
'X-Mcpmux-Workspace:${workspaceFolder}',
23+
'--header',
24+
'Authorization:Bearer ${MCPMUX_API_KEY}',
25+
],
26+
env: { MCPMUX_API_KEY: apiKey },
27+
},
28+
},
29+
};
30+
return JSON.stringify(config, null, 2);
31+
}

apps/desktop/src/locales/en/clients.json

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,5 +134,19 @@
134134
"profileIncomplete": "Selected machine is missing icon or hostname. Complete it in Settings first."
135135
}
136136
},
137-
"fallbackClientName": "Client"
137+
"fallbackClientName": "Client",
138+
"cursorBridge": {
139+
"title": "Global Cursor setup (no per-repo files)",
140+
"description": "Paste one config into ~/.cursor/mcp.json. Each Cursor window sends its own workspace folder via mcp-remote — no per-repo .cursor/mcp.json files needed.",
141+
"gatewayStopped": "Start the gateway from the Dashboard before generating a config.",
142+
"generateHint": "Creates a dedicated API-key client and builds the full ~/.cursor/mcp.json snippet with the key embedded.",
143+
"generate": "Generate global config",
144+
"generating": "Generating…",
145+
"regenerate": "Generate new key",
146+
"pasteInto": "Paste into ~/.cursor/mcp.json",
147+
"copy": "Copy config",
148+
"copied": "Copied",
149+
"keyOnceWarning": "The API key is shown only in this snippet. Store it in ~/.cursor/mcp.json now — McpMux cannot display it again. Regenerating creates a new key.",
150+
"fallbackNote": "Per-repo install via Workspaces remains available if you prefer not to use npx/mcp-remote."
151+
}
138152
}

0 commit comments

Comments
 (0)