diff --git a/src/main/ipc/localHandlers.ts b/src/main/ipc/localHandlers.ts index 83ccb29e..164ab5b5 100644 --- a/src/main/ipc/localHandlers.ts +++ b/src/main/ipc/localHandlers.ts @@ -44,6 +44,7 @@ import { writeSharedSettingsFile, } from "../sharedSettingsFile"; import { readKeybindingsFile, writeKeybindingsFile } from "../keybindingsFile"; +import { detectMcpServers } from "../mcp/detectMcpServers"; import type { AutoUpdaterController } from "../updates/autoUpdater"; import { defineMainLocalIpcHandlers, @@ -186,6 +187,7 @@ export function createLocalIpcHandlers( getKeybindings: () => readKeybindingsFile(options.requireLightcodePaths().keybindingsPath), setKeybindings: (file) => writeKeybindingsFile(options.requireLightcodePaths().keybindingsPath, file), + getDetectedMcpServers: (payload) => detectMcpServers(payload?.projectPath), revealProjectEntry: async (payload) => { shell.showItemInFolder(resolveProjectFsPath(payload)); }, diff --git a/src/main/mcp/detectMcpServers.test.ts b/src/main/mcp/detectMcpServers.test.ts new file mode 100644 index 00000000..7d8f1afe --- /dev/null +++ b/src/main/mcp/detectMcpServers.test.ts @@ -0,0 +1,110 @@ +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it, expect } from "vitest"; +import { detectGlobalMcpServers, detectProjectMcpServers } from "./detectMcpServers"; + +function tmpProject(): string { + return mkdtempSync(join(tmpdir(), "mcp-detect-")); +} + +describe("detectProjectMcpServers", () => { + it("parses .mcp.json stdio + http entries", () => { + const dir = tmpProject(); + writeFileSync( + join(dir, ".mcp.json"), + JSON.stringify({ + mcpServers: { + fs: { command: "npx", args: ["-y", "server-fs"], env: { A: "1" }, cwd: "/repo" }, + remote: { type: "http", url: "https://x/mcp", headers: { Authorization: "Bearer t" } }, + }, + }), + ); + const groups = detectProjectMcpServers(dir); + const mcpJson = groups.find((g) => g.source === "mcp-json"); + expect(mcpJson).toBeDefined(); + const fs = mcpJson!.servers.find((s) => s.name === "fs"); + expect(fs?.transport).toEqual({ + type: "stdio", + command: "npx", + args: ["-y", "server-fs"], + env: { A: "1" }, + cwd: "/repo", + }); + const remote = mcpJson!.servers.find((s) => s.name === "remote"); + expect(remote?.transport).toEqual({ + type: "http", + url: "https://x/mcp", + headers: { Authorization: "Bearer t" }, + }); + }); + + it("parses VS Code .vscode/mcp.json (servers key) and JSONC comments", () => { + const dir = tmpProject(); + mkdirSync(join(dir, ".vscode")); + writeFileSync( + join(dir, ".vscode", "mcp.json"), + `{ + // workspace MCP servers + "servers": { + "play": { "type": "stdio", "command": "node", "args": ["play.js"] } + } + }`, + ); + const groups = detectProjectMcpServers(dir); + const vscode = groups.find((g) => g.source === "vscode-project"); + expect(vscode?.servers[0]?.transport).toMatchObject({ type: "stdio", command: "node" }); + }); + + it("parses opencode.json local + remote entries", () => { + const dir = tmpProject(); + writeFileSync( + join(dir, "opencode.json"), + JSON.stringify({ + mcp: { + local1: { type: "local", command: ["bun", "x", "srv"], environment: { K: "v" } }, + remote1: { type: "remote", url: "https://r/mcp", enabled: false }, + }, + }), + ); + const groups = detectProjectMcpServers(dir); + const oc = groups.find((g) => g.source === "opencode-project"); + const local1 = oc!.servers.find((s) => s.name === "local1"); + expect(local1?.transport).toEqual({ + type: "stdio", + command: "bun", + args: ["x", "srv"], + env: { K: "v" }, + }); + const remote1 = oc!.servers.find((s) => s.name === "remote1"); + expect(remote1?.transport).toEqual({ type: "http", url: "https://r/mcp", headers: {} }); + expect(remote1?.disabled).toBe(true); + }); + + it("returns no groups when the project has no MCP config", () => { + const dir = tmpProject(); + expect(detectProjectMcpServers(dir)).toEqual([]); + }); +}); + +describe("detectGlobalMcpServers", () => { + it("parses quoted Codex MCP server table names", () => { + const dir = tmpProject(); + const codexPath = join(dir, ".codex", "config.toml"); + mkdirSync(join(dir, ".codex")); + writeFileSync( + codexPath, + ` + [mcp_servers."ctx.7"] + url = "https://mcp.example.com/mcp" + `, + ); + + const groups = detectGlobalMcpServers(undefined, dir); + const codex = groups.find((g) => g.source === "codex-global"); + expect(codex?.servers[0]).toMatchObject({ + name: "ctx.7", + transport: { type: "http", url: "https://mcp.example.com/mcp", headers: {} }, + }); + }); +}); diff --git a/src/main/mcp/detectMcpServers.ts b/src/main/mcp/detectMcpServers.ts new file mode 100644 index 00000000..53eff7c6 --- /dev/null +++ b/src/main/mcp/detectMcpServers.ts @@ -0,0 +1,397 @@ +/** + * Read-only discovery of MCP servers already configured in other tools, so the + * MCP Manager can surface what exists across agents (grouped by source) and let + * the user import any of them into Lightcode's own managed list. + * + * Everything here is best-effort and defensive: a malformed config file yields + * an empty group, never a thrown error. We never write to these files. + */ + +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { + MCP_SOURCE_META, + type DetectedMcpGroup, + type DetectedMcpServer, + type DetectMcpServersResult, + type McpSource, + type McpTransport, +} from "@/shared/contracts"; + +function readJsonFile(path: string): unknown { + try { + if (!existsSync(path)) return undefined; + let text = readFileSync(path, "utf8"); + // Tolerate JSONC (// and /* */) used by VS Code / OpenCode config files. + text = stripJsonComments(text); + return JSON.parse(text); + } catch { + return undefined; + } +} + +function stripJsonComments(text: string): string { + return text + .replace(/\/\*[\s\S]*?\*\//gu, "") + .replace(/(^|[^:"'])\/\/.*$/gmu, (_m, p1: string) => p1); +} + +function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function asStringArray(value: unknown): string[] { + return Array.isArray(value) ? value.filter((v): v is string => typeof v === "string") : []; +} + +function asStringRecord(value: unknown): Record { + const record = asRecord(value); + if (!record) return {}; + const out: Record = {}; + for (const [k, v] of Object.entries(record)) { + if (typeof v === "string") out[k] = v; + } + return out; +} + +/** + * Map a `mcpServers` / `servers` style entry (Claude, Cursor, Gemini, VS Code, + * `.mcp.json`) into a canonical transport. Returns undefined for shapes we + * can't represent. + */ +function parseStandardEntry(raw: unknown): McpTransport | undefined { + const entry = asRecord(raw); + if (!entry) return undefined; + const command = asString(entry.command); + if (command) { + const cwd = asString(entry.cwd); + return { + type: "stdio", + command, + args: asStringArray(entry.args), + env: asStringRecord(entry.env), + ...(cwd ? { cwd } : {}), + }; + } + const httpUrl = asString(entry.httpUrl); + const url = asString(entry.url) ?? asString(entry.serverUrl); + const declaredType = asString(entry.type); + const headers = asStringRecord(entry.headers); + if (httpUrl) return { type: "http", url: httpUrl, headers }; + if (url) { + const type = declaredType === "sse" ? "sse" : "http"; + return { type, url, headers }; + } + return undefined; +} + +/** Map an OpenCode `mcp` entry (`local` / `remote`) into a canonical transport. */ +function parseOpenCodeEntry(raw: unknown): { transport?: McpTransport; disabled: boolean } { + const entry = asRecord(raw); + if (!entry) return { disabled: false }; + const disabled = entry.enabled === false; + if (entry.type === "remote") { + const url = asString(entry.url); + return url + ? { transport: { type: "http", url, headers: asStringRecord(entry.headers) }, disabled } + : { disabled }; + } + // local (or unspecified) → stdio. `command` is an array [cmd, ...args]. + const command = asStringArray(entry.command); + if (command.length === 0) return { disabled }; + return { + transport: { + type: "stdio", + command: command[0]!, + args: command.slice(1), + env: asStringRecord(entry.environment), + }, + disabled, + }; +} + +function collectStandard( + source: McpSource, + filePath: string, + serversValue: unknown, +): DetectedMcpServer[] { + const record = asRecord(serversValue); + if (!record) return []; + const out: DetectedMcpServer[] = []; + for (const [name, raw] of Object.entries(record)) { + const entry = asRecord(raw); + const transport = parseStandardEntry(raw); + out.push({ + name, + source, + filePath, + ...(transport ? { transport } : {}), + ...(entry?.disabled === true || entry?.enabled === false ? { disabled: true } : {}), + raw, + }); + } + return out; +} + +function collectOpenCode( + source: McpSource, + filePath: string, + mcpValue: unknown, +): DetectedMcpServer[] { + const record = asRecord(mcpValue); + if (!record) return []; + const out: DetectedMcpServer[] = []; + for (const [name, raw] of Object.entries(record)) { + const { transport, disabled } = parseOpenCodeEntry(raw); + out.push({ + name, + source, + filePath, + ...(transport ? { transport } : {}), + ...(disabled ? { disabled: true } : {}), + raw, + }); + } + return out; +} + +function group( + source: McpSource, + filePath: string, + servers: DetectedMcpServer[], +): DetectedMcpGroup | undefined { + if (servers.length === 0) return undefined; + const meta = MCP_SOURCE_META[source]; + return { source, label: meta.label, scope: meta.scope, shared: meta.shared, filePath, servers }; +} + +/** + * Minimal extractor for Codex `~/.codex/config.toml` `[mcp_servers.NAME]` + * tables. Not a full TOML parser — handles the common stdio + remote keys so + * detection can list servers; the user imports/edits via the manager. + */ +function collectCodexToml(filePath: string): DetectedMcpServer[] { + let text: string; + try { + if (!existsSync(filePath)) return []; + text = readFileSync(filePath, "utf8"); + } catch { + return []; + } + const servers = new Map< + string, + { command?: string; args: string[]; env: Record; url?: string } + >(); + let current: string | undefined; + for (const line of text.split(/\r?\n/u)) { + const trimmed = line.trim(); + if (trimmed.length === 0 || trimmed.startsWith("#")) continue; + const sectionName = parseCodexMcpSectionName(trimmed); + if (sectionName) { + current = sectionName; + if (!servers.has(current)) servers.set(current, { args: [], env: {} }); + continue; + } + if (trimmed.startsWith("[")) { + current = undefined; + continue; + } + if (!current) continue; + const kv = /^([A-Za-z0-9_]+)\s*=\s*(.+)$/u.exec(trimmed); + const key = kv?.[1]; + const rawValue = kv?.[2]; + if (!key || rawValue === undefined) continue; + const entry = servers.get(current)!; + if (key === "command") { + const command = parseTomlString(rawValue); + if (command) entry.command = command; + } else if (key === "url") { + const url = parseTomlString(rawValue); + if (url) entry.url = url; + } else if (key === "args") entry.args = parseTomlStringArray(rawValue); + else if (key === "env") entry.env = parseTomlInlineTable(rawValue); + } + const out: DetectedMcpServer[] = []; + for (const [name, entry] of servers) { + let transport: McpTransport | undefined; + if (entry.command) + transport = { type: "stdio", command: entry.command, args: entry.args, env: entry.env }; + else if (entry.url) transport = { type: "http", url: entry.url, headers: {} }; + out.push({ + name, + source: "codex-global", + filePath, + ...(transport ? { transport } : {}), + raw: entry, + }); + } + return out; +} + +function parseTomlString(value: string): string | undefined { + const trimmed = value.trim().replace(/,\s*$/u, ""); + if (trimmed.startsWith('"')) { + try { + return JSON.parse(trimmed) as string; + } catch { + return undefined; + } + } + const match = /^'([^']*)'$/u.exec(trimmed); + return match ? match[1] : undefined; +} + +function parseCodexMcpSectionName(line: string): string | undefined { + const match = /^\[mcp_servers\.(?:"((?:[^"\\]|\\.)*)"|([A-Za-z0-9_.-]+))\]$/u.exec(line); + if (!match) return undefined; + if (match[1] !== undefined) return parseTomlString(`"${match[1]}"`); + return match[2]; +} + +function parseTomlStringArray(value: string): string[] { + const inner = value + .trim() + .replace(/^\[/u, "") + .replace(/\]\s*,?\s*$/u, ""); + const out: string[] = []; + for (const part of inner.split(",")) { + const s = parseTomlString(part); + if (s !== undefined) out.push(s); + } + return out; +} + +function parseTomlInlineTable(value: string): Record { + const inner = value + .trim() + .replace(/^\{/u, "") + .replace(/\}\s*,?\s*$/u, ""); + const out: Record = {}; + for (const part of inner.split(",")) { + const kv = /^\s*"?([A-Za-z0-9_]+)"?\s*=\s*(.+?)\s*$/u.exec(part); + const key = kv?.[1]; + const rawVal = kv?.[2]; + if (!key || rawVal === undefined) continue; + const v = parseTomlString(rawVal); + if (v !== undefined) out[key] = v; + } + return out; +} + +type ServersCollector = ( + source: McpSource, + filePath: string, + value: unknown, +) => DetectedMcpServer[]; + +/** Read `filePath` as JSON, pull the servers container out, and group it. */ +function jsonGroup( + source: McpSource, + filePath: string, + getServers: (root: Record | undefined) => unknown, + collect: ServersCollector, +): DetectedMcpGroup | undefined { + const root = asRecord(readJsonFile(filePath)); + return group(source, filePath, collect(source, filePath, getServers(root))); +} + +const mcpServersField = (root: Record | undefined): unknown => root?.mcpServers; +const mcpField = (root: Record | undefined): unknown => root?.mcp; + +function compact(groups: (DetectedMcpGroup | undefined)[]): DetectedMcpGroup[] { + return groups.filter((g): g is DetectedMcpGroup => g !== undefined); +} + +/** + * Scan all known global/user-level config files. `claudeRoot` lets the entry + * point share a single parse of `~/.claude.json` with the project scan. + */ +export function detectGlobalMcpServers( + claudeRoot?: Record | undefined, + home = homedir(), +): DetectedMcpGroup[] { + const claudeJsonPath = join(home, ".claude.json"); + const claude = claudeRoot ?? asRecord(readJsonFile(claudeJsonPath)); + const codexPath = join(home, ".codex", "config.toml"); + return compact([ + group( + "claude-global", + claudeJsonPath, + collectStandard("claude-global", claudeJsonPath, claude?.mcpServers), + ), + group("codex-global", codexPath, collectCodexToml(codexPath)), + jsonGroup("cursor-global", join(home, ".cursor", "mcp.json"), mcpServersField, collectStandard), + jsonGroup( + "gemini-global", + join(home, ".gemini", "settings.json"), + mcpServersField, + collectStandard, + ), + jsonGroup( + "opencode-global", + join(home, ".config", "opencode", "opencode.json"), + mcpField, + collectOpenCode, + ), + ]); +} + +/** Scan project-level config files under `projectPath`. */ +export function detectProjectMcpServers( + projectPath: string, + claudeRoot?: Record | undefined, +): DetectedMcpGroup[] { + // Claude Code per-project (local) scope lives in ~/.claude.json under + // `projects[absolutePath].mcpServers`. + const claudeJsonPath = join(homedir(), ".claude.json"); + const claude = claudeRoot ?? asRecord(readJsonFile(claudeJsonPath)); + const projectEntry = asRecord(asRecord(claude?.projects)?.[projectPath]); + return compact([ + // Shared `.mcp.json` (Claude Code project scope + several other tools). + jsonGroup("mcp-json", join(projectPath, ".mcp.json"), mcpServersField, collectStandard), + group( + "claude-project", + claudeJsonPath, + collectStandard("claude-project", claudeJsonPath, projectEntry?.mcpServers), + ), + jsonGroup( + "cursor-project", + join(projectPath, ".cursor", "mcp.json"), + mcpServersField, + collectStandard, + ), + jsonGroup( + "gemini-project", + join(projectPath, ".gemini", "settings.json"), + mcpServersField, + collectStandard, + ), + // VS Code workspace scope (.vscode/mcp.json) — note the key is `servers`. + jsonGroup( + "vscode-project", + join(projectPath, ".vscode", "mcp.json"), + (root) => root?.servers ?? root?.mcpServers, + collectStandard, + ), + jsonGroup("opencode-project", join(projectPath, "opencode.json"), mcpField, collectOpenCode), + ]); +} + +/** Entry point used by the IPC handler. */ +export function detectMcpServers(projectPath?: string): DetectMcpServersResult { + // `~/.claude.json` carries both user (top-level) and per-project scopes, so + // parse it once and share it across both scans. + const claudeRoot = asRecord(readJsonFile(join(homedir(), ".claude.json"))); + const groups = [ + ...detectGlobalMcpServers(claudeRoot), + ...(projectPath ? detectProjectMcpServers(projectPath, claudeRoot) : []), + ]; + return { groups }; +} diff --git a/src/main/sharedSettingsFile.test.ts b/src/main/sharedSettingsFile.test.ts index 2d7a4d30..9077105b 100644 --- a/src/main/sharedSettingsFile.test.ts +++ b/src/main/sharedSettingsFile.test.ts @@ -132,6 +132,7 @@ describe("sharedSettingsFile", () => { providerOrder: [], collapsedProviders: [], }, + mcpServers: [], }); expect(readSharedSettingsFile(settingsPath)).toEqual({ @@ -234,6 +235,7 @@ describe("sharedSettingsFile", () => { providerOrder: [], collapsedProviders: [], }, + mcpServers: [], }); expect(readFileSync(settingsPath, "utf8")).toContain('"themeMode": "dark"'); }); diff --git a/src/renderer/components/mcp/DetectedMcpPanel.tsx b/src/renderer/components/mcp/DetectedMcpPanel.tsx new file mode 100644 index 00000000..b2f07afb --- /dev/null +++ b/src/renderer/components/mcp/DetectedMcpPanel.tsx @@ -0,0 +1,133 @@ +import { useEffect, useState } from "react"; +import { Download, RefreshCw } from "lucide-react"; +import { readBridge } from "@/renderer/bridge"; +import { + isValidMcpServerName, + type DetectedMcpGroup, + type DetectedMcpServer, +} from "@/shared/contracts"; +import { Button, PixelLoader } from "@/renderer/components/common"; +import { transportBadge, transportSummary } from "./mcpFormUtils"; + +function loadDetectedMcpGroups(projectPath: string | undefined): Promise { + return readBridge() + .getDetectedMcpServers(projectPath ? { projectPath } : {}) + .then((result) => result.groups); +} + +export function DetectedMcpPanel(props: { + /** Absolute project path for project-scope scans; omit for global-only. */ + projectPath?: string; + /** Server names already managed in the current scope (lowercased). */ + managedNames: ReadonlySet; + onImport: (detected: DetectedMcpServer) => void; +}) { + const { projectPath, managedNames, onImport } = props; + const [groups, setGroups] = useState(undefined); + const [loading, setLoading] = useState(false); + + const scan = () => { + setLoading(true); + void loadDetectedMcpGroups(projectPath) + .then(setGroups) + .catch(() => setGroups([])) + .finally(() => setLoading(false)); + }; + + useEffect(() => { + let cancelled = false; + setLoading(true); + setGroups(undefined); + void loadDetectedMcpGroups(projectPath) + .then((nextGroups) => { + if (!cancelled) setGroups(nextGroups); + }) + .catch(() => { + if (!cancelled) setGroups([]); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [projectPath]); + + if (groups === undefined) { + return ( +
+ +
+ ); + } + + return ( +
+
+

+ MCP servers found in other tools' config files. Import any to manage it in Lightcode. +

+ +
+ + {groups.length === 0 ? ( +

+ No MCP servers detected in other tools. +

+ ) : ( + groups.map((group) => ( +
+
+ {group.label} + + {group.scope} + +
+
+ {group.servers.map((server) => { + const alreadyManaged = managedNames.has(server.name.toLowerCase()); + const invalidName = !isValidMcpServerName(server.name); + return ( +
+ + {server.transport ? transportBadge(server.transport) : "?"} + +
+
{server.name}
+
+ {server.transport + ? transportSummary(server.transport) + : "Unsupported config shape"} +
+
+ +
+ ); + })} +
+
+ )) + )} +
+ ); +} diff --git a/src/renderer/components/mcp/McpManager.tsx b/src/renderer/components/mcp/McpManager.tsx new file mode 100644 index 00000000..6e290b2c --- /dev/null +++ b/src/renderer/components/mcp/McpManager.tsx @@ -0,0 +1,177 @@ +import { useState, type ReactNode } from "react"; +import { Switch } from "@heroui/react"; +import { Pencil, Plus, Store, Trash2, ScanSearch, Server } from "lucide-react"; +import type { DetectedMcpServer, McpServer } from "@/shared/contracts"; +import { detectedToManagedServer } from "@/shared/contracts"; +import { catalogEntryToServer, type McpCatalogEntry } from "@/shared/mcpCatalog"; +import { Button, LightballTabs } from "@/renderer/components/common"; +import { McpServerEditorDialog } from "./McpServerEditorDialog"; +import { McpMarketplace } from "./McpMarketplace"; +import { DetectedMcpPanel } from "./DetectedMcpPanel"; +import { transportBadge, transportSummary } from "./mcpFormUtils"; + +type Tab = "servers" | "marketplace" | "detected"; + +export function McpManager(props: { + servers: McpServer[]; + onChange: (servers: McpServer[]) => void; + /** Absolute project path enabling project-scope detection. Omit for global. */ + projectPath?: string; + /** Optional banner shown above the server list (e.g. inherited-globals note). */ + banner?: ReactNode; +}) { + const { servers, onChange, projectPath, banner } = props; + const [tab, setTab] = useState("servers"); + const [editorOpen, setEditorOpen] = useState(false); + const [editing, setEditing] = useState(undefined); + const [setupHint, setSetupHint] = useState(undefined); + + const managedNames = new Set(servers.map((s) => s.name.toLowerCase())); + const installedCatalogIds = new Set( + servers.map((s) => s.catalogId).filter((id): id is string => Boolean(id)), + ); + + const upsert = (server: McpServer) => { + const idx = servers.findIndex((s) => s.id === server.id); + if (idx === -1) onChange([...servers, server]); + else onChange(servers.map((s) => (s.id === server.id ? server : s))); + }; + + const openEditor = (server: McpServer | undefined, hint?: string) => { + setEditing(server); + setSetupHint(hint); + setEditorOpen(true); + }; + const remove = (id: string) => onChange(servers.filter((s) => s.id !== id)); + const toggle = (id: string, enabled: boolean) => + onChange(servers.map((s) => (s.id === id ? { ...s, enabled } : s))); + + const addFromCatalog = (entry: McpCatalogEntry) => { + const server = catalogEntryToServer(entry, crypto.randomUUID()); + upsert(server); + openEditor(server, entry.setupHint); + setTab("servers"); + }; + + const importDetected = (detected: DetectedMcpServer) => { + const server = detectedToManagedServer(detected, crypto.randomUUID()); + if (!server) return; + // Avoid clobbering an existing managed server with the same name. + if (managedNames.has(server.name.toLowerCase())) return; + upsert(server); + setTab("servers"); + }; + + return ( +
+ , + label: `Servers${servers.length > 0 ? ` (${servers.length})` : ""}`, + }, + { id: "marketplace", icon: , label: "Marketplace" }, + { id: "detected", icon: , label: "Detected" }, + ]} + /> + + {tab === "servers" ? ( +
+ {banner} + {servers.length === 0 ? ( +
+

No MCP servers yet

+

+ Add one manually, browse the Marketplace, or import a detected server. +

+
+ ) : ( +
+ {servers.map((server) => ( +
+ toggle(server.id, selected)} + aria-label={`Enable ${server.name}`} + > + + + + + + +
+
+ + {server.label?.trim() || server.name} + + + {transportBadge(server.transport)} + +
+
+ {transportSummary(server.transport)} +
+
+ + +
+ ))} +
+ )} + +
+ ) : null} + + {tab === "marketplace" ? ( + + ) : null} + + {tab === "detected" ? ( + + ) : null} + + setEditorOpen(false)} + /> +
+ ); +} diff --git a/src/renderer/components/mcp/McpMarketplace.tsx b/src/renderer/components/mcp/McpMarketplace.tsx new file mode 100644 index 00000000..416f2808 --- /dev/null +++ b/src/renderer/components/mcp/McpMarketplace.tsx @@ -0,0 +1,79 @@ +import { useState } from "react"; +import { Check, Plus } from "lucide-react"; +import { Button, Input } from "@/renderer/components/common"; +import { + MCP_CATALOG, + MCP_CATALOG_CATEGORY_LABELS, + type McpCatalogEntry, +} from "@/shared/mcpCatalog"; +import { transportBadge } from "./mcpFormUtils"; + +export function McpMarketplace(props: { + /** Catalog ids already installed in the current scope. */ + installedCatalogIds: ReadonlySet; + onAdd: (entry: McpCatalogEntry) => void; +}) { + const { installedCatalogIds, onAdd } = props; + const [query, setQuery] = useState(""); + + const normalizedQuery = query.trim().toLowerCase(); + const filtered = normalizedQuery + ? MCP_CATALOG.filter( + (entry) => + entry.title.toLowerCase().includes(normalizedQuery) || + entry.name.toLowerCase().includes(normalizedQuery) || + entry.description.toLowerCase().includes(normalizedQuery), + ) + : MCP_CATALOG; + + return ( +
+ setQuery(e.target.value)} + /> +
+ {filtered.map((entry) => { + const installed = installedCatalogIds.has(entry.id); + return ( +
+
+
+
+ + {entry.title} + + + {transportBadge(entry.transport)} + +
+ + {MCP_CATALOG_CATEGORY_LABELS[entry.category]} + +
+ +
+

{entry.description}

+
+ ); + })} + {filtered.length === 0 ? ( +

No matching servers.

+ ) : null} +
+
+ ); +} diff --git a/src/renderer/components/mcp/McpServerEditorDialog.tsx b/src/renderer/components/mcp/McpServerEditorDialog.tsx new file mode 100644 index 00000000..d54d5c5d --- /dev/null +++ b/src/renderer/components/mcp/McpServerEditorDialog.tsx @@ -0,0 +1,200 @@ +import { useEffect, useState, type ReactNode } from "react"; +import { Modal } from "@heroui/react"; +import type { McpServer } from "@/shared/contracts"; +import { Button, Input, Select, TextArea } from "@/renderer/components/common"; +import { + formStateToServer, + newServerFormState, + serverToFormState, + validateForm, + type McpServerFormState, +} from "./mcpFormUtils"; + +const TRANSPORT_OPTIONS = [ + { id: "stdio", label: "stdio (local command)" }, + { id: "http", label: "http (streamable)" }, + { id: "sse", label: "sse (legacy)" }, +]; + +export function McpServerEditorDialog(props: { + isOpen: boolean; + /** The server being edited, or undefined to create a new one. */ + server?: McpServer | undefined; + /** Existing server names (lowercased) used to flag duplicates. */ + existingNames: ReadonlySet; + setupHint?: string | undefined; + onSave: (server: McpServer) => void; + onClose: () => void; +}) { + const { isOpen, server, existingNames, setupHint, onSave, onClose } = props; + const [state, setState] = useState(() => newServerFormState("")); + const [showErrors, setShowErrors] = useState(false); + + // Re-seed the form each time the dialog opens for a (possibly different) server. + useEffect(() => { + if (!isOpen) return; + setShowErrors(false); + setState(server ? serverToFormState(server) : newServerFormState(crypto.randomUUID())); + }, [isOpen, server]); + + const validation = validateForm(state); + const trimmedName = state.name.trim().toLowerCase(); + const duplicate = + trimmedName.length > 0 && + trimmedName !== (server?.name.toLowerCase() ?? "") && + existingNames.has(trimmedName); + + const update = (key: K, value: McpServerFormState[K]) => + setState((prev) => ({ ...prev, [key]: value })); + + const handleSave = () => { + if (!validation.ok || duplicate) { + setShowErrors(true); + return; + } + onSave(formStateToServer(state, server)); + onClose(); + }; + + const fieldError = (key: "name" | "command" | "url") => + showErrors ? validation.errors[key] : undefined; + + return ( + + !open && onClose()}> + + + + + {server ? "Edit MCP server" : "Add MCP server"} + + + {setupHint ? ( +

+ {setupHint} +

+ ) : null} + + + update("name", e.target.value)} + /> + + + + update("command", e.target.value)} + /> + + +