Skip to content

Commit bfe5e17

Browse files
committed
feat: add MCP server management to Settings (Option A)
- New MCP tab in Settings panel (desktop + mobile) - Add/remove/toggle MCP servers (stdio or HTTP) - Configure server name, command/URL, args, env vars - LocalStorage persistence (knot-code:mcp:servers) - Gateway integration stubs ready for RPC wiring - Glassmorphic UI matching existing design - New files: lib/mcp/{types,storage,gateway}.ts, components/mcp-settings.tsx
1 parent 9b571ca commit bfe5e17

File tree

5 files changed

+499
-1
lines changed

5 files changed

+499
-1
lines changed

components/mcp-settings.tsx

Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
'use client'
2+
3+
import { useState, useEffect, useCallback } from 'react'
4+
import { Icon } from '@iconify/react'
5+
import type { McpServerConfig, McpServerType } from '@/lib/mcp/types'
6+
import {
7+
listMcpServers,
8+
addMcpServer,
9+
removeMcpServer,
10+
toggleMcpServer,
11+
syncMcpServers,
12+
} from '@/lib/mcp/gateway'
13+
14+
/**
15+
* MCP Settings Component
16+
* Allows users to configure MCP (Model Context Protocol) servers
17+
*/
18+
export function McpSettings() {
19+
const [servers, setServers] = useState<McpServerConfig[]>([])
20+
const [showAddForm, setShowAddForm] = useState(false)
21+
const [syncing, setSyncing] = useState(false)
22+
const [formData, setFormData] = useState<{
23+
name: string
24+
type: McpServerType
25+
command: string
26+
url: string
27+
args: string
28+
env: string
29+
}>({
30+
name: '',
31+
type: 'stdio',
32+
command: '',
33+
url: '',
34+
args: '',
35+
env: '',
36+
})
37+
38+
// Load servers on mount
39+
useEffect(() => {
40+
listMcpServers().then(setServers)
41+
}, [])
42+
43+
const handleSync = useCallback(async () => {
44+
setSyncing(true)
45+
try {
46+
await syncMcpServers()
47+
const updated = await listMcpServers()
48+
setServers(updated)
49+
} catch (err) {
50+
console.error('Failed to sync MCP servers:', err)
51+
} finally {
52+
setSyncing(false)
53+
}
54+
}, [])
55+
56+
const handleToggle = useCallback(async (id: string) => {
57+
try {
58+
const updated = await toggleMcpServer(id)
59+
setServers(updated)
60+
} catch (err) {
61+
console.error('Failed to toggle MCP server:', err)
62+
}
63+
}, [])
64+
65+
const handleRemove = useCallback(async (id: string) => {
66+
try {
67+
const updated = await removeMcpServer(id)
68+
setServers(updated)
69+
} catch (err) {
70+
console.error('Failed to remove MCP server:', err)
71+
}
72+
}, [])
73+
74+
const handleAdd = useCallback(async () => {
75+
if (!formData.name.trim()) return
76+
77+
try {
78+
const config: McpServerConfig = {
79+
id: `mcp-${Date.now()}`,
80+
name: formData.name.trim(),
81+
type: formData.type,
82+
enabled: true,
83+
}
84+
85+
if (formData.type === 'stdio') {
86+
config.command = formData.command.trim()
87+
if (formData.args.trim()) {
88+
config.args = formData.args.split(',').map((a) => a.trim())
89+
}
90+
} else {
91+
config.url = formData.url.trim()
92+
}
93+
94+
if (formData.env.trim()) {
95+
try {
96+
config.env = JSON.parse(formData.env)
97+
} catch {
98+
// Ignore invalid JSON
99+
}
100+
}
101+
102+
const updated = await addMcpServer(config)
103+
setServers(updated)
104+
setFormData({
105+
name: '',
106+
type: 'stdio',
107+
command: '',
108+
url: '',
109+
args: '',
110+
env: '',
111+
})
112+
setShowAddForm(false)
113+
} catch (err) {
114+
console.error('Failed to add MCP server:', err)
115+
}
116+
}, [formData])
117+
118+
return (
119+
<div className="space-y-5">
120+
{/* MCP Servers Section */}
121+
<section className="rounded-[24px] border border-[var(--glass-border)] bg-[color-mix(in_srgb,var(--bg-elevated)_88%,transparent)] p-4 shadow-[var(--shadow-sm)]">
122+
<div className="mb-4 flex items-center gap-2">
123+
<span className="flex h-8 w-8 items-center justify-center rounded-2xl bg-[color-mix(in_srgb,var(--brand)_12%,transparent)] text-[var(--brand)]">
124+
<Icon icon="lucide:plug" width={14} />
125+
</span>
126+
<div>
127+
<h3 className="text-sm font-medium text-[var(--text-primary)]">MCP Servers</h3>
128+
<p className="text-[11px] text-[var(--text-secondary)]">
129+
Configure Model Context Protocol servers for enhanced AI capabilities.
130+
</p>
131+
</div>
132+
</div>
133+
134+
{/* Server List */}
135+
{servers.length > 0 && (
136+
<div className="mb-4 space-y-2">
137+
{servers.map((server) => (
138+
<div
139+
key={server.id}
140+
className="flex items-center gap-3 rounded-xl border border-[var(--border)] bg-[color-mix(in_srgb,var(--bg)_92%,transparent)] px-3 py-2.5 transition hover:bg-[color-mix(in_srgb,var(--text-primary)_4%,transparent)]"
141+
>
142+
<button
143+
onClick={() => handleToggle(server.id)}
144+
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full transition cursor-pointer"
145+
title={server.enabled ? 'Disable' : 'Enable'}
146+
>
147+
<span
148+
className={`h-2 w-2 rounded-full ${server.enabled ? 'bg-emerald-400' : 'bg-[var(--text-disabled)]'}`}
149+
/>
150+
</button>
151+
152+
<div className="min-w-0 flex-1">
153+
<div className="flex items-center gap-2">
154+
<p className="text-[13px] font-medium text-[var(--text-primary)]">
155+
{server.name}
156+
</p>
157+
<span className="rounded-full border border-[var(--border)] bg-[color-mix(in_srgb,var(--bg)_88%,transparent)] px-1.5 py-0.5 text-[9px] font-medium text-[var(--text-disabled)]">
158+
{server.type}
159+
</span>
160+
</div>
161+
<p className="mt-0.5 truncate text-[11px] text-[var(--text-secondary)]">
162+
{server.type === 'stdio' ? server.command : server.url}
163+
</p>
164+
</div>
165+
166+
<button
167+
onClick={() => handleRemove(server.id)}
168+
className="shrink-0 text-[var(--text-disabled)] transition hover:text-[var(--color-deletions)] cursor-pointer"
169+
title="Remove server"
170+
>
171+
<Icon icon="lucide:trash-2" width={14} />
172+
</button>
173+
</div>
174+
))}
175+
</div>
176+
)}
177+
178+
{/* Empty State */}
179+
{servers.length === 0 && !showAddForm && (
180+
<div className="mb-4 rounded-xl border border-dashed border-[var(--border)] bg-[color-mix(in_srgb,var(--bg)_88%,transparent)] px-4 py-8 text-center">
181+
<Icon
182+
icon="lucide:plug"
183+
width={32}
184+
height={32}
185+
className="mx-auto mb-2 text-[var(--text-disabled)]"
186+
/>
187+
<p className="text-[12px] text-[var(--text-secondary)]">No MCP servers configured</p>
188+
<p className="mt-1 text-[11px] text-[var(--text-disabled)]">
189+
Add a server to get started
190+
</p>
191+
</div>
192+
)}
193+
194+
{/* Add Form */}
195+
{showAddForm && (
196+
<div className="mb-4 space-y-3 rounded-xl border border-[var(--border)] bg-[color-mix(in_srgb,var(--bg)_92%,transparent)] p-3">
197+
<div>
198+
<label className="mb-1.5 block text-[11px] font-medium text-[var(--text-secondary)]">
199+
Server Name
200+
</label>
201+
<input
202+
type="text"
203+
value={formData.name}
204+
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
205+
placeholder="My MCP Server"
206+
className="w-full rounded-lg border border-[var(--border)] bg-[color-mix(in_srgb,var(--bg)_80%,transparent)] px-3 py-2 text-[13px] text-[var(--text-primary)] placeholder:text-[var(--text-disabled)] outline-none transition focus:border-[var(--brand)]"
207+
/>
208+
</div>
209+
210+
<div>
211+
<label className="mb-1.5 block text-[11px] font-medium text-[var(--text-secondary)]">
212+
Server Type
213+
</label>
214+
<div className="grid grid-cols-2 gap-2">
215+
{(['stdio', 'http'] as const).map((type) => (
216+
<button
217+
key={type}
218+
onClick={() => setFormData({ ...formData, type })}
219+
className={`rounded-lg border px-3 py-2 text-[12px] font-medium transition cursor-pointer ${
220+
formData.type === type
221+
? 'border-[var(--brand)] bg-[color-mix(in_srgb,var(--brand)_10%,transparent)] text-[var(--brand)]'
222+
: 'border-[var(--border)] bg-[color-mix(in_srgb,var(--bg)_72%,transparent)] text-[var(--text-secondary)] hover:bg-[color-mix(in_srgb,var(--text-primary)_4%,transparent)]'
223+
}`}
224+
>
225+
{type.toUpperCase()}
226+
</button>
227+
))}
228+
</div>
229+
</div>
230+
231+
{formData.type === 'stdio' ? (
232+
<>
233+
<div>
234+
<label className="mb-1.5 block text-[11px] font-medium text-[var(--text-secondary)]">
235+
Command
236+
</label>
237+
<input
238+
type="text"
239+
value={formData.command}
240+
onChange={(e) => setFormData({ ...formData, command: e.target.value })}
241+
placeholder="npx @modelcontextprotocol/server-example"
242+
className="w-full rounded-lg border border-[var(--border)] bg-[color-mix(in_srgb,var(--bg)_80%,transparent)] px-3 py-2 font-mono text-[12px] text-[var(--text-primary)] placeholder:text-[var(--text-disabled)] outline-none transition focus:border-[var(--brand)]"
243+
/>
244+
</div>
245+
<div>
246+
<label className="mb-1.5 block text-[11px] font-medium text-[var(--text-secondary)]">
247+
Arguments (comma-separated)
248+
</label>
249+
<input
250+
type="text"
251+
value={formData.args}
252+
onChange={(e) => setFormData({ ...formData, args: e.target.value })}
253+
placeholder="--port, 3000"
254+
className="w-full rounded-lg border border-[var(--border)] bg-[color-mix(in_srgb,var(--bg)_80%,transparent)] px-3 py-2 font-mono text-[12px] text-[var(--text-primary)] placeholder:text-[var(--text-disabled)] outline-none transition focus:border-[var(--brand)]"
255+
/>
256+
</div>
257+
</>
258+
) : (
259+
<div>
260+
<label className="mb-1.5 block text-[11px] font-medium text-[var(--text-secondary)]">
261+
URL
262+
</label>
263+
<input
264+
type="text"
265+
value={formData.url}
266+
onChange={(e) => setFormData({ ...formData, url: e.target.value })}
267+
placeholder="http://localhost:3000"
268+
className="w-full rounded-lg border border-[var(--border)] bg-[color-mix(in_srgb,var(--bg)_80%,transparent)] px-3 py-2 font-mono text-[12px] text-[var(--text-primary)] placeholder:text-[var(--text-disabled)] outline-none transition focus:border-[var(--brand)]"
269+
/>
270+
</div>
271+
)}
272+
273+
<div>
274+
<label className="mb-1.5 block text-[11px] font-medium text-[var(--text-secondary)]">
275+
Environment Variables (JSON)
276+
</label>
277+
<textarea
278+
value={formData.env}
279+
onChange={(e) => setFormData({ ...formData, env: e.target.value })}
280+
placeholder='{"API_KEY": "..."}'
281+
rows={2}
282+
className="w-full resize-none rounded-lg border border-[var(--border)] bg-[color-mix(in_srgb,var(--bg)_80%,transparent)] px-3 py-2 font-mono text-[11px] text-[var(--text-primary)] placeholder:text-[var(--text-disabled)] outline-none transition focus:border-[var(--brand)]"
283+
/>
284+
</div>
285+
286+
<div className="flex gap-2">
287+
<button
288+
onClick={handleAdd}
289+
disabled={!formData.name.trim()}
290+
className="flex-1 rounded-lg bg-[var(--brand)] px-3 py-2 text-[12px] font-medium text-[var(--brand-contrast,#fff)] transition hover:opacity-90 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
291+
>
292+
Add Server
293+
</button>
294+
<button
295+
onClick={() => setShowAddForm(false)}
296+
className="rounded-lg border border-[var(--border)] bg-[color-mix(in_srgb,var(--bg)_92%,transparent)] px-3 py-2 text-[12px] font-medium text-[var(--text-secondary)] transition hover:bg-[color-mix(in_srgb,var(--text-primary)_4%,transparent)] cursor-pointer"
297+
>
298+
Cancel
299+
</button>
300+
</div>
301+
</div>
302+
)}
303+
304+
{/* Action Buttons */}
305+
<div className="flex gap-2">
306+
{!showAddForm && (
307+
<button
308+
onClick={() => setShowAddForm(true)}
309+
className="flex items-center gap-2 rounded-lg border border-[var(--border)] bg-[color-mix(in_srgb,var(--bg)_92%,transparent)] px-3 py-2 text-[12px] font-medium text-[var(--text-primary)] transition hover:bg-[color-mix(in_srgb,var(--text-primary)_5%,transparent)] cursor-pointer"
310+
>
311+
<Icon icon="lucide:plus" width={14} />
312+
Add Server
313+
</button>
314+
)}
315+
<button
316+
onClick={handleSync}
317+
disabled={syncing}
318+
className="flex items-center gap-2 rounded-lg border border-[var(--border)] bg-[color-mix(in_srgb,var(--bg)_92%,transparent)] px-3 py-2 text-[12px] font-medium text-[var(--text-primary)] transition hover:bg-[color-mix(in_srgb,var(--text-primary)_5%,transparent)] cursor-pointer disabled:opacity-50"
319+
>
320+
<Icon
321+
icon={syncing ? 'lucide:loader-2' : 'lucide:refresh-cw'}
322+
width={14}
323+
className={syncing ? 'animate-spin' : ''}
324+
/>
325+
{syncing ? 'Syncing...' : 'Sync to Gateway'}
326+
</button>
327+
</div>
328+
</section>
329+
</div>
330+
)
331+
}

components/settings-panel.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@ import {
3333
APPROVAL_TIERS,
3434
type ApprovalTier,
3535
} from '@/lib/agent-session'
36+
import { McpSettings } from './mcp-settings'
3637

37-
type SettingsTab = 'connect' | 'general'
38+
type SettingsTab = 'connect' | 'general' | 'mcp'
3839

3940
const APPEARANCE_MODES: Array<{ id: ThemeMode; label: string; icon: string }> = [
4041
{ id: 'dark', label: 'Dark', icon: 'lucide:moon-star' },
@@ -285,6 +286,7 @@ export function SettingsPanel({
285286
label: 'General',
286287
icon: 'lucide:sliders-horizontal',
287288
},
289+
{ id: 'mcp' as SettingsTab, label: 'MCP', icon: 'lucide:plug' },
288290
].map((item) => {
289291
const active = tab === item.id
290292
return (
@@ -847,6 +849,8 @@ export function SettingsPanel({
847849
</section>
848850
</div>
849851
)}
852+
853+
{tab === 'mcp' && <McpSettings />}
850854
</div>
851855
</motion.div>
852856
</div>
@@ -875,6 +879,7 @@ export function SettingsPanel({
875879
{[
876880
{ id: 'connect' as SettingsTab, label: 'Connect', icon: 'lucide:smartphone' },
877881
{ id: 'general' as SettingsTab, label: 'General', icon: 'lucide:sliders-horizontal' },
882+
{ id: 'mcp' as SettingsTab, label: 'MCP', icon: 'lucide:plug' },
878883
].map((item) => {
879884
const active = tab === item.id
880885
return (
@@ -1105,6 +1110,8 @@ export function SettingsPanel({
11051110
</section>
11061111
</div>
11071112
)}
1113+
1114+
{tab === 'mcp' && <McpSettings />}
11081115
</div>
11091116
</div>
11101117
</div>

0 commit comments

Comments
 (0)