From b7930bb89b319cae999fff9890067253e4918c64 Mon Sep 17 00:00:00 2001 From: Christian Rey Villablanca Date: Mon, 3 Aug 2026 22:21:01 +0800 Subject: [PATCH 1/2] feat(mcp): add managed MCP server profiles --- README.md | 17 ++ package.json | 5 +- src/providers/ChatViewProvider.ts | 82 ++++++- src/services/McpServerService.ts | 200 ++++++++++++++++++ tests/services/mcp-server-service.test.mjs | 30 +++ webview/shared/src/chat/PanelComponents.tsx | 65 ++++++ webview/shared/src/chat/index.css | 157 ++++++++++++++ webview/shared/src/chat/lib/messageHandler.ts | 19 +- webview/shared/src/chat/lib/types.ts | 4 + 9 files changed, 572 insertions(+), 7 deletions(-) create mode 100644 src/services/McpServerService.ts create mode 100644 tests/services/mcp-server-service.test.mjs diff --git a/README.md b/README.md index 6d6eefe..77b6a7b 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,10 @@ The right sidebar can show: - MCP server status - MCP tool lists +- Add and manage workspace-scoped MCP server profiles +- Register local stdio servers with commands, arguments, working directories, environment variables, and timeouts +- Register remote Streamable HTTP servers with URLs, headers, and timeouts +- Connect, disconnect, and remove extension-managed MCP profiles - LSP server status - Slash-command skills - Available OpenCode agents @@ -160,6 +164,19 @@ OpenCode UI works with OpenCode plugin-driven workflows, including community plu Plugin-provided agents, skills, and capabilities can be surfaced directly inside the extension UI. +### Managing MCP Servers + +Open the **MCP Servers** section in the Integrations panel and select **Add server**. Profiles are scoped to the current file-based workspace and are re-registered with OpenCode when the managed OpenCode server reconnects. + +Supported connection types: + +- **STDIO**: OpenCode starts a local command using a structured executable and argument list. +- **Streamable HTTP**: OpenCode connects to an HTTPS MCP endpoint with optional headers. + +Managed profiles can be connected, disconnected, and removed from the panel. Removing a profile deletes this extension's saved profile and secrets and disconnects the current runtime entry. The pinned SDK does not provide dynamic MCP deletion, so the current OpenCode process may continue to list the entry until it restarts. + +The extension stores non-sensitive profile metadata in workspace state. Environment values, HTTP header values, and OAuth client secrets are stored in VS Code `SecretStorage`; they are not included in webview state, status messages, logs, or project files. OAuth authentication controls remain gated until the pinned SDK's authentication callback contract is verified. + --- ## Screenshots diff --git a/package.json b/package.json index 86a54f7..e77975b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "opencode-vscode-chryzxc", "displayName": "OpenCode — VS Code Client", - "description": "OpenCode client for VS Code with streaming chat, implementation plans, subagents, diff review, sessions, and quota monitoring.", + "description": "OpenCode client for VS Code with streaming chat, implementation plans, subagents, diff review, sessions, quota monitoring, and managed MCP servers.", "license": "MIT", "repository": { "type": "git", @@ -66,6 +66,9 @@ "subagents", "multi agent", "mcp", + "mcp server management", + "streamable http", + "stdio", "model context protocol", "session management", "quota monitoring", diff --git a/src/providers/ChatViewProvider.ts b/src/providers/ChatViewProvider.ts index 71e60d9..ba3d039 100644 --- a/src/providers/ChatViewProvider.ts +++ b/src/providers/ChatViewProvider.ts @@ -110,6 +110,7 @@ import { import type { TokenUsage } from "../services/GeminiTokenUsageTracker"; import { GeminiTokenUsageTracker } from "../services/GeminiTokenUsageTracker"; import { MessageStreamService } from "../services/MessageStreamService"; +import { McpServerService, type ManagedMcpDraft, type McpClient } from "../services/McpServerService"; import { ModelCapabilitiesService } from "../services/ModelCapabilitiesService"; import { OpencodeServerManager } from "../services/OpencodeServerManager"; import { QuotaService } from "../services/QuotaService"; @@ -885,6 +886,7 @@ export class ChatViewProvider private readonly recentUiErrorToastTimestamps = new Map(); private readonly UI_ERROR_TOAST_DEDUPE_WINDOW_MS = 15_000; private readonly installedSdkVersion = detectInstalledOpencodeSdkVersion(); + private readonly mcpServerService: McpServerService; /** ===== NEW: Module instances ===== */ private diagnosticsLogger!: DiagnosticsLogger; @@ -926,6 +928,12 @@ export class ChatViewProvider this.streamService = new MessageStreamService(serverManager); this.quotaService = new QuotaService(); this.sessionSnapshotLoader = new SessionSnapshotLoader(serverManager); + this.mcpServerService = new McpServerService( + context, + () => this.getWorkspaceDirectory(), + () => this.serverManager.ensureRunning() as Promise, + { info: (message, data) => this.logger.info(message, data), warn: (message, data) => this.logger.warn(message, data) }, + ); this.subagentTracker = new SubagentTracker(() => this.selectedModel); this.configFilesProvider = new ConfigFilesProvider(); this.skillManager = new SkillManagerService(context); @@ -4387,6 +4395,54 @@ export class ChatViewProvider ); break; } + case "addMcpServer": + case "connectMcpServer": + case "disconnectMcpServer": + case "removeMcpServer": { + const requestID = typeof message.requestID === "string" ? message.requestID : undefined; + const operation = message.type === "addMcpServer" + ? "adding" + : message.type === "connectMcpServer" + ? "connecting" + : message.type === "disconnectMcpServer" + ? "disconnecting" + : "removing"; + const serverName = typeof message.name === "string" ? message.name.trim() : ""; + const profileID = typeof message.profileId === "string" ? message.profileId : undefined; + if (message.type === "removeMcpServer") { + const confirmation = await vscode.window.showWarningMessage( + "This disconnects the current server and removes this extension's saved profile. The current OpenCode process may still list it until it restarts.", + { modal: true }, + "Remove", + ); + if (confirmation !== "Remove") break; + } + this.view?.webview.postMessage({ type: "mcpOperationStarted", requestID, operation, serverName, profileID }); + try { + if (message.type === "addMcpServer") { + await this.mcpServerService.add(message.draft as ManagedMcpDraft); + } else if (message.type === "connectMcpServer") { + await this.mcpServerService.connect(serverName); + } else if (message.type === "disconnectMcpServer") { + await this.mcpServerService.disconnect(serverName); + } else { + await this.mcpServerService.remove(serverName, profileID); + void vscode.window.showInformationMessage("MCP profile removed. The current OpenCode process may list it until it restarts."); + } + this.view?.webview.postMessage({ type: "mcpOperationResult", requestID, operation, success: true }); + } catch (error) { + void vscode.window.showErrorMessage("OpenCode could not complete the MCP operation. Check the MCP status for details."); + this.view?.webview.postMessage({ + type: "mcpOperationResult", + requestID, + operation, + success: false, + error: "MCP operation failed. Refresh the MCP status and check the server details.", + }); + } + await this.handleGetMcpStatus(); + break; + } case "getLspStatus": { this.handleGetLspStatus().catch((err) => log.error("Failed to handle LSP status request", { @@ -5060,6 +5116,7 @@ export class ChatViewProvider this.postErrorToast(serverError); } this.broadcastCompatibilityWarnings(); + if (status === "running") void this.handleGetMcpStatus(); }); const serverErrorOutputSubscription = this.serverManager.onServerErrorOutput( (snippet) => { @@ -10026,12 +10083,10 @@ export class ChatViewProvider const client = await this.serverManager.ensureRunning(); log.featureStep(flow, 'fetching_mcp_and_tool_data'); - const [mcpRes, toolIdsRes] = await Promise.all([ - client.mcp.status(), + const [servers, toolIdsRes] = await Promise.all([ + this.mcpServerService.status(client as McpClient), client.tool.ids().catch(() => ({ data: [] })), ]); - - const servers = mcpRes.data ?? {}; const toolIds: string[] = Array.isArray(toolIdsRes?.data) ? toolIdsRes.data : []; @@ -10041,9 +10096,26 @@ export class ChatViewProvider toolCount: toolIds.length, }); + const managedProfiles = new Map(this.mcpServerService.profiles().map((profile) => [profile.name, profile])); + const enrichedServers = Object.fromEntries(Object.entries(servers).filter(([name]) => !this.mcpServerService.wasRemoved(name)).map(([name, value]) => { + const profile = managedProfiles.get(name); + return [name, profile ? { ...(value as Record), managed: true, profileId: profile.id, kind: profile.kind } : value]; + })); + for (const profile of managedProfiles.values()) { + if (!Object.prototype.hasOwnProperty.call(enrichedServers, profile.name)) { + enrichedServers[profile.name] = { + status: "disconnected", + error: "The managed profile is saved but is not currently registered with OpenCode.", + managed: true, + profileId: profile.id, + kind: profile.kind, + }; + } + } + this.view?.webview.postMessage({ type: "mcpStatus", - servers, + servers: enrichedServers, toolIds, }); diff --git a/src/services/McpServerService.ts b/src/services/McpServerService.ts new file mode 100644 index 0000000..04ea5b3 --- /dev/null +++ b/src/services/McpServerService.ts @@ -0,0 +1,200 @@ +import * as crypto from "crypto"; +import * as fs from "fs"; +import * as path from "path"; +import type * as vscode from "vscode"; +import type { McpLocalConfig, McpRemoteConfig } from "@opencode-ai/sdk/v2"; + +const STORAGE_KEY = "opencode.managedMcpProfiles.v1"; +const SECRET_PREFIX = "opencode.managedMcp.v1"; +const MAX_TIMEOUT_MS = 10 * 60 * 1000; + +export type ManagedMcpKind = "local" | "remote"; + +export interface ManagedMcpProfile { + id: string; + name: string; + kind: ManagedMcpKind; + createdAt: number; + updatedAt: number; + local?: { command: string[]; cwd?: string; environmentKeys: string[]; timeout?: number }; + remote?: { url: string; headerKeys: string[]; oauth?: { clientId?: string; scope?: string; callbackPort?: number; redirectUri?: string } | false; timeout?: number }; +} + +export type ManagedMcpDraft = + | { name: string; kind: "local"; command: string[]; cwd?: string; environment: Record; timeout?: number } + | { name: string; kind: "remote"; url: string; headers: Record; oauth?: { clientId?: string; clientSecret?: string; scope?: string; callbackPort?: number; redirectUri?: string } | false; timeout?: number }; + +export type McpClient = { + mcp: { + status(parameters?: { directory?: string }): Promise<{ data?: Record }>; + add(parameters: { name: string; config: McpLocalConfig | McpRemoteConfig; directory?: string }): Promise; + connect(parameters: { name: string; directory?: string }): Promise; + disconnect(parameters: { name: string; directory?: string }): Promise; + }; +}; + +export class McpServerService { + private readonly rehydratedClients = new WeakSet(); + private readonly mutationChains = new Map>(); + private readonly removedNames = new Set(); + + public constructor( + private readonly context: Pick, + private readonly getWorkspaceDirectory: () => string | undefined, + private readonly getClient: () => Promise, + private readonly log: { info(message: string, data?: Record): void; warn(message: string, data?: Record): void }, + ) {} + + public profiles(): ManagedMcpProfile[] { + return this.readProfiles().sort((a, b) => a.createdAt - b.createdAt); + } + + public profileForName(name: string): ManagedMcpProfile | undefined { + return this.profiles().find((profile) => profile.name === name); + } + + public async add(draft: ManagedMcpDraft): Promise { + if (!this.getWorkspaceDirectory()) throw new Error("Open a file-based workspace before adding an extension-managed MCP server."); + const profile = this.validateAndCreateProfile(draft); + const existing = this.profiles().some((item) => item.name.toLowerCase() === profile.name.toLowerCase()); + if (existing) throw new Error(`An MCP server named "${profile.name}" is already managed.`); + + await this.withServerLock(profile.name, async () => { + await this.context.workspaceState.update(STORAGE_KEY, [...this.profiles(), profile]); + try { + await this.writeSecrets(profile.id, draft); + const client = await this.getClient(); + await this.addToClient(client, profile, draft); + this.removedNames.delete(profile.name); + } catch (error) { + await this.removeProfileData(profile); + throw error; + } + }); + } + + public async connect(name: string): Promise { + if (!this.profileForName(name)) throw new Error("That MCP server is not managed by this extension."); + await this.withServerLock(name, async () => { + await (await this.getClient()).mcp.connect(this.scope({ name })); + }); + } + + public async disconnect(name: string): Promise { + if (!this.profileForName(name)) throw new Error("That MCP server is not managed by this extension."); + await this.withServerLock(name, async () => { + await (await this.getClient()).mcp.disconnect(this.scope({ name })); + }); + } + + public async remove(name: string, profileId?: string): Promise { + const profile = this.profiles().find((item) => profileId ? item.id === profileId : item.name === name); + if (!profile) throw new Error("That MCP server is not managed by this extension."); + await this.withServerLock(name, async () => { + try { await (await this.getClient()).mcp.disconnect(this.scope({ name })); } catch (error) { + this.log.warn("MCP disconnect before removal failed", { name, error: this.safeError(error) }); + } + await this.removeProfileData(profile); + this.removedNames.add(profile.name); + }); + } + + public wasRemoved(name: string): boolean { + return this.removedNames.has(name); + } + + public async rehydrate(client: McpClient): Promise { + if (!this.getWorkspaceDirectory() || this.rehydratedClients.has(client as object)) return; + this.rehydratedClients.add(client as object); + for (const profile of this.profiles()) { + try { + const draft = await this.draftFromProfile(profile); + await this.addToClient(client, profile, draft); + } catch (error) { + this.log.warn("Failed to rehydrate managed MCP server", { name: profile.name, error: this.safeError(error) }); + } + } + } + + public async status(client: McpClient): Promise> { + await this.rehydrate(client); + const managedNames = this.profiles().map((profile) => profile.name); + let servers: Record = {}; + for (let attempt = 0; attempt < 3; attempt += 1) { + const response = await client.mcp.status(this.scope()); + servers = response.data ?? {}; + const allManagedVisible = managedNames.every((name) => Object.prototype.hasOwnProperty.call(servers, name)); + if (allManagedVisible || attempt === 2) return servers; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + return servers; + } + + private async addToClient(client: McpClient, profile: ManagedMcpProfile, draft: ManagedMcpDraft): Promise { + await client.mcp.add({ ...this.scope({ name: profile.name }), config: await this.toSdkConfig(profile, draft) }); + } + + private async toSdkConfig(profile: ManagedMcpProfile, draft: ManagedMcpDraft): Promise { + if (draft.kind === "local") { + return { type: "local", command: draft.command, cwd: draft.cwd, environment: draft.environment, timeout: draft.timeout }; + } + const oauth = draft.oauth + ? { clientId: draft.oauth.clientId, clientSecret: draft.oauth.clientSecret, scope: draft.oauth.scope, callbackPort: draft.oauth.callbackPort, redirectUri: draft.oauth.redirectUri } + : draft.oauth; + return { type: "remote", url: draft.url, headers: draft.headers, oauth, timeout: draft.timeout }; + } + + private validateAndCreateProfile(draft: ManagedMcpDraft): ManagedMcpProfile { + const name = draft.name.trim(); + if (!/^[A-Za-z][A-Za-z0-9._-]{0,63}$/.test(name)) throw new Error("MCP server names must start with a letter and contain only letters, numbers, '.', '_' or '-'."); + if (draft.kind === "local") { + if (!draft.command.length || draft.command.some((item) => typeof item !== "string" || !item.trim())) throw new Error("Local MCP command must contain an executable and non-empty arguments."); + if (draft.cwd && (!path.isAbsolute(draft.cwd) || !fs.existsSync(draft.cwd) || !fs.statSync(draft.cwd).isDirectory())) throw new Error("Local MCP working directory must be an existing absolute directory."); + this.validateRows(draft.environment, "environment"); + } else { + let url: URL; + try { url = new URL(draft.url); } catch { throw new Error("Remote MCP URL must be a valid absolute HTTPS URL."); } + if (url.protocol !== "https:") throw new Error("Remote MCP URL must use HTTPS."); + this.validateRows(draft.headers, "header"); + if (draft.oauth && draft.oauth.callbackPort !== undefined && (!Number.isInteger(draft.oauth.callbackPort) || draft.oauth.callbackPort < 1 || draft.oauth.callbackPort > 65535)) throw new Error("OAuth callback port must be between 1 and 65535."); + } + if (draft.timeout !== undefined && (!Number.isInteger(draft.timeout) || draft.timeout < 1 || draft.timeout > MAX_TIMEOUT_MS)) throw new Error(`Timeout must be a positive integer no greater than ${MAX_TIMEOUT_MS} milliseconds.`); + const now = Date.now(); + return draft.kind === "local" + ? { id: crypto.randomUUID(), name, kind: "local", createdAt: now, updatedAt: now, local: { command: draft.command, cwd: draft.cwd, environmentKeys: Object.keys(draft.environment), timeout: draft.timeout } } + : { id: crypto.randomUUID(), name, kind: "remote", createdAt: now, updatedAt: now, remote: { url: draft.url, headerKeys: Object.keys(draft.headers), oauth: draft.oauth === false ? false : draft.oauth ? { clientId: draft.oauth.clientId, scope: draft.oauth.scope, callbackPort: draft.oauth.callbackPort, redirectUri: draft.oauth.redirectUri } : undefined, timeout: draft.timeout } }; + } + + private validateRows(rows: Record, label: string): void { + const keys = Object.keys(rows); + if (keys.some((key) => !key.trim()) || new Set(keys.map((key) => key.toLowerCase())).size !== keys.length || Object.values(rows).some((value) => typeof value !== "string")) throw new Error(`${label} names must be non-empty, unique case-insensitively, and values must be strings.`); + } + + private async writeSecrets(id: string, draft: ManagedMcpDraft): Promise { + const values = draft.kind === "local" ? draft.environment : draft.headers; + await Promise.all(Object.entries(values).map(([key, value]) => this.context.secrets.store(this.secretKey(id, draft.kind === "local" ? "environment" : "header", key), value))); + if (draft.kind === "remote" && draft.oauth && draft.oauth.clientSecret) await this.context.secrets.store(this.secretKey(id, "oauth", "clientSecret"), draft.oauth.clientSecret); + } + + private async draftFromProfile(profile: ManagedMcpProfile): Promise { + if (profile.kind === "local" && profile.local) return { name: profile.name, kind: "local", command: profile.local.command, cwd: profile.local.cwd, timeout: profile.local.timeout, environment: await this.readSecrets(profile.id, "environment", profile.local.environmentKeys) }; + if (profile.remote) return { name: profile.name, kind: "remote", url: profile.remote.url, timeout: profile.remote.timeout, headers: await this.readSecrets(profile.id, "header", profile.remote.headerKeys), oauth: profile.remote.oauth ? { ...profile.remote.oauth, clientSecret: await this.context.secrets.get(this.secretKey(profile.id, "oauth", "clientSecret")) } : profile.remote.oauth }; + throw new Error("Managed MCP profile is incomplete."); + } + + private async readSecrets(id: string, kind: string, keys: string[]): Promise> { + const entries = await Promise.all(keys.map(async (key) => [key, await this.context.secrets.get(this.secretKey(id, kind, key)) ?? ""] as const)); + return Object.fromEntries(entries); + } + + private readProfiles(): ManagedMcpProfile[] { return this.context.workspaceState.get(STORAGE_KEY) ?? []; } + private async removeProfileData(profile: ManagedMcpProfile): Promise { + await this.context.workspaceState.update(STORAGE_KEY, this.profiles().filter((item) => item.id !== profile.id)); + const keys = [...(profile.local?.environmentKeys ?? []).map((key) => this.secretKey(profile.id, "environment", key)), ...(profile.remote?.headerKeys ?? []).map((key) => this.secretKey(profile.id, "header", key)), this.secretKey(profile.id, "oauth", "clientSecret")]; + await Promise.all(keys.map((key) => this.context.secrets.delete(key))); + } + private secretKey(id: string, kind: string, key: string): string { return `${SECRET_PREFIX}.${id}.${kind}.${key}`; } + private scope>(extra?: T): T & { directory?: string } { return { ...(extra ?? {}), directory: this.getWorkspaceDirectory() } as T & { directory?: string }; } + private safeError(error: unknown): string { return error instanceof Error ? error.message.slice(0, 500) : "MCP operation failed"; } + private async withServerLock(name: string, operation: () => Promise): Promise { const previous = this.mutationChains.get(name) ?? Promise.resolve(); const current = previous.then(operation, operation); this.mutationChains.set(name, current); try { return await current; } finally { if (this.mutationChains.get(name) === current) this.mutationChains.delete(name); } } +} diff --git a/tests/services/mcp-server-service.test.mjs b/tests/services/mcp-server-service.test.mjs new file mode 100644 index 0000000..cc03d1a --- /dev/null +++ b/tests/services/mcp-server-service.test.mjs @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { joinFromRoot, readSource } from "../helpers/source-utils.mjs"; + +const service = readSource([joinFromRoot("src", "services", "McpServerService.ts")], "McpServerService.ts"); +const provider = readSource([joinFromRoot("src", "providers", "ChatViewProvider.ts")], "ChatViewProvider.ts"); + +test("managed MCP profiles persist metadata separately from SecretStorage", () => { + assert.match(service, /opencode\.managedMcpProfiles\.v1/); + assert.match(service, /context\.secrets\.store/); + assert.match(service, /environmentKeys/); + assert.match(service, /headerKeys/); + const profileSection = service.slice(service.indexOf("export interface ManagedMcpProfile"), service.indexOf("export type ManagedMcpDraft")); + assert.doesNotMatch(profileSection, /environment\s*:/); +}); + +test("MCP mutations use the pinned SDK flat parameter shape and refresh status", () => { + assert.match(service, /client\.mcp\.add\(\{ \...this\.scope\(\{ name: profile\.name \}\), config:/); + assert.match(service, /mcp\.connect\(this\.scope\(\{ name \}\)\)/); + assert.match(service, /mcp\.disconnect\(this\.scope\(\{ name \}\)\)/); + assert.match(provider, /case "addMcpServer"/); + assert.match(provider, /await this\.handleGetMcpStatus\(\)/); +}); + +test("status enrichment exposes only safe managed metadata", () => { + assert.match(provider, /managed: true, profileId: profile\.id, kind: profile\.kind/); + assert.doesNotMatch(provider, /enrichedServers[\s\S]*environment/); + assert.doesNotMatch(provider, /enrichedServers[\s\S]*headers/); +}); diff --git a/webview/shared/src/chat/PanelComponents.tsx b/webview/shared/src/chat/PanelComponents.tsx index 5d324b6..baac9ab 100644 --- a/webview/shared/src/chat/PanelComponents.tsx +++ b/webview/shared/src/chat/PanelComponents.tsx @@ -4488,6 +4488,13 @@ export const McpPanel = memo(function McpPanel() { shallowEqual, ); const dispatch = useAppDispatch(); + const [showAdd, setShowAdd] = useState(false); + const [kind, setKind] = useState<"local" | "remote">("local"); + const [argumentRows, setArgumentRows] = useState([0]); + const [environmentRows, setEnvironmentRows] = useState([0]); + const [confirmLocal, setConfirmLocal] = useState(false); + const [formError, setFormError] = useState(); + const addFormRef = useRef(null); function toggleServer(name: string) { setExpandedServers((prev) => { @@ -4506,6 +4513,41 @@ export const McpPanel = memo(function McpPanel() { vscode.postMessage({ type: "getMcpStatus" }); } + function parseRows(value: string): Record { + return Object.fromEntries(value.split("\n").map((row) => row.trim()).filter(Boolean).map((row) => { + const separator = row.indexOf("="); + return [separator > 0 ? row.slice(0, separator).trim() : row, separator > 0 ? row.slice(separator + 1) : ""]; + })); + } + + function submitAdd() { + setFormError(undefined); + const formData = addFormRef.current ? new FormData(addFormRef.current) : undefined; + const value = (key: string) => String(formData?.get(key) ?? "").trim(); + const timeout = value("timeout"); + const environment = Object.fromEntries(environmentRows.map((row) => [value(`env-key-${row}`), value(`env-value-${row}`)]).filter(([key]) => key)); + const command = [value("command"), ...argumentRows.map((row) => value(`argument-${row}`)).filter(Boolean)]; + const config: Record = kind === "local" + ? { name: value("name"), command, cwd: value("cwd"), environment, timeout: timeout ? Number(timeout) : undefined } + : { name: value("name"), url: value("url"), headers: environment, timeout: timeout ? Number(timeout) : undefined }; + const name = typeof config.name === "string" ? config.name.trim() : ""; + if (!name) return setFormError("The object must include a name."); + if (kind === "local" && !confirmLocal) return setFormError("Confirm the local-process warning before adding this server."); + const draft = kind === "local" + ? { name, kind, command: config.command, cwd: config.cwd, environment: config.environment ?? {}, timeout: config.timeout } + : { name, kind, url: config.url, headers: config.headers ?? {}, oauth: config.oauth, timeout: config.timeout }; + vscode.postMessage({ type: "addMcpServer", requestID: crypto.randomUUID(), draft }); + addFormRef.current?.querySelectorAll('input[name^="env-value-"]').forEach((input) => { input.value = ""; }); + } + + function runServerAction(server: { name: string; managed?: boolean; profileId?: string; status: string }) { + if (server.status === "connected") { + vscode.postMessage({ type: "disconnectMcpServer", requestID: crypto.randomUUID(), name: server.name }); + } else { + vscode.postMessage({ type: "connectMcpServer", requestID: crypto.randomUUID(), name: server.name }); + } + } + const connectedCount = mcpServers.filter( (s) => s.status === "connected", ).length; @@ -4549,6 +4591,9 @@ export const McpPanel = memo(function McpPanel() {
MCP Servers
+
+
Type
+ {kind === "local" ? <> +
+
{argumentRows.map((row) =>
{argumentRows.length > 1 && }
)}
+ :
} +
{environmentRows.map((row) =>
{environmentRows.length > 1 && }
)}
+ {kind === "local" ?
+
:
} + {kind === "local" && } + {formError &&
{formError}
} +
+ + )} {!hasServers ? (
No MCP servers configured @@ -4607,6 +4668,10 @@ export const McpPanel = memo(function McpPanel() { ? `${server.tools.length} tools` : server.status} + {server.managed && <> + + + } {hasTools && (