diff options
Diffstat (limited to 'renderer/src/components/settings')
| -rw-r--r-- | renderer/src/components/settings/McpSettings.tsx | 502 |
1 files changed, 502 insertions, 0 deletions
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 @@ | |||
| 1 | import React, { useState, useEffect } from "react"; | ||
| 2 | |||
| 3 | const api = window.api; | ||
| 4 | |||
| 5 | type McpServerType = "stdio" | "sse" | "http"; | ||
| 6 | |||
| 7 | interface 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 | |||
| 16 | type McpServersConfig = Record<string, McpServerConfig>; | ||
| 17 | |||
| 18 | interface EditingServer { | ||
| 19 | originalName: string | null; // null = new server | ||
| 20 | name: string; | ||
| 21 | config: McpServerConfig; | ||
| 22 | } | ||
| 23 | |||
| 24 | const emptyConfig = (): McpServerConfig => ({ | ||
| 25 | type: "stdio", | ||
| 26 | command: "", | ||
| 27 | args: [], | ||
| 28 | env: {}, | ||
| 29 | }); | ||
| 30 | |||
| 31 | export 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 | |||
| 226 | interface ServerFormProps { | ||
| 227 | editing: EditingServer; | ||
| 228 | setEditing: React.Dispatch<React.SetStateAction<EditingServer | null>>; | ||
| 229 | onSave: () => void; | ||
| 230 | onCancel: () => void; | ||
| 231 | } | ||
| 232 | |||
| 233 | function 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 | } | ||
