diff options
| author | Clawd <ai@clawd.bot> | 2026-03-01 08:45:09 -0800 |
|---|---|---|
| committer | Clawd <ai@clawd.bot> | 2026-03-01 08:45:09 -0800 |
| commit | 12099b4f8cd10002810438bd309e208169960107 (patch) | |
| tree | b00e2043b6f66c1569c43c9ae9cad346f8dbdd42 /renderer | |
| parent | d44d0f61e4026da35c0d1a4acb87ba71ed6cd599 (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')
| -rw-r--r-- | renderer/src/components/SettingsPage.tsx | 12 | ||||
| -rw-r--r-- | renderer/src/components/settings/McpSettings.tsx | 502 | ||||
| -rw-r--r-- | renderer/src/styles/globals.css | 212 |
3 files changed, 725 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"; | |||
| 2 | import { SystemPromptsSettings } from "./settings/SystemPromptsSettings"; | 2 | import { SystemPromptsSettings } from "./settings/SystemPromptsSettings"; |
| 3 | import { GitSettings } from "./settings/GitSettings"; | 3 | import { GitSettings } from "./settings/GitSettings"; |
| 4 | import { ModelSettings } from "./settings/ModelSettings"; | 4 | import { ModelSettings } from "./settings/ModelSettings"; |
| 5 | import { McpSettings } from "./settings/McpSettings"; | ||
| 5 | 6 | ||
| 6 | type SettingsSection = "model" | "system-prompts" | "git"; | 7 | type SettingsSection = "model" | "mcp" | "system-prompts" | "git"; |
| 7 | 8 | ||
| 8 | interface SettingsPageProps { | 9 | interface 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 @@ | |||
| 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 | } | ||
diff --git a/renderer/src/styles/globals.css b/renderer/src/styles/globals.css index 0db62c2..6cf3dc7 100644 --- a/renderer/src/styles/globals.css +++ b/renderer/src/styles/globals.css | |||
| @@ -1136,3 +1136,215 @@ html[data-theme="light"] .settings-textarea:focus { | |||
| 1136 | .cm-scroller::-webkit-scrollbar-thumb:hover { | 1136 | .cm-scroller::-webkit-scrollbar-thumb:hover { |
| 1137 | background: var(--border); | 1137 | background: var(--border); |
| 1138 | } | 1138 | } |
| 1139 | |||
| 1140 | /* ── MCP Settings ────────────────────────────────────────────── */ | ||
| 1141 | .mcp-server-list { | ||
| 1142 | display: flex; | ||
| 1143 | flex-direction: column; | ||
| 1144 | gap: 8px; | ||
| 1145 | margin-top: 16px; | ||
| 1146 | } | ||
| 1147 | |||
| 1148 | .mcp-empty { | ||
| 1149 | color: var(--text-secondary); | ||
| 1150 | font-size: 12px; | ||
| 1151 | font-style: italic; | ||
| 1152 | padding: 12px; | ||
| 1153 | text-align: center; | ||
| 1154 | border: 1px dashed var(--border); | ||
| 1155 | border-radius: 4px; | ||
| 1156 | } | ||
| 1157 | |||
| 1158 | .mcp-server-card { | ||
| 1159 | display: flex; | ||
| 1160 | align-items: center; | ||
| 1161 | justify-content: space-between; | ||
| 1162 | padding: 10px 12px; | ||
| 1163 | background: var(--bg-secondary); | ||
| 1164 | border: 1px solid var(--border); | ||
| 1165 | border-radius: 4px; | ||
| 1166 | } | ||
| 1167 | |||
| 1168 | .mcp-server-info { | ||
| 1169 | display: flex; | ||
| 1170 | align-items: center; | ||
| 1171 | gap: 10px; | ||
| 1172 | min-width: 0; | ||
| 1173 | flex: 1; | ||
| 1174 | } | ||
| 1175 | |||
| 1176 | .mcp-server-name { | ||
| 1177 | font-weight: 600; | ||
| 1178 | font-size: 13px; | ||
| 1179 | color: var(--text-primary); | ||
| 1180 | } | ||
| 1181 | |||
| 1182 | .mcp-server-type { | ||
| 1183 | font-size: 10px; | ||
| 1184 | text-transform: uppercase; | ||
| 1185 | letter-spacing: 0.05em; | ||
| 1186 | padding: 2px 6px; | ||
| 1187 | background: var(--bg-tertiary); | ||
| 1188 | border-radius: 3px; | ||
| 1189 | color: var(--text-secondary); | ||
| 1190 | } | ||
| 1191 | |||
| 1192 | .mcp-server-detail { | ||
| 1193 | font-size: 11px; | ||
| 1194 | color: var(--text-secondary); | ||
| 1195 | font-family: var(--font-mono, monospace); | ||
| 1196 | white-space: nowrap; | ||
| 1197 | overflow: hidden; | ||
| 1198 | text-overflow: ellipsis; | ||
| 1199 | flex: 1; | ||
| 1200 | min-width: 0; | ||
| 1201 | } | ||
| 1202 | |||
| 1203 | .mcp-server-actions { | ||
| 1204 | display: flex; | ||
| 1205 | gap: 6px; | ||
| 1206 | flex-shrink: 0; | ||
| 1207 | } | ||
| 1208 | |||
| 1209 | .mcp-error { | ||
| 1210 | padding: 8px 12px; | ||
| 1211 | background: rgba(239, 68, 68, 0.1); | ||
| 1212 | border: 1px solid rgba(239, 68, 68, 0.3); | ||
| 1213 | border-radius: 4px; | ||
| 1214 | color: #ef4444; | ||
| 1215 | font-size: 12px; | ||
| 1216 | margin-top: 12px; | ||
| 1217 | } | ||
| 1218 | |||
| 1219 | .mcp-server-form { | ||
| 1220 | margin-top: 16px; | ||
| 1221 | padding: 16px; | ||
| 1222 | background: var(--bg-secondary); | ||
| 1223 | border: 1px solid var(--border); | ||
| 1224 | border-radius: 6px; | ||
| 1225 | } | ||
| 1226 | |||
| 1227 | .mcp-form-title { | ||
| 1228 | font-size: 14px; | ||
| 1229 | font-weight: 600; | ||
| 1230 | color: var(--text-primary); | ||
| 1231 | margin-bottom: 16px; | ||
| 1232 | } | ||
| 1233 | |||
| 1234 | .mcp-form-field { | ||
| 1235 | margin-bottom: 14px; | ||
| 1236 | } | ||
| 1237 | |||
| 1238 | .mcp-form-field label { | ||
| 1239 | display: block; | ||
| 1240 | font-size: 11px; | ||
| 1241 | font-weight: 500; | ||
| 1242 | text-transform: uppercase; | ||
| 1243 | letter-spacing: 0.05em; | ||
| 1244 | color: var(--text-secondary); | ||
| 1245 | margin-bottom: 6px; | ||
| 1246 | } | ||
| 1247 | |||
| 1248 | .mcp-form-field input, | ||
| 1249 | .mcp-form-field select { | ||
| 1250 | width: 100%; | ||
| 1251 | padding: 8px 10px; | ||
| 1252 | background: var(--bg-primary); | ||
| 1253 | border: 1px solid var(--border); | ||
| 1254 | border-radius: 4px; | ||
| 1255 | color: var(--text-primary); | ||
| 1256 | font-size: 13px; | ||
| 1257 | font-family: var(--font-mono, monospace); | ||
| 1258 | box-sizing: border-box; | ||
| 1259 | } | ||
| 1260 | |||
| 1261 | .mcp-form-field input:focus, | ||
| 1262 | .mcp-form-field select:focus { | ||
| 1263 | outline: none; | ||
| 1264 | border-color: var(--accent); | ||
| 1265 | } | ||
| 1266 | |||
| 1267 | .mcp-form-field select { | ||
| 1268 | cursor: pointer; | ||
| 1269 | } | ||
| 1270 | |||
| 1271 | .mcp-array-builder, | ||
| 1272 | .mcp-kv-builder { | ||
| 1273 | display: flex; | ||
| 1274 | flex-direction: column; | ||
| 1275 | gap: 6px; | ||
| 1276 | } | ||
| 1277 | |||
| 1278 | .mcp-array-row { | ||
| 1279 | display: flex; | ||
| 1280 | gap: 6px; | ||
| 1281 | } | ||
| 1282 | |||
| 1283 | .mcp-array-row input { | ||
| 1284 | flex: 1; | ||
| 1285 | } | ||
| 1286 | |||
| 1287 | .mcp-kv-row { | ||
| 1288 | display: flex; | ||
| 1289 | align-items: center; | ||
| 1290 | gap: 6px; | ||
| 1291 | } | ||
| 1292 | |||
| 1293 | .mcp-kv-row input:first-child { | ||
| 1294 | flex: 0 0 35%; | ||
| 1295 | } | ||
| 1296 | |||
| 1297 | .mcp-kv-row input:nth-child(3) { | ||
| 1298 | flex: 1; | ||
| 1299 | } | ||
| 1300 | |||
| 1301 | .mcp-kv-sep { | ||
| 1302 | color: var(--text-secondary); | ||
| 1303 | font-family: var(--font-mono, monospace); | ||
| 1304 | font-size: 13px; | ||
| 1305 | } | ||
| 1306 | |||
| 1307 | .btn-icon { | ||
| 1308 | width: 28px; | ||
| 1309 | height: 28px; | ||
| 1310 | padding: 0; | ||
| 1311 | display: flex; | ||
| 1312 | align-items: center; | ||
| 1313 | justify-content: center; | ||
| 1314 | background: transparent; | ||
| 1315 | border: 1px solid var(--border); | ||
| 1316 | border-radius: 4px; | ||
| 1317 | color: var(--text-secondary); | ||
| 1318 | cursor: pointer; | ||
| 1319 | font-size: 16px; | ||
| 1320 | flex-shrink: 0; | ||
| 1321 | } | ||
| 1322 | |||
| 1323 | .btn-icon:hover { | ||
| 1324 | background: var(--bg-tertiary); | ||
| 1325 | color: var(--text-primary); | ||
| 1326 | } | ||
| 1327 | |||
| 1328 | .btn-small { | ||
| 1329 | padding: 4px 8px; | ||
| 1330 | font-size: 11px; | ||
| 1331 | } | ||
| 1332 | |||
| 1333 | .btn-danger { | ||
| 1334 | color: #ef4444; | ||
| 1335 | border-color: rgba(239, 68, 68, 0.3); | ||
| 1336 | } | ||
| 1337 | |||
| 1338 | .btn-danger:hover { | ||
| 1339 | background: rgba(239, 68, 68, 0.1); | ||
| 1340 | color: #ef4444; | ||
| 1341 | } | ||
| 1342 | |||
| 1343 | .mcp-form-actions { | ||
| 1344 | display: flex; | ||
| 1345 | justify-content: flex-end; | ||
| 1346 | gap: 8px; | ||
| 1347 | margin-top: 20px; | ||
| 1348 | padding-top: 16px; | ||
| 1349 | border-top: 1px solid var(--border); | ||
| 1350 | } | ||
