diff --git a/src/browser/components/MCPAddServerForm/MCPAddServerForm.tsx b/src/browser/components/MCPAddServerForm/MCPAddServerForm.tsx new file mode 100644 index 0000000000..f45dcf2a8a --- /dev/null +++ b/src/browser/components/MCPAddServerForm/MCPAddServerForm.tsx @@ -0,0 +1,493 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { CheckCircle, ChevronRight, Loader2, Play, Plus, XCircle } from "lucide-react"; +import { Button } from "@/browser/components/Button/Button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/browser/components/SelectPrimitive/SelectPrimitive"; +import { MCPHeadersEditor } from "@/browser/components/MCPHeadersEditor/MCPHeadersEditor"; +import { MCPOAuthRequiredCallout } from "@/browser/components/MCPOAuth/MCPOAuth"; +import { useAPI } from "@/browser/contexts/API"; +import { usePolicy } from "@/browser/contexts/PolicyContext"; +import { useMCPTestCache } from "@/browser/hooks/useMCPTestCache"; +import { mcpHeaderRowsToRecord, type MCPHeaderRow } from "@/browser/utils/mcpHeaders"; +import { cn } from "@/common/lib/utils"; +import type { CachedMCPTestResult, MCPServerInfo, MCPServerTransport } from "@/common/types/mcp"; + +interface EditableServer { + name: string; + transport: MCPServerTransport; + /** command (stdio) or url (http/sse/auto) */ + value: string; + /** Headers (http/sse/auto only) */ + headersRows: MCPHeaderRow[]; +} + +interface MCPAddServerFormProps { + /** Existing global servers (used by the OAuth callout to detect collisions). */ + existingServers: Record; + /** Called after a successful add so the parent can refresh its list. */ + onAdded?: (name: string) => void | Promise; + /** + * When true, render the form inline (no expandable `
` wrapper). + * Defaults to false (Settings panel uses the expandable summary). + */ + alwaysExpanded?: boolean; + /** Optional className applied to the outer wrapper. */ + className?: string; +} + +/** + * Inline "Add MCP server" form. Writes to global config via `api.mcp.add`, + * with optional pre-add Test and an OAuth callout for remote servers that + * advertise a `WWW-Authenticate: Bearer` challenge. + * + * Extracted verbatim from `MCPSettingsSection`; shared between the global + * Settings panel and the per-chat "Manage MCP servers" modal. + */ +export const MCPAddServerForm: React.FC = ({ + existingServers, + onAdded, + alwaysExpanded = false, + className, +}) => { + const { api } = useAPI(); + const policyState = usePolicy(); + const mcpAllowUserDefined = + policyState.status.state === "enforced" ? policyState.policy?.mcp.allowUserDefined : undefined; + + const { setResult: cacheTestResult } = useMCPTestCache("__global__"); + const [globalSecretKeys, setGlobalSecretKeys] = useState([]); + + const [newServer, setNewServer] = useState({ + name: "", + transport: "stdio", + value: "", + headersRows: [], + }); + const [addingServer, setAddingServer] = useState(false); + const [testingNew, setTestingNew] = useState(false); + const [testingServer, setTestingServer] = useState(null); + const [newTestResult, setNewTestResult] = useState(null); + const [error, setError] = useState(null); + const [, setMcpOauthRefreshNonce] = useState(0); + + // Load global secrets (used for {secret:"KEY"} header values). + useEffect(() => { + if (!api) { + setGlobalSecretKeys([]); + return; + } + + let cancelled = false; + (async () => { + try { + const secrets = await api.secrets.get({}); + if (cancelled) return; + setGlobalSecretKeys(secrets.map((s) => s.key)); + } catch (err) { + if (cancelled) return; + console.error("Failed to load global secrets:", err); + setGlobalSecretKeys([]); + } + })(); + + return () => { + cancelled = true; + }; + }, [api]); + + // Ensure the "Add server" transport select always points to a policy-allowed value. + useEffect(() => { + if (!mcpAllowUserDefined) { + return; + } + + const isAllowed = (transport: MCPServerTransport): boolean => { + if (transport === "stdio") { + return mcpAllowUserDefined.stdio; + } + + return mcpAllowUserDefined.remote; + }; + + setNewServer((prev) => { + if (isAllowed(prev.transport)) { + return prev; + } + + const fallback: MCPServerTransport | null = mcpAllowUserDefined.stdio + ? "stdio" + : mcpAllowUserDefined.remote + ? "http" + : null; + + if (!fallback) { + return prev; + } + + return { ...prev, transport: fallback, value: "", headersRows: [] }; + }); + }, [mcpAllowUserDefined]); + + // Clear new-server test result when transport/value/headers change + useEffect(() => { + setNewTestResult(null); + }, [newServer.transport, newServer.value, newServer.headersRows]); + + const handleTestNewServer = useCallback(async () => { + if (!api || !newServer.value.trim()) return; + setTestingNew(true); + setNewTestResult(null); + + try { + const { headers, validation } = + newServer.transport === "stdio" + ? { headers: undefined, validation: { errors: [], warnings: [] } } + : mcpHeaderRowsToRecord(newServer.headersRows, { + knownSecretKeys: new Set(globalSecretKeys), + }); + + if (validation.errors.length > 0) { + throw new Error(validation.errors[0]); + } + + const pendingName = newServer.name.trim(); + + const result = await api.mcp.test({ + ...(newServer.transport === "stdio" + ? { command: newServer.value.trim() } + : { + ...(pendingName ? { name: pendingName } : {}), + transport: newServer.transport, + url: newServer.value.trim(), + headers, + }), + }); + + setNewTestResult({ result, testedAt: Date.now() }); + } catch (err) { + setNewTestResult({ + result: { success: false, error: err instanceof Error ? err.message : "Test failed" }, + testedAt: Date.now(), + }); + } finally { + setTestingNew(false); + } + }, [ + api, + newServer.name, + newServer.transport, + newServer.value, + newServer.headersRows, + globalSecretKeys, + ]); + + const handleAddServer = useCallback(async () => { + if (!api || !newServer.name.trim() || !newServer.value.trim()) return; + + const serverName = newServer.name.trim(); + const serverTransport = newServer.transport; + const serverValue = newServer.value.trim(); + const serverHeadersRows = newServer.headersRows; + const existingTestResult = newTestResult; + + setAddingServer(true); + setError(null); + + try { + const { headers, validation } = + serverTransport === "stdio" + ? { headers: undefined, validation: { errors: [], warnings: [] } } + : mcpHeaderRowsToRecord(serverHeadersRows, { + knownSecretKeys: new Set(globalSecretKeys), + }); + + if (validation.errors.length > 0) { + throw new Error(validation.errors[0]); + } + + const result = await api.mcp.add({ + name: serverName, + ...(serverTransport === "stdio" + ? { transport: "stdio", command: serverValue } + : { + transport: serverTransport, + url: serverValue, + headers, + }), + }); + + if (!result.success) { + setError(result.error ?? "Failed to add MCP server"); + return; + } + + setNewServer({ name: "", transport: "stdio", value: "", headersRows: [] }); + setNewTestResult(null); + await onAdded?.(serverName); + + // For stdio, avoid running arbitrary user-provided commands automatically. + if (serverTransport === "stdio") { + if (existingTestResult?.result.success) { + cacheTestResult(serverName, existingTestResult.result); + } + return; + } + + // For remote servers, always run a test immediately after adding so OAuth-required servers can + // surface an OAuth callout without requiring a manual Test click. + setTestingServer(serverName); + try { + const testResult = await api.mcp.test({ + name: serverName, + }); + cacheTestResult(serverName, testResult); + } catch (err) { + cacheTestResult(serverName, { + success: false, + error: err instanceof Error ? err.message : "Test failed", + }); + } finally { + setTestingServer(null); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to add MCP server"); + } finally { + setAddingServer(false); + } + }, [api, newServer, newTestResult, onAdded, cacheTestResult, globalSecretKeys]); + + const newHeadersValidation = + newServer.transport === "stdio" + ? { errors: [], warnings: [] } + : mcpHeaderRowsToRecord(newServer.headersRows, { + knownSecretKeys: new Set(globalSecretKeys), + }).validation; + + const canAdd = + newServer.name.trim().length > 0 && + newServer.value.trim().length > 0 && + (newServer.transport === "stdio" || newHeadersValidation.errors.length === 0); + + const canTest = + newServer.value.trim().length > 0 && + (newServer.transport === "stdio" || newHeadersValidation.errors.length === 0); + + // Suppress unused-var lint warning for testingServer (it is set so callers can disable + // dependent UI later; right now we just track it). + void testingServer; + + const body = ( +
+
+ + setNewServer((prev) => ({ ...prev, name: e.target.value }))} + className="bg-modal-bg border-border-medium focus:border-accent w-full rounded border px-2 py-1.5 text-sm focus:outline-none" + /> +
+ +
+ + +
+ +
+ + setNewServer((prev) => ({ ...prev, value: e.target.value }))} + spellCheck={false} + className="bg-modal-bg border-border-medium focus:border-accent w-full rounded border px-2 py-1.5 font-mono text-sm focus:outline-none" + /> +
+ + {newServer.transport !== "stdio" && ( +
+ + + setNewServer((prev) => ({ + ...prev, + headersRows: rows, + })) + } + secretKeys={globalSecretKeys} + disabled={addingServer || testingNew} + /> +
+ )} + + {/* Error surfacing for add failures (validation, oRPC errors, etc.) */} + {error && ( +
+ {error} +
+ )} + + {/* Test result */} + {newTestResult && ( +
+ {newTestResult.result.success ? ( + <> + +
+ + Connected — {newTestResult.result.tools.length} tools + + {newTestResult.result.tools.length > 0 && ( +

+ {newTestResult.result.tools.join(", ")} +

+ )} +
+ + ) : ( + <> + + {newTestResult.result.error} + + )} +
+ )} + + {newTestResult && !newTestResult.result.success && newTestResult.result.oauthChallenge && ( +
+ { + const pendingName = newServer.name.trim(); + if (!pendingName) { + return undefined; + } + + // If the server already exists in config, prefer that config for OAuth. + const existing = existingServers[pendingName]; + if (existing) { + return undefined; + } + + if (newServer.transport === "stdio") { + return undefined; + } + + const url = newServer.value.trim(); + if (!url) { + return undefined; + } + + return { transport: newServer.transport, url }; + })()} + disabledReason={(() => { + const pendingName = newServer.name.trim(); + if (!pendingName) { + return "Enter a server name to enable OAuth login."; + } + + const existing = existingServers[pendingName]; + + const transport = existing?.transport ?? newServer.transport; + if (transport === "stdio") { + return "OAuth login is only supported for remote (http/sse) MCP servers."; + } + + return undefined; + })()} + onLoginSuccess={async () => { + setMcpOauthRefreshNonce((prev) => prev + 1); + await handleTestNewServer(); + }} + /> +
+ )} +
+ + +
+
+ ); + + if (alwaysExpanded) { + return
{body}
; + } + + return ( +
+ + + Add server + + {body} +
+ ); +}; diff --git a/src/browser/components/MCPOAuth/MCPOAuth.tsx b/src/browser/components/MCPOAuth/MCPOAuth.tsx new file mode 100644 index 0000000000..8bbeec9a27 --- /dev/null +++ b/src/browser/components/MCPOAuth/MCPOAuth.tsx @@ -0,0 +1,357 @@ +import React, { useCallback, useRef, useState } from "react"; +import { Loader2 } from "lucide-react"; +import { Button } from "@/browser/components/Button/Button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/browser/components/Tooltip/Tooltip"; +import { useAPI } from "@/browser/contexts/API"; +import { getErrorMessage } from "@/common/utils/errors"; +import type { MCPOAuthPendingServerConfig } from "@/common/types/mcpOauth"; + +export type MCPOAuthLoginStatus = "idle" | "starting" | "waiting" | "success" | "error"; + +export interface MCPOAuthAuthStatus { + serverUrl?: string; + isLoggedIn: boolean; + hasRefreshToken: boolean; + scope?: string; + updatedAtMs?: number; +} + +export type MCPOAuthAPI = NonNullable["api"]>["mcpOauth"]; + +export function isRecord(value: unknown): value is Record { + // In dev-server (browser) mode, the ORPC client can surface namespaces/procedures as Proxy + // functions (callable objects). Treat functions as record-like so runtime guards don't + // incorrectly report "OAuth is not available". + if (value === null) return false; + const type = typeof value; + return type === "object" || type === "function"; +} + +/** + * Defensive runtime guard: `mcpOauth` may not exist when running against older backends + * or in non-desktop environments. Treat OAuth as unavailable instead of surfacing raw exceptions. + */ +export function getMCPOAuthAPI(api: ReturnType["api"]): MCPOAuthAPI | null { + if (!api) return null; + + // Avoid direct property access since `api.mcpOauth` may be missing at runtime. + const maybeOauth: unknown = Reflect.get(api, "mcpOauth"); + if (!isRecord(maybeOauth)) return null; + + const requiredFns = ["getAuthStatus", "logout"] as const; + + for (const fn of requiredFns) { + if (typeof maybeOauth[fn] !== "function") { + return null; + } + } + + // Login flow support depends on whether the client can complete the callback. + const hasDesktopFlowFns = + typeof maybeOauth.startDesktopFlow === "function" && + typeof maybeOauth.waitForDesktopFlow === "function" && + typeof maybeOauth.cancelDesktopFlow === "function"; + + const hasServerFlowFns = + typeof maybeOauth.startServerFlow === "function" && + typeof maybeOauth.waitForServerFlow === "function" && + typeof maybeOauth.cancelServerFlow === "function"; + + if (!hasDesktopFlowFns && !hasServerFlowFns) { + return null; + } + + return maybeOauth as unknown as MCPOAuthAPI; +} + +export type MCPOAuthLoginFlowMode = "desktop" | "server"; + +export function getMCPOAuthLoginFlowMode(input: { + isDesktop: boolean; + mcpOauthApi: MCPOAuthAPI | null; +}): MCPOAuthLoginFlowMode | null { + const api = input.mcpOauthApi; + if (!api || !isRecord(api)) { + return null; + } + + const hasDesktopFlowFns = + typeof api.startDesktopFlow === "function" && + typeof api.waitForDesktopFlow === "function" && + typeof api.cancelDesktopFlow === "function"; + + const hasServerFlowFns = + typeof api.startServerFlow === "function" && + typeof api.waitForServerFlow === "function" && + typeof api.cancelServerFlow === "function"; + + if (input.isDesktop) { + return hasDesktopFlowFns ? "desktop" : null; + } + + return hasServerFlowFns ? "server" : null; +} + +export function useMCPOAuthLogin(input: { + api: ReturnType["api"]; + isDesktop: boolean; + serverName: string; + pendingServer?: MCPOAuthPendingServerConfig; + onSuccess?: () => void | Promise; +}) { + const { api, isDesktop, serverName, pendingServer, onSuccess } = input; + const loginAttemptRef = useRef(0); + const [flowId, setFlowId] = useState(null); + + const [loginStatus, setLoginStatus] = useState("idle"); + const [loginError, setLoginError] = useState(null); + + const loginInProgress = loginStatus === "starting" || loginStatus === "waiting"; + + const cancelLogin = useCallback(() => { + loginAttemptRef.current++; + + const mcpOauthApi = getMCPOAuthAPI(api); + const loginFlowMode = getMCPOAuthLoginFlowMode({ + isDesktop, + mcpOauthApi, + }); + + if (mcpOauthApi && flowId && loginFlowMode === "desktop") { + void mcpOauthApi.cancelDesktopFlow({ flowId }); + } + + if (mcpOauthApi && flowId && loginFlowMode === "server") { + void mcpOauthApi.cancelServerFlow({ flowId }); + } + + setFlowId(null); + setLoginStatus("idle"); + setLoginError(null); + }, [api, flowId, isDesktop]); + + const startLogin = useCallback(async () => { + const attempt = ++loginAttemptRef.current; + + try { + setLoginError(null); + setFlowId(null); + + if (!api) { + setLoginStatus("error"); + setLoginError("Mux API not connected."); + return; + } + + if (!serverName.trim()) { + setLoginStatus("error"); + setLoginError("Server name is required to start OAuth login."); + return; + } + + const mcpOauthApi = getMCPOAuthAPI(api); + if (!mcpOauthApi) { + setLoginStatus("error"); + setLoginError("OAuth is not available in this environment."); + return; + } + + const loginFlowMode = getMCPOAuthLoginFlowMode({ + isDesktop, + mcpOauthApi, + }); + if (!loginFlowMode) { + setLoginStatus("error"); + setLoginError("OAuth login is not available in this environment."); + return; + } + + setLoginStatus("starting"); + + const startResult = + loginFlowMode === "desktop" + ? await mcpOauthApi.startDesktopFlow({ serverName, pendingServer }) + : await mcpOauthApi.startServerFlow({ serverName, pendingServer }); + + if (attempt !== loginAttemptRef.current) { + if (startResult.success) { + if (loginFlowMode === "desktop") { + void mcpOauthApi.cancelDesktopFlow({ flowId: startResult.data.flowId }); + } else { + void mcpOauthApi.cancelServerFlow({ flowId: startResult.data.flowId }); + } + } + return; + } + + if (!startResult.success) { + setLoginStatus("error"); + setLoginError(startResult.error); + return; + } + + const { flowId: nextFlowId, authorizeUrl } = startResult.data; + setFlowId(nextFlowId); + setLoginStatus("waiting"); + + // Desktop main process intercepts external window.open() calls and routes them via shell.openExternal. + // In browser mode, this opens a new tab/window. + // + // NOTE: In some browsers (especially when using `noopener`), `window.open()` may return null even when + // the tab opens successfully. Do not treat a null return value as a failure signal; keep the OAuth flow + // alive and show guidance to the user while we wait. + try { + window.open(authorizeUrl, "_blank", "noopener"); + } catch { + // Popups can be blocked or restricted by the browser. The user can cancel and retry after allowing + // popups; we intentionally do not auto-cancel the server flow here. + } + + if (attempt !== loginAttemptRef.current) { + return; + } + + const waitResult = + loginFlowMode === "desktop" + ? await mcpOauthApi.waitForDesktopFlow({ flowId: nextFlowId }) + : await mcpOauthApi.waitForServerFlow({ flowId: nextFlowId }); + + if (attempt !== loginAttemptRef.current) { + return; + } + + if (waitResult.success) { + setLoginStatus("success"); + await onSuccess?.(); + return; + } + + setLoginStatus("error"); + setLoginError(waitResult.error); + } catch (err) { + if (attempt !== loginAttemptRef.current) { + return; + } + + const message = getErrorMessage(err); + setLoginStatus("error"); + setLoginError(message); + } + }, [api, isDesktop, onSuccess, pendingServer, serverName]); + + return { + loginStatus, + loginError, + loginInProgress, + startLogin, + cancelLogin, + }; +} + +export const MCPOAuthRequiredCallout: React.FC<{ + serverName: string; + pendingServer?: MCPOAuthPendingServerConfig; + disabledReason?: string; + onLoginSuccess?: () => void | Promise; +}> = ({ serverName, pendingServer, disabledReason, onLoginSuccess }) => { + const { api } = useAPI(); + const isDesktop = !!window.api; + + const { loginStatus, loginError, loginInProgress, startLogin, cancelLogin } = useMCPOAuthLogin({ + api, + isDesktop, + serverName, + pendingServer, + onSuccess: onLoginSuccess, + }); + + const mcpOauthApi = getMCPOAuthAPI(api); + const loginFlowMode = getMCPOAuthLoginFlowMode({ + isDesktop, + mcpOauthApi, + }); + + const disabledTitle = + disabledReason ?? + (!api + ? "Mux API not connected" + : !mcpOauthApi + ? "OAuth is not available in this environment." + : !loginFlowMode + ? isDesktop + ? "OAuth login is not available in this environment." + : "OAuth login is only available in the desktop app." + : undefined); + + const loginDisabled = Boolean(disabledReason) || !api || !loginFlowMode || loginInProgress; + + const loginButton = ( + + ); + + return ( +
+
+
+

This server requires OAuth.

+ {disabledReason &&

{disabledReason}

} + + {loginStatus === "waiting" && ( + <> +

+ Finish the login flow in your browser, then return here. +

+ {!isDesktop && ( +

+ If a new tab didn't open, your browser may have blocked the popup. Allow + popups and try again. +

+ )} + + )} + + {loginStatus === "success" &&

Logged in.

} + + {loginStatus === "error" && loginError && ( +

OAuth error: {loginError}

+ )} +
+ +
+ {disabledTitle ? ( + + + {loginButton} + + {disabledTitle} + + ) : ( + loginButton + )} + + {loginStatus === "waiting" && ( + + )} +
+
+
+ ); +}; diff --git a/src/browser/components/ProjectMCPOverview/ProjectMCPOverview.tsx b/src/browser/components/ProjectMCPOverview/ProjectMCPOverview.tsx index d0caa54cf3..3e8128c202 100644 --- a/src/browser/components/ProjectMCPOverview/ProjectMCPOverview.tsx +++ b/src/browser/components/ProjectMCPOverview/ProjectMCPOverview.tsx @@ -1,23 +1,34 @@ import React from "react"; -import { Loader2, Plus, Server } from "lucide-react"; -import type { MCPServerInfo } from "@/common/types/mcp"; +import { Loader2, Server, SlidersHorizontal } from "lucide-react"; +import type { MCPServerInfo, WorkspaceMCPOverrides } from "@/common/types/mcp"; import { useAPI } from "@/browser/contexts/API"; import { useSettings } from "@/browser/contexts/SettingsContext"; import { Button } from "@/browser/components/Button/Button"; +import { WorkspaceMCPModal } from "@/browser/components/WorkspaceMCPModal/WorkspaceMCPModal"; import { getMCPServersKey } from "@/common/constants/storage"; import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { effectiveEnabledServerNames, hasAnyOverride } from "@/common/utils/workspaceMcpEffective"; interface ProjectMCPOverviewProps { projectPath: string; + /** + * Per-workspace MCP overrides currently staged on the creation page. The + * overview reflects the *effective* set (project servers + staged overrides) + * so the count matches what the workspace will actually see. + */ + stagedOverrides?: WorkspaceMCPOverrides; + /** Called when the user saves changes from the "Manage MCP servers" modal. */ + onStagedOverridesChange?: (next: WorkspaceMCPOverrides) => void; } export const ProjectMCPOverview: React.FC = (props) => { - const projectPath = props.projectPath; + const { projectPath, stagedOverrides, onStagedOverridesChange } = props; const { api } = useAPI(); const settings = useSettings(); const [loading, setLoading] = React.useState(false); const [error, setError] = React.useState(null); + const [manageOpen, setManageOpen] = React.useState(false); // Initialize from localStorage cache to avoid flash const [servers, setServers] = React.useState>(() => readPersistedState>(getMCPServersKey(projectPath), {}) @@ -54,50 +65,90 @@ export const ProjectMCPOverview: React.FC = (props) => }; }, [api, projectPath, settings.isOpen]); - const enabledServerNames = Object.entries(servers) - .filter(([, info]) => !info.disabled) - .map(([name]) => name) - .sort((a, b) => a.localeCompare(b)); + // Refetch servers whenever the manage modal closes (it may have added new ones). + React.useEffect(() => { + if (!api || manageOpen) return; + let cancelled = false; + api.mcp + .list({ projectPath }) + .then((result) => { + if (cancelled) return; + const newServers = result ?? {}; + setServers(newServers); + updatePersistedState(getMCPServersKey(projectPath), newServers); + }) + .catch(() => { + // Errors are surfaced by the main load effect; this is just opportunistic. + }); + return () => { + cancelled = true; + }; + }, [api, projectPath, manageOpen]); + + // Compute the effective enabled set: project defaults overridden by staged workspace overrides. + const enabledServerNames = React.useMemo( + () => effectiveEnabledServerNames(servers, stagedOverrides), + [servers, stagedOverrides] + ); const shownServerNames = enabledServerNames.slice(0, 3); const remainingCount = enabledServerNames.length - shownServerNames.length; + const isModified = hasAnyOverride(stagedOverrides); return ( -
-
- + <> +
+
+ -
-
- - MCP Servers ({enabledServerNames.length} enabled) - - {loading && } +
+
+ + MCP Servers ({enabledServerNames.length} enabled) + + {isModified && ( + (modified for this chat) + )} + {loading && } +
+ + {error ? ( +
{error}
+ ) : enabledServerNames.length === 0 ? ( +
+ No MCP servers enabled for this project. +
+ ) : ( +
+ {shownServerNames.join(", ")} + {remainingCount > 0 && ( + +{remainingCount} more + )} +
+ )}
- {error ? ( -
{error}
- ) : enabledServerNames.length === 0 ? ( -
No MCP servers enabled for this project.
- ) : ( -
- {shownServerNames.join(", ")} - {remainingCount > 0 && +{remainingCount} more} -
- )} +
- -
-
+ + onStagedOverridesChange?.(next)} + /> + ); }; diff --git a/src/browser/components/ProjectPage/ProjectPage.tsx b/src/browser/components/ProjectPage/ProjectPage.tsx index 5b70a79ea0..bfa1a935f6 100644 --- a/src/browser/components/ProjectPage/ProjectPage.tsx +++ b/src/browser/components/ProjectPage/ProjectPage.tsx @@ -1,6 +1,7 @@ -import React, { useRef, useCallback, useState, useEffect } from "react"; +import React, { useRef, useCallback, useState, useEffect, useMemo } from "react"; import { Menu } from "lucide-react"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; +import type { WorkspaceMCPOverrides } from "@/common/types/mcp"; import { cn } from "@/common/lib/utils"; import { AgentProvider } from "@/browser/contexts/AgentContext"; import { ThinkingProvider } from "@/browser/contexts/ThinkingContext"; @@ -210,6 +211,40 @@ export const ProjectPage: React.FC = ({ const didAutoFocusRef = useRef(false); + /** + * Per-draft staged MCP overrides for the workspace this chat will create. + * + * Keyed by `pendingDraftId ?? "__pending__"` (matches the same scope ID + * ChatInput uses for draft persistence). When the user submits, the staged + * overrides for the active draft are forwarded to ChatInput/useCreationWorkspace, + * which calls `api.workspace.mcp.set` after the workspace is created. + * + * Reset entirely on projectPath change so drafts can't leak across projects. + */ + const [stagedMcpOverridesByDraft, setStagedMcpOverridesByDraft] = useState< + Map + >(() => new Map()); + + useEffect(() => { + setStagedMcpOverridesByDraft(new Map()); + }, [projectPath]); + + const activeDraftKey = pendingDraftId ?? "__pending__"; + const stagedMcpOverrides = useMemo( + () => stagedMcpOverridesByDraft.get(activeDraftKey) ?? {}, + [stagedMcpOverridesByDraft, activeDraftKey] + ); + const handleStagedMcpOverridesChange = useCallback( + (next: WorkspaceMCPOverrides) => { + setStagedMcpOverridesByDraft((prev) => { + const map = new Map(prev); + map.set(activeDraftKey, next); + return map; + }); + }, + [activeDraftKey] + ); + const handleDismissAgentsInit = useCallback(() => { setShowAgentsInitNudge(false); }, [setShowAgentsInitNudge]); @@ -331,6 +366,7 @@ export const ProjectPage: React.FC = ({ pendingDraftId={pendingDraftId} onReady={handleChatReady} onWorkspaceCreated={onWorkspaceCreated} + stagedMcpOverrides={stagedMcpOverrides} /> )} @@ -340,7 +376,11 @@ export const ProjectPage: React.FC = ({ {/* MCP servers: overview between creation and archived workspaces */}
- +
diff --git a/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.stories.tsx b/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.stories.tsx index 01e050d9d0..0119f73e8a 100644 --- a/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.stories.tsx +++ b/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.stories.tsx @@ -1,5 +1,5 @@ import { useRef } from "react"; -import type { FC, ReactNode } from "react"; +import type { ComponentType, FC, ReactNode } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, waitFor, within } from "@storybook/test"; @@ -15,7 +15,7 @@ import { createMockORPCClient } from "@/browser/stories/mocks/orpc"; import { getMCPTestResultsKey } from "@/common/constants/storage"; import type { MCPServerInfo } from "@/common/types/mcp"; -import { WorkspaceMCPModal } from "./WorkspaceMCPModal"; +import { WorkspaceMCPModal, type WorkspaceMCPModalWorkspaceProps } from "./WorkspaceMCPModal"; const PROJECT_PATH = "/Users/test/my-app"; const WORKSPACE_ID = "ws-mcp-test"; @@ -189,9 +189,15 @@ async function findWorkspaceMCPDialog(canvasElement: HTMLElement): Promise = { +// Stories use their own `render` and ignore args, but Storybook's typed StoryObj +// requires args to satisfy the component's prop type. WorkspaceMCPModal accepts +// a discriminated union (workspace mode vs. draft mode); pin the stories to the +// workspace-mode shape so `StoryObj` doesn't demand `never` for args. +const meta: Meta = { title: "Components/WorkspaceMCPModal", - component: WorkspaceMCPModal, + // Cast: WorkspaceMCPModal's prop type is a discriminated union; the stories all + // render in workspace mode, so we narrow the meta's component to that branch. + component: WorkspaceMCPModal as ComponentType, parameters: { layout: "fullscreen", chromatic: { @@ -202,7 +208,7 @@ const meta: Meta = { export default meta; -type Story = StoryObj; +type Story = StoryObj; export const WorkspaceMCPNoOverrides: Story = { render: () => diff --git a/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.tsx b/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.tsx index bfd722fbf5..9eb736b72e 100644 --- a/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.tsx +++ b/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.tsx @@ -14,41 +14,82 @@ import { } from "@/browser/components/Dialog/Dialog"; import { useMCPTestCache } from "@/browser/hooks/useMCPTestCache"; import { ToolSelector } from "@/browser/components/ToolSelector/ToolSelector"; - -interface WorkspaceMCPModalProps { +import { MCPAddServerForm } from "@/browser/components/MCPAddServerForm/MCPAddServerForm"; +import { + isServerEffectivelyEnabled, + toggleServerOverride, +} from "@/common/utils/workspaceMcpEffective"; + +/** + * Workspace mode: load+save overrides for an existing workspace via api.workspace.mcp.{get,set}. + * Used from inside an open workspace (kebab menu → "Configure MCP servers"). + */ +export interface WorkspaceMCPModalWorkspaceProps { + mode?: "workspace"; workspaceId: string; projectPath: string; open: boolean; onOpenChange: (open: boolean) => void; } -export const WorkspaceMCPModal: React.FC = ({ - workspaceId, - projectPath, - open, - onOpenChange, -}) => { +/** + * Draft mode: no workspace exists yet (creation page). Overrides are staged in + * the caller's state via `initialOverrides`/`onSave`; persistence happens when + * the chat is actually submitted and the workspace is created. + * + * In draft mode the tool-allowlist UI is hidden (per product decision) and the + * built-in "Add server" form is shown inline so the user does not have to + * detour through Settings. + */ +interface WorkspaceMCPModalDraftProps { + mode: "draft"; + projectPath: string; + open: boolean; + onOpenChange: (open: boolean) => void; + /** Overrides currently staged in the parent (used as the starting point). */ + initialOverrides: WorkspaceMCPOverrides; + /** Called on Save with the new staged overrides; the parent owns persistence. */ + onSave: (overrides: WorkspaceMCPOverrides) => void; +} + +type WorkspaceMCPModalProps = WorkspaceMCPModalWorkspaceProps | WorkspaceMCPModalDraftProps; + +export const WorkspaceMCPModal: React.FC = (props) => { + const isDraft = props.mode === "draft"; + const mode: "workspace" | "draft" = isDraft ? "draft" : "workspace"; + const { projectPath, open, onOpenChange } = props; + const workspaceId = isDraft ? null : props.workspaceId; + const draftInitialOverrides = isDraft ? props.initialOverrides : null; + const draftOnSave = isDraft ? props.onSave : null; + const settings = useSettings(); const { api } = useAPI(); // State for project servers and workspace overrides const [servers, setServers] = useState>({}); - const [overrides, setOverrides] = useState({}); + const [overrides, setOverrides] = useState( + () => draftInitialOverrides ?? {} + ); const [loadingTools, setLoadingTools] = useState>({}); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); - // Use shared cache for tool test results + // Use shared cache for tool test results (workspace mode only; harmless in draft). const { getTools, setResult, reload: reloadCache } = useMCPTestCache(projectPath); // Ref so the effect can call reloadCache without depending on its identity. - // We only want to re-fire the effect when the modal opens (open/api/ids change), - // not when the cache hook recreates the reload callback. const reloadCacheRef = useRef(reloadCache); reloadCacheRef.current = reloadCache; - // Load project servers and workspace overrides when modal opens + // Snapshot the initial-overrides reference so we can reset the local state when the + // modal re-opens in draft mode (without resetting on every parent re-render). + const initialOverridesRef = useRef(draftInitialOverrides); + if (isDraft) { + initialOverridesRef.current = draftInitialOverrides; + } + + // Load project servers (and, in workspace mode, the persisted overrides) when the modal opens. useEffect(() => { if (!open || !api) return; @@ -59,12 +100,21 @@ export const WorkspaceMCPModal: React.FC = ({ setLoading(true); setError(null); try { - const [projectServers, workspaceOverrides] = await Promise.all([ - api.mcp.list({ projectPath }), - api.workspace.mcp.get({ workspaceId }), - ]); - setServers(projectServers ?? {}); - setOverrides(workspaceOverrides ?? {}); + const projectServersPromise = api.mcp.list({ projectPath }); + + if (mode === "workspace" && workspaceId) { + const [projectServers, workspaceOverrides] = await Promise.all([ + projectServersPromise, + api.workspace.mcp.get({ workspaceId }), + ]); + setServers(projectServers ?? {}); + setOverrides(workspaceOverrides ?? {}); + } else { + const projectServers = await projectServersPromise; + setServers(projectServers ?? {}); + // Draft mode: reset to the snapshot we took when the modal opened. + setOverrides(initialOverridesRef.current ?? {}); + } } catch (err) { setError(err instanceof Error ? err.message : "Failed to load MCP configuration"); } finally { @@ -73,9 +123,20 @@ export const WorkspaceMCPModal: React.FC = ({ }; void loadData(); - }, [open, api, projectPath, workspaceId]); + }, [open, api, projectPath, workspaceId, mode]); + + // Refresh the project servers list (used after adding a server in draft mode). + const refreshServers = useCallback(async () => { + if (!api) return; + try { + const result = await api.mcp.list({ projectPath }); + setServers(result ?? {}); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load MCP servers"); + } + }, [api, projectPath]); - // Fetch/refresh tools for a server + // Fetch/refresh tools for a server (workspace mode only — allowlist UI is hidden in draft). const fetchTools = useCallback( async (serverName: string) => { if (!api) return; @@ -97,58 +158,19 @@ export const WorkspaceMCPModal: React.FC = ({ /** * Determine if a server is effectively enabled for this workspace. - * Logic: - * - If in enabledServers: enabled (overrides project disabled) - * - If in disabledServers: disabled (overrides project enabled) - * - Otherwise: use project-level state (info.disabled) + * Shared three-way rule lives in @/common/utils/workspaceMcpEffective so the + * creation-page count and the modal toggles always agree. */ const isServerEnabled = useCallback( - (serverName: string, projectDisabled: boolean): boolean => { - if (overrides.enabledServers?.includes(serverName)) return true; - if (overrides.disabledServers?.includes(serverName)) return false; - return !projectDisabled; - }, - [overrides.enabledServers, overrides.disabledServers] + (serverName: string, projectDisabled: boolean): boolean => + isServerEffectivelyEnabled(serverName, projectDisabled, overrides), + [overrides] ); // Toggle server enabled/disabled for workspace const toggleServerEnabled = useCallback( (serverName: string, enabled: boolean, projectDisabled: boolean) => { - setOverrides((prev) => { - const currentEnabled = prev.enabledServers ?? []; - const currentDisabled = prev.disabledServers ?? []; - - let newEnabled: string[]; - let newDisabled: string[]; - - if (enabled) { - // Enabling the server - newDisabled = currentDisabled.filter((s) => s !== serverName); - if (projectDisabled) { - // Need explicit enable to override project disabled - newEnabled = [...currentEnabled, serverName]; - } else { - // Project already enabled, just remove from disabled list - newEnabled = currentEnabled.filter((s) => s !== serverName); - } - } else { - // Disabling the server - newEnabled = currentEnabled.filter((s) => s !== serverName); - if (projectDisabled) { - // Project already disabled, just remove from enabled list - newDisabled = currentDisabled.filter((s) => s !== serverName); - } else { - // Need explicit disable to override project enabled - newDisabled = [...currentDisabled, serverName]; - } - } - - return { - ...prev, - enabledServers: newEnabled.length > 0 ? newEnabled : undefined, - disabledServers: newDisabled.length > 0 ? newDisabled : undefined, - }; - }); + setOverrides((prev) => toggleServerOverride(prev, serverName, enabled, projectDisabled)); }, [] ); @@ -231,9 +253,17 @@ export const WorkspaceMCPModal: React.FC = ({ }); }, []); - // Save overrides + // Save overrides. + // In workspace mode this persists immediately via api.workspace.mcp.set. + // In draft mode this delegates to the parent (which stages the overrides for later persistence). const handleSave = useCallback(async () => { - if (!api) return; + if (isDraft) { + draftOnSave?.(overrides); + onOpenChange(false); + return; + } + + if (!api || !workspaceId) return; setSaving(true); setError(null); try { @@ -248,7 +278,7 @@ export const WorkspaceMCPModal: React.FC = ({ } finally { setSaving(false); } - }, [api, workspaceId, overrides, onOpenChange]); + }, [api, workspaceId, overrides, onOpenChange, isDraft, draftOnSave]); const serverEntries = Object.entries(servers); @@ -258,13 +288,18 @@ export const WorkspaceMCPModal: React.FC = ({ }, [onOpenChange, settings]); const hasServers = serverEntries.length > 0; + const title = isDraft ? "MCP servers for this chat" : "Workspace MCP Configuration"; + const subtitle = isDraft + ? "Customize which MCP servers are available for the workspace this chat will create." + : "Customize which MCP servers and tools are available in this workspace. Changes only affect this workspace."; + return ( - Workspace MCP Configuration + {title} @@ -272,29 +307,10 @@ export const WorkspaceMCPModal: React.FC = ({
- ) : !hasServers ? ( -
-

No MCP servers configured for this project.

-

- Configure servers in{" "} - {" "} - to use them here. -

-
) : (
-

- Customize which MCP servers and tools are available in this workspace. Changes only - affect this workspace. -

+

{subtitle}

)} -
- {serverEntries.map(([name, info]) => { - const projectDisabled = info.disabled; - const effectivelyEnabled = isServerEnabled(name, projectDisabled); - const tools = getTools(name); - const isLoadingTools = loadingTools[name]; - const allowedTools = overrides.toolAllowlist?.[name] ?? tools ?? []; - - return ( -
-
-
- - toggleServerEnabled(name, checked, projectDisabled) - } - aria-label={`Toggle ${name} MCP server`} - /> -
-
{name}
- {projectDisabled && ( -
(disabled at project level)
- )} + {!hasServers ? ( +
+ {isDraft ? ( + "No MCP servers configured yet. Add one below." + ) : ( + <> +

No MCP servers configured for this project.

+

+ Configure servers in{" "} + {" "} + to use them here. +

+ + )} +
+ ) : ( +
+ {serverEntries.map(([name, info]) => { + const projectDisabled = info.disabled; + const effectivelyEnabled = isServerEnabled(name, projectDisabled); + const tools = getTools(name); + const isLoadingTools = loadingTools[name]; + const allowedTools = overrides.toolAllowlist?.[name] ?? tools ?? []; + + return ( +
+
+
+ + toggleServerEnabled(name, checked, projectDisabled) + } + aria-label={`Toggle ${name} MCP server`} + /> +
+
{name}
+ {projectDisabled && ( +
(disabled at project level)
+ )} +
+ {/* Tool allowlist UI is workspace-only (out of scope for the creation modal). */} + {!isDraft && effectivelyEnabled && ( + + )}
- {effectivelyEnabled && ( - +
+ )} + + {!isDraft && effectivelyEnabled && tools?.length === 0 && ( +
No tools available
)}
+ ); + })} +
+ )} - {/* Tool allowlist section */} - {effectivelyEnabled && tools && tools.length > 0 && ( -
- toggleToolAllowed(name, tool, allowed)} - onSelectAll={() => setAllToolsAllowed(name)} - onSelectNone={() => setNoToolsAllowed(name)} - /> - {!hasNoAllowlist(name) && ( -
- {allowedTools.length} of {tools.length} tools enabled -
- )} -
- )} - - {effectivelyEnabled && tools?.length === 0 && ( -
No tools available
- )} -
- ); - })} -
+ {/* Inline "Add server" — draft mode only (workspace mode keeps Settings → MCP as the canonical add surface). */} + {isDraft && ( +
+ refreshServers()} /> +
+ Need to edit or remove a server?{" "} + + . +
+
+ )}
)} diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index d1772bae2e..8108ebeeeb 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -849,6 +849,7 @@ const ChatInputInner: React.FC = (props) => { message: creationNameMessage, draftId: props.pendingDraftId, userModel: preferredModel, + stagedMcpOverrides: props.stagedMcpOverrides, } : { // Dummy values for workspace variant (never used) diff --git a/src/browser/features/ChatInput/types.ts b/src/browser/features/ChatInput/types.ts index 96b9a13040..0516219cf0 100644 --- a/src/browser/features/ChatInput/types.ts +++ b/src/browser/features/ChatInput/types.ts @@ -3,6 +3,7 @@ import type { TelemetryRuntimeType } from "@/common/telemetry/payload"; import type { Review } from "@/common/types/review"; import type { EditingMessageState, PendingUserMessage } from "@/browser/utils/chatEditing"; import type { SendMessageOptions } from "@/common/orpc/types"; +import type { WorkspaceMCPOverrides } from "@/common/types/mcp"; export type GoalInterventionPolicy = NonNullable; export type QueueDispatchMode = NonNullable; @@ -79,6 +80,13 @@ export interface ChatInputCreationVariant { onModelChange?: (model: string) => void; disabled?: boolean; onReady?: (api: ChatInputAPI) => void; + /** + * Per-workspace MCP overrides staged in the creation UI (e.g. via the + * "Manage MCP servers" modal on the project page). When provided and + * non-empty, they are persisted via `api.workspace.mcp.set` immediately + * after the workspace is successfully created. + */ + stagedMcpOverrides?: WorkspaceMCPOverrides; } export type ChatInputProps = ChatInputWorkspaceVariant | ChatInputCreationVariant; diff --git a/src/browser/features/ChatInput/useCreationWorkspace.ts b/src/browser/features/ChatInput/useCreationWorkspace.ts index 429b3af1d9..b6fc7a0784 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.ts +++ b/src/browser/features/ChatInput/useCreationWorkspace.ts @@ -39,6 +39,8 @@ import { ConfirmationModal } from "@/browser/components/ConfirmationModal/Confir import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig"; import type { FilePart, SendMessageOptions } from "@/common/orpc/types"; import type { WorkspaceCreatedOptions } from "@/browser/features/ChatInput/types"; +import type { WorkspaceMCPOverrides } from "@/common/types/mcp"; +import { hasAnyOverride } from "@/common/utils/workspaceMcpEffective"; import type { ParsedCommand } from "@/browser/utils/slashCommands/types"; import { processSlashCommand, type SlashCommandContext } from "@/browser/utils/chatCommands"; import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; @@ -77,6 +79,13 @@ interface UseCreationWorkspaceOptions { draftId?: string | null; /** User's currently selected model (for name generation fallback) */ userModel?: string; + /** + * Per-workspace MCP overrides staged by the user (via the "Manage MCP servers" + * modal on the project page). Applied via `api.workspace.mcp.set` immediately + * after workspace creation succeeds; non-blocking and fire-and-forget so the + * initial prompt can start streaming without waiting on the override write. + */ + stagedMcpOverrides?: WorkspaceMCPOverrides; } function syncCreationPreferences(projectPath: string, workspaceId: string): void { @@ -215,6 +224,7 @@ export function useCreationWorkspace({ subProjectPath, draftId, userModel, + stagedMcpOverrides, }: UseCreationWorkspaceOptions): UseCreationWorkspaceReturn { const workspaceContext = useOptionalWorkspaceContext(); const promoteWorkspaceDraft = workspaceContext?.promoteWorkspaceDraft; @@ -517,6 +527,29 @@ export function useCreationWorkspace({ const { metadata } = createResult; createdWorkspaceId = metadata.id; + // Best-effort: persist per-workspace MCP overrides staged on the creation page. + // Fire-and-forget so navigation / first prompt are not blocked by this write + // (remote runtimes like SSH/Coder can make the write slow). If it fails, the + // workspace simply uses project defaults; the user can re-apply via the + // post-creation "Configure MCP servers" modal. + if (stagedMcpOverrides && hasAnyOverride(stagedMcpOverrides)) { + void api.workspace.mcp + .set({ workspaceId: metadata.id, overrides: stagedMcpOverrides }) + .then((result) => { + if (!result.success) { + console.warn( + `Failed to apply staged MCP overrides to workspace ${metadata.id}: ${result.error}` + ); + } + }) + .catch((err) => { + console.warn( + `Failed to apply staged MCP overrides to workspace ${metadata.id}:`, + err + ); + }); + } + // Best-effort: persist the initial AI settings to the backend immediately so this workspace // is portable across devices even before the first stream starts. Initial /goal commands do // not send a normal user message, so they await this write before setting the goal; that lets @@ -694,6 +727,7 @@ export function useCreationWorkspace({ promoteWorkspaceDraft, deleteWorkspaceDraft, providersConfig, + stagedMcpOverrides, ] ); diff --git a/src/browser/features/Settings/Sections/MCPSettingsSection.tsx b/src/browser/features/Settings/Sections/MCPSettingsSection.tsx index 917369dcd9..6eddf6805e 100644 --- a/src/browser/features/Settings/Sections/MCPSettingsSection.tsx +++ b/src/browser/features/Settings/Sections/MCPSettingsSection.tsx @@ -1,13 +1,11 @@ -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import { usePolicy } from "@/browser/contexts/PolicyContext"; import { useAPI } from "@/browser/contexts/API"; import { Trash2, Play, Loader2, - CheckCircle, XCircle, - Plus, Pencil, Check, X, @@ -16,20 +14,12 @@ import { ChevronRight, } from "lucide-react"; import { Button } from "@/browser/components/Button/Button"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/browser/components/SelectPrimitive/SelectPrimitive"; import { createEditKeyHandler } from "@/browser/utils/ui/keybinds"; import { Switch } from "@/browser/components/Switch/Switch"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/browser/components/Tooltip/Tooltip"; import { cn } from "@/common/lib/utils"; import { formatRelativeTime } from "@/browser/utils/ui/dateTime"; -import type { CachedMCPTestResult, MCPServerInfo, MCPServerTransport } from "@/common/types/mcp"; -import type { MCPOAuthPendingServerConfig } from "@/common/types/mcpOauth"; +import type { MCPServerInfo, MCPServerTransport } from "@/common/types/mcp"; import { useMCPTestCache } from "@/browser/hooks/useMCPTestCache"; import { MCPHeadersEditor } from "@/browser/components/MCPHeadersEditor/MCPHeadersEditor"; import { @@ -40,6 +30,14 @@ import { import { ToolSelector } from "@/browser/components/ToolSelector/ToolSelector"; import { KebabMenu, type KebabMenuItem } from "@/browser/components/KebabMenu/KebabMenu"; import { getErrorMessage } from "@/common/utils/errors"; +import { MCPAddServerForm } from "@/browser/components/MCPAddServerForm/MCPAddServerForm"; +import { + getMCPOAuthAPI, + getMCPOAuthLoginFlowMode, + MCPOAuthRequiredCallout, + useMCPOAuthLogin, + type MCPOAuthAuthStatus, +} from "@/browser/components/MCPOAuth/MCPOAuth"; /** Component for managing tool allowlist for a single MCP server */ const ToolAllowlistSection: React.FC<{ @@ -172,356 +170,6 @@ const ToolAllowlistSection: React.FC<{ ); }; -type MCPOAuthLoginStatus = "idle" | "starting" | "waiting" | "success" | "error"; - -interface MCPOAuthAuthStatus { - serverUrl?: string; - isLoggedIn: boolean; - hasRefreshToken: boolean; - scope?: string; - updatedAtMs?: number; -} - -type MCPOAuthAPI = NonNullable["api"]>["mcpOauth"]; - -function isRecord(value: unknown): value is Record { - // In dev-server (browser) mode, the ORPC client can surface namespaces/procedures as Proxy - // functions (callable objects). Treat functions as record-like so runtime guards don't - // incorrectly report "OAuth is not available". - if (value === null) return false; - const type = typeof value; - return type === "object" || type === "function"; -} - -/** - * Defensive runtime guard: `mcpOauth` may not exist when running against older backends - * or in non-desktop environments. Treat OAuth as unavailable instead of surfacing raw exceptions. - */ -function getMCPOAuthAPI(api: ReturnType["api"]): MCPOAuthAPI | null { - if (!api) return null; - - // Avoid direct property access since `api.mcpOauth` may be missing at runtime. - const maybeOauth: unknown = Reflect.get(api, "mcpOauth"); - if (!isRecord(maybeOauth)) return null; - - const requiredFns = ["getAuthStatus", "logout"] as const; - - for (const fn of requiredFns) { - if (typeof maybeOauth[fn] !== "function") { - return null; - } - } - - // Login flow support depends on whether the client can complete the callback. - const hasDesktopFlowFns = - typeof maybeOauth.startDesktopFlow === "function" && - typeof maybeOauth.waitForDesktopFlow === "function" && - typeof maybeOauth.cancelDesktopFlow === "function"; - - const hasServerFlowFns = - typeof maybeOauth.startServerFlow === "function" && - typeof maybeOauth.waitForServerFlow === "function" && - typeof maybeOauth.cancelServerFlow === "function"; - - if (!hasDesktopFlowFns && !hasServerFlowFns) { - return null; - } - - return maybeOauth as unknown as MCPOAuthAPI; -} - -type MCPOAuthLoginFlowMode = "desktop" | "server"; - -function getMCPOAuthLoginFlowMode(input: { - isDesktop: boolean; - mcpOauthApi: MCPOAuthAPI | null; -}): MCPOAuthLoginFlowMode | null { - const api = input.mcpOauthApi; - if (!api || !isRecord(api)) { - return null; - } - - const hasDesktopFlowFns = - typeof api.startDesktopFlow === "function" && - typeof api.waitForDesktopFlow === "function" && - typeof api.cancelDesktopFlow === "function"; - - const hasServerFlowFns = - typeof api.startServerFlow === "function" && - typeof api.waitForServerFlow === "function" && - typeof api.cancelServerFlow === "function"; - - if (input.isDesktop) { - return hasDesktopFlowFns ? "desktop" : null; - } - - return hasServerFlowFns ? "server" : null; -} - -function useMCPOAuthLogin(input: { - api: ReturnType["api"]; - isDesktop: boolean; - serverName: string; - pendingServer?: MCPOAuthPendingServerConfig; - onSuccess?: () => void | Promise; -}) { - const { api, isDesktop, serverName, pendingServer, onSuccess } = input; - const loginAttemptRef = useRef(0); - const [flowId, setFlowId] = useState(null); - - const [loginStatus, setLoginStatus] = useState("idle"); - const [loginError, setLoginError] = useState(null); - - const loginInProgress = loginStatus === "starting" || loginStatus === "waiting"; - - const cancelLogin = useCallback(() => { - loginAttemptRef.current++; - - const mcpOauthApi = getMCPOAuthAPI(api); - const loginFlowMode = getMCPOAuthLoginFlowMode({ - isDesktop, - mcpOauthApi, - }); - - if (mcpOauthApi && flowId && loginFlowMode === "desktop") { - void mcpOauthApi.cancelDesktopFlow({ flowId }); - } - - if (mcpOauthApi && flowId && loginFlowMode === "server") { - void mcpOauthApi.cancelServerFlow({ flowId }); - } - - setFlowId(null); - setLoginStatus("idle"); - setLoginError(null); - }, [api, flowId, isDesktop]); - - const startLogin = useCallback(async () => { - const attempt = ++loginAttemptRef.current; - - try { - setLoginError(null); - setFlowId(null); - - if (!api) { - setLoginStatus("error"); - setLoginError("Mux API not connected."); - return; - } - - if (!serverName.trim()) { - setLoginStatus("error"); - setLoginError("Server name is required to start OAuth login."); - return; - } - - const mcpOauthApi = getMCPOAuthAPI(api); - if (!mcpOauthApi) { - setLoginStatus("error"); - setLoginError("OAuth is not available in this environment."); - return; - } - - const loginFlowMode = getMCPOAuthLoginFlowMode({ - isDesktop, - mcpOauthApi, - }); - if (!loginFlowMode) { - setLoginStatus("error"); - setLoginError("OAuth login is not available in this environment."); - return; - } - - setLoginStatus("starting"); - - const startResult = - loginFlowMode === "desktop" - ? await mcpOauthApi.startDesktopFlow({ serverName, pendingServer }) - : await mcpOauthApi.startServerFlow({ serverName, pendingServer }); - - if (attempt !== loginAttemptRef.current) { - if (startResult.success) { - if (loginFlowMode === "desktop") { - void mcpOauthApi.cancelDesktopFlow({ flowId: startResult.data.flowId }); - } else { - void mcpOauthApi.cancelServerFlow({ flowId: startResult.data.flowId }); - } - } - return; - } - - if (!startResult.success) { - setLoginStatus("error"); - setLoginError(startResult.error); - return; - } - - const { flowId: nextFlowId, authorizeUrl } = startResult.data; - setFlowId(nextFlowId); - setLoginStatus("waiting"); - - // Desktop main process intercepts external window.open() calls and routes them via shell.openExternal. - // In browser mode, this opens a new tab/window. - // - // NOTE: In some browsers (especially when using `noopener`), `window.open()` may return null even when - // the tab opens successfully. Do not treat a null return value as a failure signal; keep the OAuth flow - // alive and show guidance to the user while we wait. - try { - window.open(authorizeUrl, "_blank", "noopener"); - } catch { - // Popups can be blocked or restricted by the browser. The user can cancel and retry after allowing - // popups; we intentionally do not auto-cancel the server flow here. - } - - if (attempt !== loginAttemptRef.current) { - return; - } - - const waitResult = - loginFlowMode === "desktop" - ? await mcpOauthApi.waitForDesktopFlow({ flowId: nextFlowId }) - : await mcpOauthApi.waitForServerFlow({ flowId: nextFlowId }); - - if (attempt !== loginAttemptRef.current) { - return; - } - - if (waitResult.success) { - setLoginStatus("success"); - await onSuccess?.(); - return; - } - - setLoginStatus("error"); - setLoginError(waitResult.error); - } catch (err) { - if (attempt !== loginAttemptRef.current) { - return; - } - - const message = getErrorMessage(err); - setLoginStatus("error"); - setLoginError(message); - } - }, [api, isDesktop, onSuccess, pendingServer, serverName]); - - return { - loginStatus, - loginError, - loginInProgress, - startLogin, - cancelLogin, - }; -} - -const MCPOAuthRequiredCallout: React.FC<{ - serverName: string; - pendingServer?: MCPOAuthPendingServerConfig; - disabledReason?: string; - onLoginSuccess?: () => void | Promise; -}> = ({ serverName, pendingServer, disabledReason, onLoginSuccess }) => { - const { api } = useAPI(); - const isDesktop = !!window.api; - - const { loginStatus, loginError, loginInProgress, startLogin, cancelLogin } = useMCPOAuthLogin({ - api, - isDesktop, - serverName, - pendingServer, - onSuccess: onLoginSuccess, - }); - - const mcpOauthApi = getMCPOAuthAPI(api); - const loginFlowMode = getMCPOAuthLoginFlowMode({ - isDesktop, - mcpOauthApi, - }); - - const disabledTitle = - disabledReason ?? - (!api - ? "Mux API not connected" - : !mcpOauthApi - ? "OAuth is not available in this environment." - : !loginFlowMode - ? isDesktop - ? "OAuth login is not available in this environment." - : "OAuth login is only available in the desktop app." - : undefined); - - const loginDisabled = Boolean(disabledReason) || !api || !loginFlowMode || loginInProgress; - - const loginButton = ( - - ); - - return ( -
-
-
-

This server requires OAuth.

- {disabledReason &&

{disabledReason}

} - - {loginStatus === "waiting" && ( - <> -

- Finish the login flow in your browser, then return here. -

- {!isDesktop && ( -

- If a new tab didn't open, your browser may have blocked the popup. Allow - popups and try again. -

- )} - - )} - - {loginStatus === "success" &&

Logged in.

} - - {loginStatus === "error" && loginError && ( -

OAuth error: {loginError}

- )} -
- -
- {disabledTitle ? ( - - - {loginButton} - - {disabledTitle} - - ) : ( - loginButton - )} - - {loginStatus === "waiting" && ( - - )} -
-
-
- ); -}; - const RemoteMCPOAuthSection: React.FC<{ serverName: string; transport: Exclude; @@ -746,50 +394,6 @@ export const MCPSettingsSection: React.FC = () => { headersRows: MCPHeaderRow[]; } - // Add form state - - // Ensure the "Add server" transport select always points to a policy-allowed value. - useEffect(() => { - if (!mcpAllowUserDefined) { - return; - } - - const isAllowed = (transport: MCPServerTransport): boolean => { - if (transport === "stdio") { - return mcpAllowUserDefined.stdio; - } - - return mcpAllowUserDefined.remote; - }; - - setNewServer((prev) => { - if (isAllowed(prev.transport)) { - return prev; - } - - const fallback: MCPServerTransport | null = mcpAllowUserDefined.stdio - ? "stdio" - : mcpAllowUserDefined.remote - ? "http" - : null; - - if (!fallback) { - return prev; - } - - return { ...prev, transport: fallback, value: "", headersRows: [] }; - }); - }, [mcpAllowUserDefined]); - const [newServer, setNewServer] = useState({ - name: "", - transport: "stdio", - value: "", - headersRows: [], - }); - const [addingServer, setAddingServer] = useState(false); - const [testingNew, setTestingNew] = useState(false); - const [newTestResult, setNewTestResult] = useState(null); - // Edit state const [editing, setEditing] = useState(null); const [savingEdit, setSavingEdit] = useState(false); @@ -837,11 +441,6 @@ export const MCPSettingsSection: React.FC = () => { void refresh(); }, [refresh]); - // Clear new-server test result when transport/value/headers change - useEffect(() => { - setNewTestResult(null); - }, [newServer.transport, newServer.value, newServer.headersRows]); - const handleRemove = useCallback( async (name: string) => { if (!api) return; @@ -918,129 +517,6 @@ export const MCPSettingsSection: React.FC = () => { const serverDisplayValue = (entry: MCPServerInfo): string => entry.transport === "stdio" ? entry.command : entry.url; - const handleTestNewServer = useCallback(async () => { - if (!api || !newServer.value.trim()) return; - setTestingNew(true); - setNewTestResult(null); - - try { - const { headers, validation } = - newServer.transport === "stdio" - ? { headers: undefined, validation: { errors: [], warnings: [] } } - : mcpHeaderRowsToRecord(newServer.headersRows, { - knownSecretKeys: new Set(globalSecretKeys), - }); - - if (validation.errors.length > 0) { - throw new Error(validation.errors[0]); - } - - const pendingName = newServer.name.trim(); - - const result = await api.mcp.test({ - ...(newServer.transport === "stdio" - ? { command: newServer.value.trim() } - : { - ...(pendingName ? { name: pendingName } : {}), - transport: newServer.transport, - url: newServer.value.trim(), - headers, - }), - }); - - setNewTestResult({ result, testedAt: Date.now() }); - } catch (err) { - setNewTestResult({ - result: { success: false, error: err instanceof Error ? err.message : "Test failed" }, - testedAt: Date.now(), - }); - } finally { - setTestingNew(false); - } - }, [ - api, - newServer.name, - newServer.transport, - newServer.value, - newServer.headersRows, - globalSecretKeys, - ]); - - const handleAddServer = useCallback(async () => { - if (!api || !newServer.name.trim() || !newServer.value.trim()) return; - - const serverName = newServer.name.trim(); - const serverTransport = newServer.transport; - const serverValue = newServer.value.trim(); - const serverHeadersRows = newServer.headersRows; - const existingTestResult = newTestResult; - - setAddingServer(true); - setError(null); - - try { - const { headers, validation } = - serverTransport === "stdio" - ? { headers: undefined, validation: { errors: [], warnings: [] } } - : mcpHeaderRowsToRecord(serverHeadersRows, { - knownSecretKeys: new Set(globalSecretKeys), - }); - - if (validation.errors.length > 0) { - throw new Error(validation.errors[0]); - } - - const result = await api.mcp.add({ - name: serverName, - ...(serverTransport === "stdio" - ? { transport: "stdio", command: serverValue } - : { - transport: serverTransport, - url: serverValue, - headers, - }), - }); - - if (!result.success) { - setError(result.error ?? "Failed to add MCP server"); - return; - } - - setNewServer({ name: "", transport: "stdio", value: "", headersRows: [] }); - setNewTestResult(null); - await refresh(); - - // For stdio, avoid running arbitrary user-provided commands automatically. - if (serverTransport === "stdio") { - if (existingTestResult?.result.success) { - cacheTestResult(serverName, existingTestResult.result); - } - return; - } - - // For remote servers, always run a test immediately after adding so OAuth-required servers can - // surface an OAuth callout without requiring a manual Test click. - setTestingServer(serverName); - try { - const testResult = await api.mcp.test({ - name: serverName, - }); - cacheTestResult(serverName, testResult); - } catch (err) { - cacheTestResult(serverName, { - success: false, - error: err instanceof Error ? err.message : "Test failed", - }); - } finally { - setTestingServer(null); - } - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to add MCP server"); - } finally { - setAddingServer(false); - } - }, [api, newServer, newTestResult, refresh, cacheTestResult, globalSecretKeys]); - const handleStartEdit = useCallback((name: string, entry: MCPServerInfo) => { setEditing({ name, @@ -1097,22 +573,6 @@ export const MCPSettingsSection: React.FC = () => { } }, [api, editing, refresh, clearTestResult, globalSecretKeys]); - const newHeadersValidation = - newServer.transport === "stdio" - ? { errors: [], warnings: [] } - : mcpHeaderRowsToRecord(newServer.headersRows, { - knownSecretKeys: new Set(globalSecretKeys), - }).validation; - - const canAdd = - newServer.name.trim().length > 0 && - newServer.value.trim().length > 0 && - (newServer.transport === "stdio" || newHeadersValidation.errors.length === 0); - - const canTest = - newServer.value.trim().length > 0 && - (newServer.transport === "stdio" || newHeadersValidation.errors.length === 0); - const editHeadersValidation = editing && editing.transport !== "stdio" ? mcpHeaderRowsToRecord(editing.headersRows, { @@ -1405,206 +865,7 @@ export const MCPSettingsSection: React.FC = () => {
{/* Add server form */} -
- - - Add server - -
-
- - setNewServer((prev) => ({ ...prev, name: e.target.value }))} - className="bg-modal-bg border-border-medium focus:border-accent w-full rounded border px-2 py-1.5 text-sm focus:outline-none" - /> -
- -
- - -
- -
- - setNewServer((prev) => ({ ...prev, value: e.target.value }))} - spellCheck={false} - className="bg-modal-bg border-border-medium focus:border-accent w-full rounded border px-2 py-1.5 font-mono text-sm focus:outline-none" - /> -
- - {newServer.transport !== "stdio" && ( -
- - - setNewServer((prev) => ({ - ...prev, - headersRows: rows, - })) - } - secretKeys={globalSecretKeys} - disabled={addingServer || testingNew} - /> -
- )} - - {/* Test result */} - {newTestResult && ( -
- {newTestResult.result.success ? ( - <> - -
- - Connected — {newTestResult.result.tools.length} tools - - {newTestResult.result.tools.length > 0 && ( -

- {newTestResult.result.tools.join(", ")} -

- )} -
- - ) : ( - <> - - {newTestResult.result.error} - - )} -
- )} - - {newTestResult && - !newTestResult.result.success && - newTestResult.result.oauthChallenge && ( -
- { - const pendingName = newServer.name.trim(); - if (!pendingName) { - return undefined; - } - - // If the server already exists in config, prefer that config for OAuth. - const existing = servers[pendingName]; - if (existing) { - return undefined; - } - - if (newServer.transport === "stdio") { - return undefined; - } - - const url = newServer.value.trim(); - if (!url) { - return undefined; - } - - return { transport: newServer.transport, url }; - })()} - disabledReason={(() => { - const pendingName = newServer.name.trim(); - if (!pendingName) { - return "Enter a server name to enable OAuth login."; - } - - const existing = servers[pendingName]; - - const transport = existing?.transport ?? newServer.transport; - if (transport === "stdio") { - return "OAuth login is only supported for remote (http/sse) MCP servers."; - } - - return undefined; - })()} - onLoginSuccess={async () => { - setMcpOauthRefreshNonce((prev) => prev + 1); - await handleTestNewServer(); - }} - /> -
- )} -
- - -
-
-
+ refresh()} /> )}
diff --git a/src/common/utils/workspaceMcpEffective.test.ts b/src/common/utils/workspaceMcpEffective.test.ts new file mode 100644 index 0000000000..2ef7a80b72 --- /dev/null +++ b/src/common/utils/workspaceMcpEffective.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "bun:test"; +import type { MCPServerInfo, WorkspaceMCPOverrides } from "@/common/types/mcp"; +import { + effectiveEnabledServerNames, + hasAnyOverride, + isServerEffectivelyEnabled, + toggleServerOverride, +} from "./workspaceMcpEffective"; + +const stdio = (disabled: boolean): MCPServerInfo => ({ + transport: "stdio", + command: "noop", + disabled, +}); + +describe("workspaceMcpEffective", () => { + describe("isServerEffectivelyEnabled", () => { + it("returns project-level enabled state when no overrides", () => { + expect(isServerEffectivelyEnabled("a", false, undefined)).toBe(true); + expect(isServerEffectivelyEnabled("a", true, undefined)).toBe(false); + expect(isServerEffectivelyEnabled("a", true, {})).toBe(false); + }); + + it("enabledServers wins over project disabled", () => { + expect(isServerEffectivelyEnabled("a", true, { enabledServers: ["a"] })).toBe(true); + }); + + it("disabledServers wins over project enabled", () => { + expect(isServerEffectivelyEnabled("a", false, { disabledServers: ["a"] })).toBe(false); + }); + + it("enabledServers takes precedence over disabledServers", () => { + expect( + isServerEffectivelyEnabled("a", false, { + enabledServers: ["a"], + disabledServers: ["a"], + }) + ).toBe(true); + }); + }); + + describe("effectiveEnabledServerNames", () => { + it("returns sorted enabled set from project defaults", () => { + const servers: Record = { + b: stdio(false), + a: stdio(false), + c: stdio(true), + }; + expect(effectiveEnabledServerNames(servers, undefined)).toEqual(["a", "b"]); + }); + + it("applies overrides on top of project defaults", () => { + const servers: Record = { + a: stdio(false), + b: stdio(false), + c: stdio(true), + }; + const overrides: WorkspaceMCPOverrides = { + disabledServers: ["a"], + enabledServers: ["c"], + }; + expect(effectiveEnabledServerNames(servers, overrides)).toEqual(["b", "c"]); + }); + }); + + describe("toggleServerOverride", () => { + it("when project-enabled, adds to disabledServers when toggled off", () => { + const next = toggleServerOverride({}, "a", false, false); + expect(next.disabledServers).toEqual(["a"]); + expect(next.enabledServers).toBeUndefined(); + }); + + it("when project-enabled, removes from disabledServers when toggled on", () => { + const next = toggleServerOverride({ disabledServers: ["a"] }, "a", true, false); + expect(next.disabledServers).toBeUndefined(); + expect(next.enabledServers).toBeUndefined(); + }); + + it("when project-disabled, adds to enabledServers when toggled on", () => { + const next = toggleServerOverride({}, "a", true, true); + expect(next.enabledServers).toEqual(["a"]); + expect(next.disabledServers).toBeUndefined(); + }); + + it("when project-disabled, removes from enabledServers when toggled off", () => { + const next = toggleServerOverride({ enabledServers: ["a"] }, "a", false, true); + expect(next.enabledServers).toBeUndefined(); + expect(next.disabledServers).toBeUndefined(); + }); + + it("preserves toolAllowlist when toggling enable state", () => { + const next = toggleServerOverride({ toolAllowlist: { a: ["x"] } }, "a", false, false); + expect(next.toolAllowlist).toEqual({ a: ["x"] }); + expect(next.disabledServers).toEqual(["a"]); + }); + + it("does not duplicate entries when toggling repeatedly", () => { + let s: WorkspaceMCPOverrides = {}; + s = toggleServerOverride(s, "a", false, false); + s = toggleServerOverride(s, "a", false, false); + expect(s.disabledServers).toEqual(["a"]); + }); + }); + + describe("hasAnyOverride", () => { + it("returns false for undefined / empty", () => { + expect(hasAnyOverride(undefined)).toBe(false); + expect(hasAnyOverride({})).toBe(false); + expect(hasAnyOverride({ enabledServers: [], disabledServers: [] })).toBe(false); + }); + + it("returns true when any field has content", () => { + expect(hasAnyOverride({ enabledServers: ["a"] })).toBe(true); + expect(hasAnyOverride({ disabledServers: ["a"] })).toBe(true); + expect(hasAnyOverride({ toolAllowlist: { a: [] } })).toBe(true); + }); + }); +}); diff --git a/src/common/utils/workspaceMcpEffective.ts b/src/common/utils/workspaceMcpEffective.ts new file mode 100644 index 0000000000..ddebd79395 --- /dev/null +++ b/src/common/utils/workspaceMcpEffective.ts @@ -0,0 +1,94 @@ +import type { MCPServerInfo, WorkspaceMCPOverrides } from "@/common/types/mcp"; + +/** + * Resolve whether a server is effectively enabled for a workspace, applying + * workspace overrides on top of the project-level `disabled` flag. + * + * Precedence (highest first): + * 1. overrides.enabledServers → enabled + * 2. overrides.disabledServers → disabled + * 3. !projectDisabled (the project-level state) + */ +export function isServerEffectivelyEnabled( + serverName: string, + projectDisabled: boolean, + overrides: WorkspaceMCPOverrides | undefined +): boolean { + if (overrides?.enabledServers?.includes(serverName)) return true; + if (overrides?.disabledServers?.includes(serverName)) return false; + return !projectDisabled; +} + +/** + * Compute the sorted list of server names that are effectively enabled when the + * given overrides are applied to a project-level servers map. + */ +export function effectiveEnabledServerNames( + servers: Record, + overrides: WorkspaceMCPOverrides | undefined +): string[] { + return Object.entries(servers) + .filter(([name, info]) => isServerEffectivelyEnabled(name, info.disabled, overrides)) + .map(([name]) => name) + .sort((a, b) => a.localeCompare(b)); +} + +/** + * Toggle a server's effective enabled state in a workspace overrides bag. + * Returns a new overrides object; the input is not mutated. + * + * The semantics mirror `WorkspaceMCPModal.toggleServerEnabled`: + * - When the requested state matches the project default, the explicit + * entry is removed (so the workspace simply inherits the project state). + * - When the requested state differs, an explicit entry is added. + */ +export function toggleServerOverride( + overrides: WorkspaceMCPOverrides, + serverName: string, + enabled: boolean, + projectDisabled: boolean +): WorkspaceMCPOverrides { + const currentEnabled = overrides.enabledServers ?? []; + const currentDisabled = overrides.disabledServers ?? []; + + let newEnabled: string[]; + let newDisabled: string[]; + + if (enabled) { + newDisabled = currentDisabled.filter((s) => s !== serverName); + if (projectDisabled) { + newEnabled = currentEnabled.includes(serverName) + ? currentEnabled + : [...currentEnabled, serverName]; + } else { + newEnabled = currentEnabled.filter((s) => s !== serverName); + } + } else { + newEnabled = currentEnabled.filter((s) => s !== serverName); + if (projectDisabled) { + newDisabled = currentDisabled.filter((s) => s !== serverName); + } else { + newDisabled = currentDisabled.includes(serverName) + ? currentDisabled + : [...currentDisabled, serverName]; + } + } + + return { + ...overrides, + enabledServers: newEnabled.length > 0 ? newEnabled : undefined, + disabledServers: newDisabled.length > 0 ? newDisabled : undefined, + }; +} + +/** + * True when overrides contain any signal worth persisting (any enable/disable + * entry or any allowlist entry). + */ +export function hasAnyOverride(overrides: WorkspaceMCPOverrides | undefined): boolean { + if (!overrides) return false; + if (overrides.enabledServers && overrides.enabledServers.length > 0) return true; + if (overrides.disabledServers && overrides.disabledServers.length > 0) return true; + if (overrides.toolAllowlist && Object.keys(overrides.toolAllowlist).length > 0) return true; + return false; +}