From c1d6829850c894e6fc6a2b495ebc9a4ddcd480b9 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 17 Dec 2025 17:06:33 +0100 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20HTTP/SSE=20MCP?= =?UTF-8?q?=20servers=20and=20usage=20telemetry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: I9b4ba646bded38efa160d25e5d4514db2350c06c Signed-off-by: Thomas Kosiewski --- .../sections/ProjectSettingsSection.tsx | 243 ++++++-- src/common/orpc/schemas/mcp.ts | 123 +++- src/common/orpc/schemas/telemetry.ts | 76 +++ src/common/telemetry/payload.ts | 76 +++ src/common/types/mcp.ts | 34 +- src/node/orpc/router.ts | 232 +++++++- src/node/services/aiService.ts | 65 ++- src/node/services/mcpConfigService.test.ts | 60 +- src/node/services/mcpConfigService.ts | 179 +++++- src/node/services/mcpServerManager.ts | 547 +++++++++++++++--- src/node/services/serviceContainer.ts | 1 + 11 files changed, 1423 insertions(+), 213 deletions(-) diff --git a/src/browser/components/Settings/sections/ProjectSettingsSection.tsx b/src/browser/components/Settings/sections/ProjectSettingsSection.tsx index e9e8aa5dc..76ceb2aa5 100644 --- a/src/browser/components/Settings/sections/ProjectSettingsSection.tsx +++ b/src/browser/components/Settings/sections/ProjectSettingsSection.tsx @@ -27,7 +27,12 @@ import { createEditKeyHandler } from "@/browser/utils/ui/keybinds"; import { Switch } from "@/browser/components/ui/switch"; import { cn } from "@/common/lib/utils"; import { formatRelativeTime } from "@/browser/utils/ui/dateTime"; -import type { CachedMCPTestResult, MCPServerInfo } from "@/common/types/mcp"; +import type { + CachedMCPTestResult, + MCPHeaderValue, + MCPServerInfo, + MCPServerTransport, +} from "@/common/types/mcp"; import { useMCPTestCache } from "@/browser/hooks/useMCPTestCache"; import { ToolSelector } from "@/browser/components/ToolSelector"; @@ -185,14 +190,28 @@ export const ProjectSettingsSection: React.FC = () => { } = useMCPTestCache(selectedProject); const [testingServer, setTestingServer] = useState(null); + interface EditableServer { + name: string; + transport: MCPServerTransport; + /** command (stdio) or url (http/sse/auto) */ + value: string; + /** JSON string for headers (http/sse/auto only) */ + headersJson: string; + } + // Add form state - const [newServer, setNewServer] = useState({ name: "", command: "" }); + const [newServer, setNewServer] = useState({ + name: "", + transport: "stdio", + value: "", + headersJson: "", + }); const [addingServer, setAddingServer] = useState(false); const [testingNew, setTestingNew] = useState(false); const [newTestResult, setNewTestResult] = useState(null); // Edit state - const [editing, setEditing] = useState<{ name: string; command: string } | null>(null); + const [editing, setEditing] = useState(null); const [savingEdit, setSavingEdit] = useState(false); // Idle compaction state @@ -235,10 +254,10 @@ export const ProjectSettingsSection: React.FC = () => { void refresh(); }, [refresh]); - // Clear new command test result when command changes + // Clear new-server test result when transport/value/headers change useEffect(() => { setNewTestResult(null); - }, [newServer.command]); + }, [newServer.transport, newServer.value, newServer.headersJson]); const handleRemove = useCallback( async (name: string) => { @@ -314,15 +333,66 @@ export const ProjectSettingsSection: React.FC = () => { [api, selectedProject, cacheTestResult] ); - const handleTestNewCommand = useCallback(async () => { - if (!api || !selectedProject || !newServer.command.trim()) return; + const parseHeadersJson = (headersJson: string): Record | undefined => { + const trimmed = headersJson.trim(); + if (!trimmed) { + return undefined; + } + + const parsed: unknown = JSON.parse(trimmed); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Headers must be a JSON object"); + } + + const out: Record = {}; + for (const [key, value] of Object.entries(parsed as Record)) { + if (typeof value === "string") { + out[key] = value; + continue; + } + if (value && typeof value === "object" && !Array.isArray(value)) { + const secret = (value as Record).secret; + if (typeof secret === "string") { + out[key] = { secret }; + continue; + } + } + throw new Error(`Invalid header value for ${key}`); + } + + return out; + }; + + const serverDisplayValue = (entry: MCPServerInfo): string => + entry.transport === "stdio" ? entry.command : entry.url; + + const serverHeadersJson = (entry: MCPServerInfo): string => { + if (entry.transport === "stdio") { + return ""; + } + return entry.headers ? JSON.stringify(entry.headers, null, 2) : ""; + }; + + const handleTestNewServer = useCallback(async () => { + if (!api || !selectedProject || !newServer.value.trim()) return; setTestingNew(true); setNewTestResult(null); + try { + const headers = + newServer.transport === "stdio" ? undefined : parseHeadersJson(newServer.headersJson); + const result = await api.projects.mcp.test({ projectPath: selectedProject, - command: newServer.command.trim(), + ...(newServer.transport === "stdio" + ? { command: newServer.value.trim() } + : { + transport: newServer.transport, + url: newServer.value.trim(), + headers, + }), }); + setNewTestResult({ result, testedAt: Date.now() }); } catch (err) { setNewTestResult({ @@ -332,18 +402,29 @@ export const ProjectSettingsSection: React.FC = () => { } finally { setTestingNew(false); } - }, [api, selectedProject, newServer.command]); + }, [api, selectedProject, newServer.transport, newServer.value, newServer.headersJson]); const handleAddServer = useCallback(async () => { - if (!api || !selectedProject || !newServer.name.trim() || !newServer.command.trim()) return; + if (!api || !selectedProject || !newServer.name.trim() || !newServer.value.trim()) return; setAddingServer(true); setError(null); + try { + const headers = + newServer.transport === "stdio" ? undefined : parseHeadersJson(newServer.headersJson); + const result = await api.projects.mcp.add({ projectPath: selectedProject, name: newServer.name.trim(), - command: newServer.command.trim(), + ...(newServer.transport === "stdio" + ? { transport: "stdio", command: newServer.value.trim() } + : { + transport: newServer.transport, + url: newServer.value.trim(), + headers, + }), }); + if (!result.success) { setError(result.error ?? "Failed to add MCP server"); } else { @@ -351,7 +432,7 @@ export const ProjectSettingsSection: React.FC = () => { if (newTestResult?.result.success) { cacheTestResult(newServer.name.trim(), newTestResult.result); } - setNewServer({ name: "", command: "" }); + setNewServer({ name: "", transport: "stdio", value: "", headersJson: "" }); setNewTestResult(null); await refresh(); } @@ -362,8 +443,13 @@ export const ProjectSettingsSection: React.FC = () => { } }, [api, selectedProject, newServer, newTestResult, refresh, cacheTestResult]); - const handleStartEdit = useCallback((name: string, command: string) => { - setEditing({ name, command }); + const handleStartEdit = useCallback((name: string, entry: MCPServerInfo) => { + setEditing({ + name, + transport: entry.transport, + value: serverDisplayValue(entry), + headersJson: serverHeadersJson(entry), + }); }, []); const handleCancelEdit = useCallback(() => { @@ -371,19 +457,30 @@ export const ProjectSettingsSection: React.FC = () => { }, []); const handleSaveEdit = useCallback(async () => { - if (!api || !selectedProject || !editing?.command.trim()) return; + if (!api || !selectedProject || !editing?.value.trim()) return; setSavingEdit(true); setError(null); + try { + const headers = + editing.transport === "stdio" ? undefined : parseHeadersJson(editing.headersJson); + const result = await api.projects.mcp.add({ projectPath: selectedProject, name: editing.name, - command: editing.command.trim(), + ...(editing.transport === "stdio" + ? { transport: "stdio", command: editing.value.trim() } + : { + transport: editing.transport, + url: editing.value.trim(), + headers, + }), }); + if (!result.success) { setError(result.error ?? "Failed to update MCP server"); } else { - // Clear cached test result since command changed + // Clear cached test result since config changed clearTestResult(editing.name); setEditing(null); await refresh(); @@ -430,8 +527,8 @@ export const ProjectSettingsSection: React.FC = () => { } const projectName = (path: string) => path.split(/[\\/]/).pop() ?? path; - const canAdd = newServer.name.trim() && newServer.command.trim(); - const canTest = newServer.command.trim(); + const canAdd = newServer.name.trim() && newServer.value.trim(); + const canTest = newServer.value.trim(); return (
@@ -564,21 +661,37 @@ export const ProjectSettingsSection: React.FC = () => { )}
{isEditing ? ( - setEditing({ ...editing, command: e.target.value })} - className="border-border-medium bg-secondary/30 text-foreground placeholder:text-muted-foreground focus:ring-accent mt-1 w-full rounded-md border px-2 py-1 font-mono text-xs focus:ring-1 focus:outline-none" - autoFocus - spellCheck={false} - onKeyDown={createEditKeyHandler({ - onSave: () => void handleSaveEdit(), - onCancel: handleCancelEdit, - })} - /> +
+

+ transport: {editing.transport} +

+ setEditing({ ...editing, value: e.target.value })} + className="border-border-medium bg-secondary/30 text-foreground placeholder:text-muted-foreground focus:ring-accent w-full rounded-md border px-2 py-1 font-mono text-xs focus:ring-1 focus:outline-none" + autoFocus + spellCheck={false} + onKeyDown={createEditKeyHandler({ + onSave: () => void handleSaveEdit(), + onCancel: handleCancelEdit, + })} + /> + {editing.transport !== "stdio" && ( +