aboutsummaryrefslogtreecommitdiffstats
path: root/renderer/src/components
diff options
context:
space:
mode:
authorClawd <ai@clawd.bot>2026-03-01 08:45:09 -0800
committerClawd <ai@clawd.bot>2026-03-01 08:45:09 -0800
commit12099b4f8cd10002810438bd309e208169960107 (patch)
treeb00e2043b6f66c1569c43c9ae9cad346f8dbdd42 /renderer/src/components
parentd44d0f61e4026da35c0d1a4acb87ba71ed6cd599 (diff)
feat(settings): add MCP server configuration
- Add McpSettings component with add/edit/delete server UI - Support stdio (command + args + env) and sse/http (url + headers) transports - Array builder for args, key-value builder for env vars and headers - Pass mcpServers config to SDK query() calls - Store config as JSON in settings table
Diffstat (limited to 'renderer/src/components')
-rw-r--r--renderer/src/components/SettingsPage.tsx12
-rw-r--r--renderer/src/components/settings/McpSettings.tsx502
2 files changed, 513 insertions, 1 deletions
diff --git a/renderer/src/components/SettingsPage.tsx b/renderer/src/components/SettingsPage.tsx
index d3ff4bf..7d06547 100644
--- a/renderer/src/components/SettingsPage.tsx
+++ b/renderer/src/components/SettingsPage.tsx
@@ -2,8 +2,9 @@ import React, { useState } from "react";
2import { SystemPromptsSettings } from "./settings/SystemPromptsSettings"; 2import { SystemPromptsSettings } from "./settings/SystemPromptsSettings";
3import { GitSettings } from "./settings/GitSettings"; 3import { GitSettings } from "./settings/GitSettings";
4import { ModelSettings } from "./settings/ModelSettings"; 4import { ModelSettings } from "./settings/ModelSettings";
5import { McpSettings } from "./settings/McpSettings";
5 6
6type SettingsSection = "model" | "system-prompts" | "git"; 7type SettingsSection = "model" | "mcp" | "system-prompts" | "git";
7 8
8interface SettingsPageProps { 9interface SettingsPageProps {
9 onClose: () => void; 10 onClose: () => void;
@@ -42,6 +43,14 @@ export function SettingsPage({ onClose }: SettingsPageProps) {
42 </button> 43 </button>
43 <button 44 <button
44 className={`settings-nav-item${ 45 className={`settings-nav-item${
46 activeSection === "mcp" ? " active" : ""
47 }`}
48 onClick={() => setActiveSection("mcp")}
49 >
50 MCP Servers
51 </button>
52 <button
53 className={`settings-nav-item${
45 activeSection === "system-prompts" ? " active" : "" 54 activeSection === "system-prompts" ? " active" : ""
46 }`} 55 }`}
47 onClick={() => setActiveSection("system-prompts")} 56 onClick={() => setActiveSection("system-prompts")}
@@ -61,6 +70,7 @@ export function SettingsPage({ onClose }: SettingsPageProps) {
61 {/* Content */} 70 {/* Content */}
62 <div className="settings-content"> 71 <div className="settings-content">
63 {activeSection === "model" && <ModelSettings />} 72 {activeSection === "model" && <ModelSettings />}
73 {activeSection === "mcp" && <McpSettings />}
64 {activeSection === "system-prompts" && <SystemPromptsSettings />} 74 {activeSection === "system-prompts" && <SystemPromptsSettings />}
65 {activeSection === "git" && <GitSettings />} 75 {activeSection === "git" && <GitSettings />}
66 </div> 76 </div>
diff --git a/renderer/src/components/settings/McpSettings.tsx b/renderer/src/components/settings/McpSettings.tsx
new file mode 100644
index 0000000..7d23fe4
--- /dev/null
+++ b/renderer/src/components/settings/McpSettings.tsx
@@ -0,0 +1,502 @@
1import React, { useState, useEffect } from "react";
2
3const api = window.api;
4
5type McpServerType = "stdio" | "sse" | "http";
6
7interface McpServerConfig {
8 type: McpServerType;
9 command?: string;
10 args?: string[];
11 env?: Record<string, string>;
12 url?: string;
13 headers?: Record<string, string>;
14}
15
16type McpServersConfig = Record<string, McpServerConfig>;
17
18interface EditingServer {
19 originalName: string | null; // null = new server
20 name: string;
21 config: McpServerConfig;
22}
23
24const emptyConfig = (): McpServerConfig => ({
25 type: "stdio",
26 command: "",
27 args: [],
28 env: {},
29});
30
31export function McpSettings() {
32 const [servers, setServers] = useState<McpServersConfig | null>(null);
33 const [editing, setEditing] = useState<EditingServer | null>(null);
34 const [saveStatus, setSaveStatus] = useState<"idle" | "saved" | "error">("idle");
35 const [error, setError] = useState<string | null>(null);
36
37 useEffect(() => {
38 api.getSettings(["mcpServers"]).then((settings) => {
39 try {
40 const parsed = settings["mcpServers"]
41 ? JSON.parse(settings["mcpServers"])
42 : {};
43 setServers(parsed);
44 } catch {
45 setServers({});
46 setError("Failed to parse saved MCP config");
47 }
48 });
49 }, []);
50
51 const saveServers = async (newServers: McpServersConfig) => {
52 try {
53 if (Object.keys(newServers).length === 0) {
54 await api.deleteSetting("mcpServers");
55 } else {
56 await api.setSetting("mcpServers", JSON.stringify(newServers));
57 }
58 setServers(newServers);
59 setSaveStatus("saved");
60 setTimeout(() => setSaveStatus("idle"), 1500);
61 } catch (e) {
62 setSaveStatus("error");
63 setError(String(e));
64 }
65 };
66
67 const handleDelete = async (name: string) => {
68 if (!servers) return;
69 const { [name]: _, ...rest } = servers;
70 await saveServers(rest);
71 };
72
73 const handleSaveEdit = async () => {
74 if (!editing || !servers) return;
75 const { originalName, name, config } = editing;
76
77 if (!name.trim()) {
78 setError("Server name is required");
79 return;
80 }
81
82 // Validate based on type
83 if (config.type === "stdio" && !config.command?.trim()) {
84 setError("Command is required for stdio servers");
85 return;
86 }
87 if ((config.type === "sse" || config.type === "http") && !config.url?.trim()) {
88 setError("URL is required for SSE/HTTP servers");
89 return;
90 }
91
92 const newServers = { ...servers };
93
94 // If renaming, remove old entry
95 if (originalName && originalName !== name) {
96 delete newServers[originalName];
97 }
98
99 // Clean up config based on type
100 const cleanConfig: McpServerConfig = { type: config.type };
101 if (config.type === "stdio") {
102 cleanConfig.command = config.command;
103 if (config.args && config.args.length > 0) {
104 cleanConfig.args = config.args.filter((a) => a.trim());
105 }
106 if (config.env && Object.keys(config.env).length > 0) {
107 cleanConfig.env = config.env;
108 }
109 } else {
110 cleanConfig.url = config.url;
111 if (config.headers && Object.keys(config.headers).length > 0) {
112 cleanConfig.headers = config.headers;
113 }
114 }
115
116 newServers[name.trim()] = cleanConfig;
117 await saveServers(newServers);
118 setEditing(null);
119 setError(null);
120 };
121
122 const handleCancelEdit = () => {
123 setEditing(null);
124 setError(null);
125 };
126
127 if (servers === null) {
128 return (
129 <div style={{ color: "var(--text-secondary)", fontSize: 12 }}>
130 Loading...
131 </div>
132 );
133 }
134
135 const serverNames = Object.keys(servers);
136
137 return (
138 <div>
139 <div className="settings-section-title">MCP Servers</div>
140 <div className="settings-section-desc">
141 Configure Model Context Protocol servers to give Claude access to
142 external data sources like Jira, documentation, or databases. Servers
143 are available in all phases.
144 </div>
145
146 {error && (
147 <div className="mcp-error">{error}</div>
148 )}
149
150 {/* Server List */}
151 {!editing && (
152 <>
153 <div className="mcp-server-list">
154 {serverNames.length === 0 ? (
155 <div className="mcp-empty">No MCP servers configured</div>
156 ) : (
157 serverNames.map((name) => (
158 <div key={name} className="mcp-server-card">
159 <div className="mcp-server-info">
160 <span className="mcp-server-name">{name}</span>
161 <span className="mcp-server-type">{servers[name].type}</span>
162 <span className="mcp-server-detail">
163 {servers[name].type === "stdio"
164 ? servers[name].command
165 : servers[name].url}
166 </span>
167 </div>
168 <div className="mcp-server-actions">
169 <button
170 className="btn-secondary btn-small"
171 onClick={() =>
172 setEditing({
173 originalName: name,
174 name,
175 config: { ...servers[name] },
176 })
177 }
178 >
179 Edit
180 </button>
181 <button
182 className="btn-secondary btn-small btn-danger"
183 onClick={() => handleDelete(name)}
184 >
185 Delete
186 </button>
187 </div>
188 </div>
189 ))
190 )}
191 </div>
192
193 <div className="settings-actions">
194 <button
195 className="btn-primary"
196 onClick={() =>
197 setEditing({
198 originalName: null,
199 name: "",
200 config: emptyConfig(),
201 })
202 }
203 >
204 Add Server
205 </button>
206 {saveStatus === "saved" && (
207 <span className="settings-custom-badge">saved ✓</span>
208 )}
209 </div>
210 </>
211 )}
212
213 {/* Edit Form */}
214 {editing && (
215 <ServerForm
216 editing={editing}
217 setEditing={setEditing}
218 onSave={handleSaveEdit}
219 onCancel={handleCancelEdit}
220 />
221 )}
222 </div>
223 );
224}
225
226interface ServerFormProps {
227 editing: EditingServer;
228 setEditing: React.Dispatch<React.SetStateAction<EditingServer | null>>;
229 onSave: () => void;
230 onCancel: () => void;
231}
232
233function ServerForm({ editing, setEditing, onSave, onCancel }: ServerFormProps) {
234 const { name, config } = editing;
235
236 const updateName = (newName: string) => {
237 setEditing({ ...editing, name: newName });
238 };
239
240 const updateConfig = (updates: Partial<McpServerConfig>) => {
241 setEditing({ ...editing, config: { ...config, ...updates } });
242 };
243
244 const updateArg = (index: number, value: string) => {
245 const newArgs = [...(config.args || [])];
246 newArgs[index] = value;
247 updateConfig({ args: newArgs });
248 };
249
250 const addArg = () => {
251 updateConfig({ args: [...(config.args || []), ""] });
252 };
253
254 const removeArg = (index: number) => {
255 const newArgs = [...(config.args || [])];
256 newArgs.splice(index, 1);
257 updateConfig({ args: newArgs });
258 };
259
260 const updateEnvKey = (oldKey: string, newKey: string) => {
261 const env = { ...config.env };
262 const value = env[oldKey] || "";
263 delete env[oldKey];
264 if (newKey) env[newKey] = value;
265 updateConfig({ env });
266 };
267
268 const updateEnvValue = (key: string, value: string) => {
269 updateConfig({ env: { ...config.env, [key]: value } });
270 };
271
272 const addEnvVar = () => {
273 const env = { ...config.env, "": "" };
274 updateConfig({ env });
275 };
276
277 const removeEnvVar = (key: string) => {
278 const env = { ...config.env };
279 delete env[key];
280 updateConfig({ env });
281 };
282
283 const updateHeaderKey = (oldKey: string, newKey: string) => {
284 const headers = { ...config.headers };
285 const value = headers[oldKey] || "";
286 delete headers[oldKey];
287 if (newKey) headers[newKey] = value;
288 updateConfig({ headers });
289 };
290
291 const updateHeaderValue = (key: string, value: string) => {
292 updateConfig({ headers: { ...config.headers, [key]: value } });
293 };
294
295 const addHeader = () => {
296 const headers = { ...config.headers, "": "" };
297 updateConfig({ headers });
298 };
299
300 const removeHeader = (key: string) => {
301 const headers = { ...config.headers };
302 delete headers[key];
303 updateConfig({ headers });
304 };
305
306 return (
307 <div className="mcp-server-form">
308 <div className="mcp-form-title">
309 {editing.originalName ? `Edit "${editing.originalName}"` : "Add Server"}
310 </div>
311
312 {/* Name */}
313 <div className="mcp-form-field">
314 <label>Name</label>
315 <input
316 type="text"
317 value={name}
318 onChange={(e) => updateName(e.target.value)}
319 placeholder="e.g., jira, godoc"
320 spellCheck={false}
321 />
322 </div>
323
324 {/* Type */}
325 <div className="mcp-form-field">
326 <label>Type</label>
327 <select
328 value={config.type}
329 onChange={(e) =>
330 updateConfig({
331 type: e.target.value as McpServerType,
332 // Reset type-specific fields
333 command: undefined,
334 args: [],
335 env: {},
336 url: undefined,
337 headers: {},
338 })
339 }
340 >
341 <option value="stdio">stdio (local command)</option>
342 <option value="sse">sse (Server-Sent Events)</option>
343 <option value="http">http (HTTP endpoint)</option>
344 </select>
345 </div>
346
347 {/* stdio fields */}
348 {config.type === "stdio" && (
349 <>
350 <div className="mcp-form-field">
351 <label>Command</label>
352 <input
353 type="text"
354 value={config.command || ""}
355 onChange={(e) => updateConfig({ command: e.target.value })}
356 placeholder="e.g., npx, node, python"
357 spellCheck={false}
358 />
359 </div>
360
361 <div className="mcp-form-field">
362 <label>Arguments</label>
363 <div className="mcp-array-builder">
364 {(config.args || []).map((arg, i) => (
365 <div key={i} className="mcp-array-row">
366 <input
367 type="text"
368 value={arg}
369 onChange={(e) => updateArg(i, e.target.value)}
370 placeholder={`Argument ${i + 1}`}
371 spellCheck={false}
372 />
373 <button
374 type="button"
375 className="btn-icon"
376 onClick={() => removeArg(i)}
377 title="Remove"
378 >
379 ×
380 </button>
381 </div>
382 ))}
383 <button
384 type="button"
385 className="btn-secondary btn-small"
386 onClick={addArg}
387 >
388 + Add Argument
389 </button>
390 </div>
391 </div>
392
393 <div className="mcp-form-field">
394 <label>Environment Variables</label>
395 <div className="mcp-kv-builder">
396 {Object.entries(config.env || {}).map(([key, value]) => (
397 <div key={key} className="mcp-kv-row">
398 <input
399 type="text"
400 value={key}
401 onChange={(e) => updateEnvKey(key, e.target.value)}
402 placeholder="KEY"
403 spellCheck={false}
404 />
405 <span className="mcp-kv-sep">=</span>
406 <input
407 type="text"
408 value={value}
409 onChange={(e) => updateEnvValue(key, e.target.value)}
410 placeholder="value"
411 spellCheck={false}
412 />
413 <button
414 type="button"
415 className="btn-icon"
416 onClick={() => removeEnvVar(key)}
417 title="Remove"
418 >
419 ×
420 </button>
421 </div>
422 ))}
423 <button
424 type="button"
425 className="btn-secondary btn-small"
426 onClick={addEnvVar}
427 >
428 + Add Variable
429 </button>
430 </div>
431 </div>
432 </>
433 )}
434
435 {/* sse/http fields */}
436 {(config.type === "sse" || config.type === "http") && (
437 <>
438 <div className="mcp-form-field">
439 <label>URL</label>
440 <input
441 type="text"
442 value={config.url || ""}
443 onChange={(e) => updateConfig({ url: e.target.value })}
444 placeholder="https://..."
445 spellCheck={false}
446 />
447 </div>
448
449 <div className="mcp-form-field">
450 <label>Headers</label>
451 <div className="mcp-kv-builder">
452 {Object.entries(config.headers || {}).map(([key, value]) => (
453 <div key={key} className="mcp-kv-row">
454 <input
455 type="text"
456 value={key}
457 onChange={(e) => updateHeaderKey(key, e.target.value)}
458 placeholder="Header-Name"
459 spellCheck={false}
460 />
461 <span className="mcp-kv-sep">:</span>
462 <input
463 type="text"
464 value={value}
465 onChange={(e) => updateHeaderValue(key, e.target.value)}
466 placeholder="value"
467 spellCheck={false}
468 />
469 <button
470 type="button"
471 className="btn-icon"
472 onClick={() => removeHeader(key)}
473 title="Remove"
474 >
475 ×
476 </button>
477 </div>
478 ))}
479 <button
480 type="button"
481 className="btn-secondary btn-small"
482 onClick={addHeader}
483 >
484 + Add Header
485 </button>
486 </div>
487 </div>
488 </>
489 )}
490
491 {/* Form Actions */}
492 <div className="mcp-form-actions">
493 <button className="btn-secondary" onClick={onCancel}>
494 Cancel
495 </button>
496 <button className="btn-primary" onClick={onSave}>
497 Save
498 </button>
499 </div>
500 </div>
501 );
502}