From 15dd8b29e36ae7d4f0a61851ef8847314e72d4ca Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Fri, 21 Aug 2026 23:32:23 -0700 Subject: [PATCH] fix(mcp): sync provider settings across thread sessions - Respect provider-owned MCP configuration and WSL availability in composer UI - Re-resolve MCP launch settings during session recovery and reloads - Prevent state emissions after sessions close and expand regression coverage - Localize provider settings actions across supported languages --- .../composer/ComposerAddMenu.test.tsx | 39 +++++ .../components/composer/ComposerAddMenu.tsx | 7 +- .../components/composer/composerMcpServers.ts | 21 ++- .../thread/ThreadComposerSection.test.tsx | 117 ++++++++++++++ .../thread/ThreadComposerSection.tsx | 56 ++++--- .../thread/ThreadDraftComposerArea.tsx | 153 +++++++++++++----- .../thread/ThreadDraftView.test.tsx | 48 ++++++ src/renderer/locales/de/messages.po | 4 + src/renderer/locales/en/messages.po | 4 + src/renderer/locales/es/messages.po | 4 + src/renderer/locales/fr/messages.po | 4 + src/renderer/locales/ja/messages.po | 4 + src/renderer/locales/ko/messages.po | 4 + src/renderer/locales/pl/messages.po | 4 + src/renderer/locales/pt-BR/messages.po | 4 + src/renderer/locales/ru/messages.po | 4 + src/renderer/locales/tr/messages.po | 4 + src/renderer/locales/uk/messages.po | 4 + src/renderer/locales/vi/messages.po | 4 + src/renderer/locales/zh-CN/messages.po | 4 + src/shared/contracts/agent.ts | 8 +- src/supervisor/agents/opencode/detection.ts | 3 +- .../invalidSessionRecovery.test.ts | 4 + .../threadSession/invalidSessionRecovery.ts | 17 +- .../runtime/threadSession/spawnPipeline.ts | 94 +++++++---- .../threadSessionManager.startClose.test.ts | 54 ++++++- .../runtime/threadSessionManager.ts | 20 ++- 27 files changed, 579 insertions(+), 114 deletions(-) diff --git a/src/renderer/components/composer/ComposerAddMenu.test.tsx b/src/renderer/components/composer/ComposerAddMenu.test.tsx index d9a96752b..f914ac3dd 100644 --- a/src/renderer/components/composer/ComposerAddMenu.test.tsx +++ b/src/renderer/components/composer/ComposerAddMenu.test.tsx @@ -34,6 +34,18 @@ describe("ComposerAddMenu", () => { bridgeMock.isRemoteSession.mockReturnValue(false); }); + it("keeps Chrome unavailable for WSL projects", () => { + expect( + chromeMcpServer.isAvailable({ + kind: "wsl", + distro: "Ubuntu", + linuxPath: "/home/demo/repo", + uncPath: "\\\\wsl.localhost\\Ubuntu\\home\\demo\\repo", + }), + ).toBe(false); + expect(chromeMcpServer.isAvailable({ kind: "windows", path: "C:\\repo" })).toBe(true); + }); + it("keeps the desktop dropdown trigger free of nested buttons", () => { const { container } = render( void>()} />, @@ -324,6 +336,33 @@ describe("ComposerAddMenu", () => { expect(browserToggle).not.toHaveBeenCalled(); }); + it("accepts provider-settings guidance for read-only draft bindings", () => { + render( + void>(), + }, + ]} + showFileOption={false} + onPickFiles={vi.fn<() => void>()} + />, + ); + + openMenu(); + openMcpSubmenu(); + + expect(screen.getByText("Change servers in provider settings")).toBeInTheDocument(); + expect( + screen.queryByText("Set when this session started — start a new thread to change servers"), + ).not.toBeInTheDocument(); + }); + it("shows an explicit empty state in read-only mode with no servers", () => { render( Set when this session started — start a new thread to change servers + (props.readOnlyCaption ?? ( + Set when this session started — start a new thread to change servers + )) ) : ( Enabled servers stay on for new threads ); diff --git a/src/renderer/components/composer/composerMcpServers.ts b/src/renderer/components/composer/composerMcpServers.ts index 23e1da08a..f875795a0 100644 --- a/src/renderer/components/composer/composerMcpServers.ts +++ b/src/renderer/components/composer/composerMcpServers.ts @@ -35,10 +35,8 @@ export const resolveMcpScope = resolveComposerMcpScope; /** * Providers that declare `mcpConfigSource: "agentSettings"` configure MCP on - * their settings page instead of the composer: the "+" menu shows no MCP rows - * at all for their threads (built-ins are hidden by their `"none"` scopes; - * callers use this to suppress the custom-server rows and read-only fallbacks - * too). + * their settings page instead of the composer: the "+" menu shows their + * effective MCP rows read-only instead of exposing per-thread toggles. */ export function providerOwnsMcpConfig( capabilities: Pick, @@ -46,6 +44,15 @@ export function providerOwnsMcpConfig( return capabilities.mcpConfigSource === "agentSettings"; } +/** Resolve a provider-owned MCP flag the same way as the supervisor runtime. */ +export function providerMcpSettingEnabled( + capabilities: Pick, + settings: Record | undefined, + key: ComposerMcpConfigKey | "computerUse", +): boolean { + return (settings?.[key] ?? capabilities.agentSettingsDefaults?.[key]) === true; +} + export interface ComposerMcpServerDescriptor { id: "browser" | "crossagents" | "chrome"; configKey: ComposerMcpConfigKey; @@ -56,6 +63,7 @@ export interface ComposerMcpServerDescriptor { enabledTitle: MessageDescriptor; /** aria-label for the chip's remove button. */ disableLabel: MessageDescriptor; + isAvailable: (projectLocation?: ProjectLocation) => boolean; getScope: ( capabilities: AgentCapability, presentationMode: ThreadPresentationMode, @@ -70,6 +78,7 @@ export const browserMcpServer: ComposerMcpServerDescriptor = { label: msg`Browser`, enabledTitle: msg`Browser MCP enabled for this thread`, disableLabel: msg`Disable Browser MCP`, + isAvailable: () => true, getScope: (capabilities, presentationMode) => resolveMcpScope(capabilities.mcpScope, presentationMode), }; @@ -81,6 +90,7 @@ export const crossagentMcpServer: ComposerMcpServerDescriptor = { label: msg`Crossagents`, enabledTitle: msg`Crossagents enabled for this thread`, disableLabel: msg`Disable Crossagents`, + isAvailable: () => true, getScope: (capabilities, presentationMode) => resolveMcpScope(capabilities.mcpScope, presentationMode), }; @@ -92,8 +102,9 @@ export const chromeMcpServer: ComposerMcpServerDescriptor = { label: msg`Chrome`, enabledTitle: msg`Chrome MCP enabled for this thread`, disableLabel: msg`Disable Chrome MCP`, + isAvailable: (projectLocation) => projectLocation?.kind !== "wsl", getScope: (capabilities, presentationMode, projectLocation) => - projectLocation?.kind === "wsl" + !chromeMcpServer.isAvailable(projectLocation) ? "none" : resolveMcpScope(capabilities.mcpScope, presentationMode), }; diff --git a/src/renderer/components/thread/ThreadComposerSection.test.tsx b/src/renderer/components/thread/ThreadComposerSection.test.tsx index 0db9ac116..aba07b878 100644 --- a/src/renderer/components/thread/ThreadComposerSection.test.tsx +++ b/src/renderer/components/thread/ThreadComposerSection.test.tsx @@ -45,6 +45,8 @@ const analytics = vi.hoisted(() => ({ captureThreadPromptSubmitted: vi.fn<() => void>(), })); +const composerAddMenuSpy = vi.hoisted(() => vi.fn<(props: unknown) => void>()); + vi.mock("@/renderer/analytics/posthog", async (importOriginal) => ({ ...(await importOriginal()), captureThreadPromptSubmitted: analytics.captureThreadPromptSubmitted, @@ -67,6 +69,13 @@ vi.mock("@/renderer/actions/agentLoginActions", () => ({ runAgentLoginCommand: loginActions.runAgentLoginCommand, })); +vi.mock("../composer/ComposerAddMenu", () => ({ + ComposerAddMenu: (props: unknown) => { + composerAddMenuSpy(props); + return null; + }, +})); + vi.mock("../../bridge", () => ({ isRemoteSession: bridgeMock.isRemoteSession, readBridge: () => ({ @@ -92,6 +101,8 @@ vi.mock("./ThreadComposer", () => ({ fixedContent?: ReactNode; attachmentBar?: ReactNode; inputContent?: ReactNode; + leadingControls?: ReactNode | (() => ReactNode); + afterControls?: ReactNode | (() => ReactNode); onAttachFiles?: (paths: string[]) => void; onStop?: () => void; onSubmit: () => void; @@ -101,6 +112,10 @@ vi.mock("./ThreadComposer", () => ({ {props.fixedContent} {props.attachmentBar} {props.inputContent} + {typeof props.leadingControls === "function" + ? props.leadingControls() + : props.leadingControls} + {typeof props.afterControls === "function" ? props.afterControls() : props.afterControls} {props.controls?.map((control) => control.kind ?? control.label ?? "").join(",") ?? ""} @@ -242,6 +257,8 @@ describe("ThreadComposerSection", () => { pendingComposerFocusThreadId: null, threadDraftContents: {}, provisioningWorktreeThreadIds: {}, + runtimeLaunchConfigByThreadId: {}, + mcpLaunchCustomServerNamesByThreadId: {}, }); useGitStore.setState({ statuses: {} }); useComposerInputInbox.setState({ itemsByComposer: {} }); @@ -256,6 +273,7 @@ describe("ThreadComposerSection", () => { bridgeMock.setPendingSteer.mockResolvedValue(undefined); analytics.captureProductEvent.mockClear(); analytics.captureThreadPromptSubmitted.mockClear(); + composerAddMenuSpy.mockClear(); runtimeActions.changeThreadConfig.mockClear(); runtimeActions.resolveThreadServerRequest.mockClear(); runtimeActions.resolveThreadServerRequest.mockResolvedValue(undefined); @@ -442,6 +460,105 @@ describe("ThreadComposerSection", () => { expect(screen.queryByRole("option")).not.toBeInTheDocument(); }); + it("shows provider-owned enabled MCPs in the indicator and @ mentions", () => { + useAppStore.setState({ + runtimeLaunchConfigByThreadId: { + [guiThread.id]: { model: "gpt-5.4", crossagentMcp: true }, + }, + mcpLaunchCustomServerNamesByThreadId: { + [guiThread.id]: ["Vision-MCP"], + }, + }); + const rangeRectDescriptor = Object.getOwnPropertyDescriptor( + Range.prototype, + "getBoundingClientRect", + ); + const scrollIntoViewDescriptor = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + "scrollIntoView", + ); + Object.defineProperty(Range.prototype, "getBoundingClientRect", { + configurable: true, + value: () => ({ left: 0, top: 0 }), + }); + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: () => undefined, + }); + + try { + renderComposer({ + agentStatus: { + ...codexGuiStatus, + capabilities: { + ...codexGuiStatus.capabilities, + mcpConfigSource: "agentSettings", + }, + }, + }); + + const menuProps = composerAddMenuSpy.mock.lastCall?.[0] as { + mcpServers: Array<{ descriptor: { id: string }; visible: boolean }>; + customMcpServers: Array<{ name: string; enabled: boolean }>; + readOnly: boolean; + }; + expect( + menuProps.mcpServers + .filter((server) => server.visible) + .map((server) => server.descriptor.id), + ).toEqual(["crossagents"]); + expect(menuProps.customMcpServers).toEqual([ + expect.objectContaining({ name: "Vision-MCP", enabled: true }), + ]); + expect(menuProps.readOnly).toBe(true); + + const input = screen.getByRole("textbox"); + typeComposerText(input, "@cro"); + expect(screen.getByRole("option")).toHaveTextContent("Crossagents"); + + typeComposerText(input, "@vis"); + expect(screen.getByRole("option")).toHaveTextContent("Vision-MCP"); + } finally { + if (rangeRectDescriptor) { + Object.defineProperty(Range.prototype, "getBoundingClientRect", rangeRectDescriptor); + } else { + Reflect.deleteProperty(Range.prototype, "getBoundingClientRect"); + } + if (scrollIntoViewDescriptor) { + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", scrollIntoViewDescriptor); + } else { + Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView"); + } + } + }); + + it("does not report client-local custom MCPs for a remote provider-owned thread", () => { + useAppStore.setState({ + runtimeLaunchConfigByThreadId: { + [guiThread.id]: { model: "gpt-5.4", crossagentMcp: true }, + }, + mcpLaunchCustomServerNamesByThreadId: { + [guiThread.id]: ["Client-only MCP"], + }, + }); + + renderComposer({ + thread: { ...guiThread, remoteServerId: "desktop-1", remoteId: "remote-thread-1" }, + agentStatus: { + ...codexGuiStatus, + capabilities: { + ...codexGuiStatus.capabilities, + mcpConfigSource: "agentSettings", + }, + }, + }); + + const menuProps = composerAddMenuSpy.mock.lastCall?.[0] as { + customMcpServers: unknown[]; + }; + expect(menuProps.customMcpServers).toEqual([]); + }); + it("uses GUI presentation capabilities for slash commands and /fast submission", () => { const divergentStatus: AgentStatus = { ...codexGuiStatus, diff --git a/src/renderer/components/thread/ThreadComposerSection.tsx b/src/renderer/components/thread/ThreadComposerSection.tsx index ff98997f0..6af53a7c9 100644 --- a/src/renderer/components/thread/ThreadComposerSection.tsx +++ b/src/renderer/components/thread/ThreadComposerSection.tsx @@ -8,7 +8,7 @@ import { type RefObject, } from "react"; import { toast } from "@heroui/react"; -import { ChevronDown, Monitor, TerminalSquare } from "lucide-react"; +import { ChevronDown, Monitor, TerminalSquare, Webhook } from "lucide-react"; import { useLingui } from "@lingui/react/macro"; import type { AgentStatus, ProjectLocation, PromptSegment, Thread } from "@/shared/contracts"; import { friendlyError } from "@/shared/messages"; @@ -257,29 +257,31 @@ function ThreadComposerSectionInner(props: ThreadComposerSectionProps & { thread // and Computer Use. Users change servers in the draft composer or settings // before launching a new thread. // Bindings are display-only for an active session; toggles are no-ops. - // Providers with `mcpConfigSource: "agentSettings"` configure MCP on their - // settings page instead of per-thread, so their composer carries no MCP - // display at all — no built-in rows, no custom servers, and no read-only - // "none for this run" fallback. const providerOwnsMcp = effectiveAgentStatus ? providerOwnsMcpConfig(effectiveAgentStatus.capabilities) : false; + const runtimeLaunchConfig = useAppStore((s) => s.runtimeLaunchConfigByThreadId[thread.id]); + const effectiveMcpConfig = providerOwnsMcp + ? (runtimeLaunchConfig ?? thread.config) + : thread.config; const mcpServers = composerMcpServers.map((descriptor) => ({ descriptor, - enabled: thread.config?.[descriptor.configKey] === true, - visible: !providerOwnsMcp && thread.config?.[descriptor.configKey] === true, + enabled: effectiveMcpConfig?.[descriptor.configKey] === true, + visible: + descriptor.isAvailable(projectLocation) && + effectiveMcpConfig?.[descriptor.configKey] === true, onToggle: () => {}, })); const launchCustomMcpNames = useAppStore( (s) => s.mcpLaunchCustomServerNamesByThreadId[thread.id], ); - const customMcpServers = providerOwnsMcp - ? [] - : (launchCustomMcpNames ?? []).map((name) => ({ - id: name, - name, - enabled: true, - })); + const customMcpServers = ( + providerOwnsMcp && usesRemoteTransport ? [] : (launchCustomMcpNames ?? []) + ).map((name) => ({ + id: name, + name, + enabled: true, + })); const mcpMentions: McpMentionItem[] = [ ...(appControlsEnabled && !providerOwnsMcp ? [ @@ -294,7 +296,11 @@ function ThreadComposerSectionInner(props: ThreadComposerSectionProps & { thread ] : []), ...composerMcpServers - .filter((descriptor) => thread.config?.[descriptor.configKey] === true) + .filter( + (descriptor) => + descriptor.isAvailable(projectLocation) && + effectiveMcpConfig?.[descriptor.configKey] === true, + ) .map((descriptor) => ({ id: descriptor.id, name: t(descriptor.label), @@ -302,7 +308,16 @@ function ThreadComposerSectionInner(props: ThreadComposerSectionProps & { thread detail: t`MCP server`, enabled: true, })), - ...(thread.config?.computerUse === true + ...customMcpServers.map((server) => ({ + id: server.id, + name: server.name, + icon: Webhook, + detail: t`MCP server`, + enabled: true, + })), + ...(effectiveMcpConfig?.computerUse === true && + readBridge()?.platform !== "linux" && + projectLocation?.kind !== "wsl" ? [ { id: COMPUTER_USE_MCP_ID, @@ -913,10 +928,13 @@ function ThreadComposerSectionInner(props: ThreadComposerSectionProps & { thread {}, }} showFileOption={!usesRemoteTransport || props.pickFiles !== undefined} diff --git a/src/renderer/components/thread/ThreadDraftComposerArea.tsx b/src/renderer/components/thread/ThreadDraftComposerArea.tsx index 17825e3d3..02ecf1380 100644 --- a/src/renderer/components/thread/ThreadDraftComposerArea.tsx +++ b/src/renderer/components/thread/ThreadDraftComposerArea.tsx @@ -34,6 +34,7 @@ import { composerMcpServers, COMPUTER_USE_MCP_ID, mcpTogglePatch, + providerMcpSettingEnabled, providerOwnsMcpConfig, } from "@/renderer/components/composer/composerMcpServers"; import { openAttachmentLightbox } from "@/renderer/components/composer/ImageLightbox"; @@ -233,6 +234,7 @@ function DraftComposerAfterControls(props: { onPickFiles: () => void; showVoiceInputButton: boolean; isDisabled: boolean; + readOnlyMcp?: boolean; experiment?: { enabled: boolean; disabled: boolean; @@ -251,6 +253,12 @@ function DraftComposerAfterControls(props: { Change servers in provider settings, + } + : {})} showFileOption onPickFiles={props.onPickFiles} computerUse={props.computerUse} @@ -315,6 +323,7 @@ export function ThreadDraftComposerArea(props: { const setMcpServerEnabled = useSharedSettings((s) => s.setMcpServerEnabled); const userCustomMcpServers = useSharedSettings((s) => s.mcpServers); const setUserCustomMcpServers = useSharedSettings((s) => s.setMcpServers); + const providerMcpSettings = useSharedSettings((s) => s.agentSettings[props.selectedAgent.kind]); const mentionRef = useRef(null); const voiceInputRef = useRef(null); const attachments = useAttachments({ @@ -423,44 +432,73 @@ export function ThreadDraftComposerArea(props: { const availableComposerMcpServers = composerMcpServers.filter( (descriptor) => disabledBuiltInMcpServers[descriptor.id] !== true, ); + const providerOwnsMcp = providerOwnsMcpConfig(props.selectedAgent.capabilities); + // A desktop remote project launches on the paired host, whose provider + // settings are not present in this renderer. The mobile remote bridge does + // hydrate the shared store from that same host, so it can still render the + // provider-owned MCP set. + const providerOwnsMcpForComposer = providerOwnsMcp && (!props.isRemote || isRemoteSurface); const mcpServers = availableComposerMcpServers.map((descriptor) => ({ descriptor, - enabled: persistentMcpServers[descriptor.id] === true, - visible: - descriptor.getScope( - props.selectedAgent.capabilities, - props.presentationMode, - props.project.location, - ) !== "none", - onToggle: (next: boolean) => setMcpServerEnabled(descriptor.id, next), + enabled: providerOwnsMcpForComposer + ? providerMcpSettingEnabled( + props.selectedAgent.capabilities, + providerMcpSettings, + descriptor.configKey, + ) + : persistentMcpServers[descriptor.id] === true, + visible: providerOwnsMcp + ? providerOwnsMcpForComposer && + descriptor.isAvailable(props.project.location) && + providerMcpSettingEnabled( + props.selectedAgent.capabilities, + providerMcpSettings, + descriptor.configKey, + ) + : descriptor.getScope( + props.selectedAgent.capabilities, + props.presentationMode, + props.project.location, + ) !== "none", + onToggle: (next: boolean) => { + if (!providerOwnsMcp) { + setMcpServerEnabled(descriptor.id, next); + } + }, })); // User-configured MCP servers (global + this project's workspace scope). // Toggling flips the server's persistent `enabled` flag — the same switch as // the MCP Servers settings page — because custom servers bind at launch from // settings, not from per-thread config. The launch-time merge helper decides // which workspace entries override global ones, so the menu can't drift from - // what actually launches. Providers whose MCP set lives on their settings - // page show no rows here at all. - const providerOwnsMcp = providerOwnsMcpConfig(props.selectedAgent.capabilities); + // what actually launches. Provider-owned MCP rows are shown read-only here; + // their settings page remains the single place that changes them. const projectCustomMcpServers = props.project.mcpServers ?? []; const projectCustomMcpIds = new Set(projectCustomMcpServers.map((server) => server.id)); - const mergedCustomMcpServers = providerOwnsMcp - ? [] - : mergeMcpServers(userCustomMcpServers, projectCustomMcpServers); - const customMcpServers: ComposerCustomMcpItem[] = mergedCustomMcpServers.map((server) => { + const mergedCustomMcpServers = mergeMcpServers(userCustomMcpServers, projectCustomMcpServers); + const visibleCustomMcpServers = providerOwnsMcp + ? providerOwnsMcpForComposer + ? mergedCustomMcpServers.filter((server) => server.enabled) + : [] + : mergedCustomMcpServers; + const customMcpServers: ComposerCustomMcpItem[] = visibleCustomMcpServers.map((server) => { const isProject = projectCustomMcpIds.has(server.id); const scopedServers = isProject ? projectCustomMcpServers : userCustomMcpServers; return { id: `${isProject ? "project" : "user"}:${server.id}`, name: server.name, enabled: server.enabled, - onToggle: (next: boolean) => { - const nextServers = scopedServers.map((item) => - item.id === server.id ? { ...item, enabled: next } : item, - ); - if (isProject) updateProjectMcpServers(props.project.id, nextServers); - else setUserCustomMcpServers(nextServers); - }, + ...(!providerOwnsMcp + ? { + onToggle: (next: boolean) => { + const nextServers = scopedServers.map((item) => + item.id === server.id ? { ...item, enabled: next } : item, + ); + if (isProject) updateProjectMcpServers(props.project.id, nextServers); + else setUserCustomMcpServers(nextServers); + }, + } + : {}), }; }); // Composer chips represent per-thread *mentions* only: a server whose config @@ -468,7 +506,9 @@ export function ThreadDraftComposerArea(props: { // enabled servers are on for every thread and show no chip. const mentionedMcpServers = availableComposerMcpServers.filter( (descriptor) => - props.config[descriptor.configKey] === true && persistentMcpServers[descriptor.id] !== true, + props.config[descriptor.configKey] === true && + persistentMcpServers[descriptor.id] !== true && + (!providerOwnsMcp || providerOwnsMcpForComposer), ); // Worktree creation lives in the composer toolbar. The "bring over uncommitted @@ -577,11 +617,19 @@ export function ThreadDraftComposerArea(props: { readBridge()?.platform, ); const computerUseEnabled = props.config.computerUse === true; + const providerComputerUseEnabled = + providerOwnsMcpForComposer && + disabledBuiltInMcpServers[COMPUTER_USE_MCP_ID] !== true && + readBridge()?.platform !== "linux" && + props.project.location.kind !== "wsl" && + providerMcpSettingEnabled(props.selectedAgent.capabilities, providerMcpSettings, "computerUse"); const computerUsePersistent = persistentMcpServers[COMPUTER_USE_MCP_ID] === true; // Same chip rule as the registry servers: a chip only for a per-thread mention, // never for the persistent standing default. const showComputerUseChip = - computerUseScope !== "none" && computerUseEnabled && !computerUsePersistent; + (providerOwnsMcp ? providerOwnsMcpForComposer : computerUseScope !== "none") && + computerUseEnabled && + !computerUsePersistent; const onConfigChange = props.onConfigChange; // `@`-mention affordances: disabled servers enable the capability for this // draft; already-effective servers remain available and insert a textual @@ -600,29 +648,49 @@ export function ThreadDraftComposerArea(props: { ] : []), ...availableComposerMcpServers - .filter( - (descriptor) => - descriptor.getScope( - props.selectedAgent.capabilities, - props.presentationMode, - props.project.location, - ) !== "none", + .filter((descriptor) => + providerOwnsMcp + ? providerOwnsMcpForComposer && + descriptor.isAvailable(props.project.location) && + providerMcpSettingEnabled( + props.selectedAgent.capabilities, + providerMcpSettings, + descriptor.configKey, + ) + : descriptor.getScope( + props.selectedAgent.capabilities, + props.presentationMode, + props.project.location, + ) !== "none", ) .map((descriptor) => ({ id: descriptor.id, name: t(descriptor.label), icon: descriptor.icon, detail: t`MCP server`, - enabled: props.config[descriptor.configKey] === true, + enabled: providerOwnsMcp ? true : props.config[descriptor.configKey] === true, + })), + ...visibleCustomMcpServers + .filter((server) => server.enabled) + .map((server) => ({ + id: server.id, + name: server.name, + icon: Webhook, + detail: t`MCP server`, + enabled: true, })), - ...(computerUseScope !== "none" + ...(( + providerOwnsMcp + ? providerOwnsMcpForComposer && providerComputerUseEnabled + : computerUseScope !== "none" + ) ? [ { id: COMPUTER_USE_MCP_ID, name: t`Computer Use`, icon: Monitor, detail: t`Computer Use`, - enabled: computerUseEnabled, + enabled: providerOwnsMcpForComposer ? true : computerUseEnabled, }, ] : []), @@ -1195,6 +1263,7 @@ export function ThreadDraftComposerArea(props: { .catch((error: unknown) => toast.danger(friendlyError(error))); }} customMcpServers={customMcpServers} + readOnlyMcp={providerOwnsMcpForComposer} showVoiceInputButton={showVoiceInputButton} isDisabled={authRequired || agentUpdating || isSubmitting} {...(!isHomeScope && !usesRemoteTransport && !isQuickComposer && props.gitBranch @@ -1221,9 +1290,19 @@ export function ThreadDraftComposerArea(props: { mentionRef={mentionRef} voiceInputRef={voiceInputRef} computerUse={{ - enabled: computerUsePersistent, - visible: computerUseScope !== "none", - onToggle: (next) => setMcpServerEnabled(COMPUTER_USE_MCP_ID, next), + enabled: providerOwnsMcpForComposer + ? providerComputerUseEnabled + : providerOwnsMcp + ? false + : computerUsePersistent, + visible: providerOwnsMcpForComposer + ? providerComputerUseEnabled + : providerOwnsMcp + ? false + : computerUseScope !== "none", + onToggle: (next) => { + if (!providerOwnsMcp) setMcpServerEnabled(COMPUTER_USE_MCP_ID, next); + }, }} /> } diff --git a/src/renderer/components/thread/ThreadDraftView.test.tsx b/src/renderer/components/thread/ThreadDraftView.test.tsx index 055c4a77d..47994147e 100644 --- a/src/renderer/components/thread/ThreadDraftView.test.tsx +++ b/src/renderer/components/thread/ThreadDraftView.test.tsx @@ -26,6 +26,7 @@ vi.mock("./ThreadComposer", () => ({ controls: unknown[]; onPromptChange: (value: string) => void; onSubmit: () => void; + afterControls?: ReactNode; }) => { composerSpy(props); return ( @@ -498,6 +499,7 @@ describe("ThreadDraftView", () => { useSharedSettings.setState({ providerConfigs: {}, providerModelPreferences: {}, + agentSettings: {}, hiddenModels: {}, disabledAgents: [], lastPresentationModeByAgent: {}, @@ -1108,6 +1110,52 @@ describe("ThreadDraftView", () => { expect(onStart).not.toHaveBeenCalled(); }); + it("does not use local provider MCP settings for a desktop remote draft", () => { + useSharedSettings.setState({ agentSettings: { codex: { crossagentMcp: true } } }); + useRemoteServersStore.setState({ + servers: [ + { + desktopId: "desktop-1", + label: "Remote Mac", + endpoint: "http://remote/", + accessToken: "token", + scopes: [], + }, + ], + runtime: { + "desktop-1": { status: "online", projects: [], threads: [] }, + }, + }); + + render( + {}} + />, + ); + + const composerProps = composerSpy.mock.lastCall?.[0] as { afterControls?: ReactNode }; + expect(isValidElement(composerProps.afterControls)).toBe(true); + const menuProps = (composerProps.afterControls as ReactElement).props as { + mcpServers: Array<{ visible: boolean }>; + customMcpServers: unknown[]; + readOnlyMcp?: boolean; + }; + expect(menuProps.mcpServers.some((server) => server.visible)).toBe(false); + expect(menuProps.customMcpServers).toEqual([]); + expect(menuProps.readOnlyMcp).not.toBe(true); + }); + it("shows the discovery reveal for a WSL project while its distro is probing", () => { const onStart = vi.fn<(input: unknown) => void>(); useAgentStatusesStore.getState().beginFirstLaunchDiscovery({ kind: "wsl", distro: "Ubuntu" }); diff --git a/src/renderer/locales/de/messages.po b/src/renderer/locales/de/messages.po index 754e23642..bf541c5ad 100644 --- a/src/renderer/locales/de/messages.po +++ b/src/renderer/locales/de/messages.po @@ -2126,6 +2126,10 @@ msgstr "Projektordner ändern" msgid "Change project icon" msgstr "Projektsymbol ändern" +#: src/renderer/components/thread/ThreadDraftComposerArea.tsx +msgid "Change servers in provider settings" +msgstr "Server in den Anbietereinstellungen ändern" + #: src/renderer/views/SettingsOverlay/parts/ShortcutsSettings.tsx msgid "Change shortcut" msgstr "Tastenkürzel ändern" diff --git a/src/renderer/locales/en/messages.po b/src/renderer/locales/en/messages.po index 6b66a830f..8207b70df 100644 --- a/src/renderer/locales/en/messages.po +++ b/src/renderer/locales/en/messages.po @@ -2126,6 +2126,10 @@ msgstr "Change project folder" msgid "Change project icon" msgstr "Change project icon" +#: src/renderer/components/thread/ThreadDraftComposerArea.tsx +msgid "Change servers in provider settings" +msgstr "Change servers in provider settings" + #: src/renderer/views/SettingsOverlay/parts/ShortcutsSettings.tsx msgid "Change shortcut" msgstr "Change shortcut" diff --git a/src/renderer/locales/es/messages.po b/src/renderer/locales/es/messages.po index d8a366944..4d29aa34d 100644 --- a/src/renderer/locales/es/messages.po +++ b/src/renderer/locales/es/messages.po @@ -2126,6 +2126,10 @@ msgstr "Cambiar la carpeta del proyecto" msgid "Change project icon" msgstr "Cambiar el icono del proyecto" +#: src/renderer/components/thread/ThreadDraftComposerArea.tsx +msgid "Change servers in provider settings" +msgstr "Cambia los servidores en la configuración del proveedor" + #: src/renderer/views/SettingsOverlay/parts/ShortcutsSettings.tsx msgid "Change shortcut" msgstr "Cambiar atajo" diff --git a/src/renderer/locales/fr/messages.po b/src/renderer/locales/fr/messages.po index 6ff81e876..5f2d86d62 100644 --- a/src/renderer/locales/fr/messages.po +++ b/src/renderer/locales/fr/messages.po @@ -2126,6 +2126,10 @@ msgstr "Changer le dossier du projet" msgid "Change project icon" msgstr "Changer l'icône du projet" +#: src/renderer/components/thread/ThreadDraftComposerArea.tsx +msgid "Change servers in provider settings" +msgstr "Modifiez les serveurs dans les paramètres du fournisseur" + #: src/renderer/views/SettingsOverlay/parts/ShortcutsSettings.tsx msgid "Change shortcut" msgstr "Changer le raccourci" diff --git a/src/renderer/locales/ja/messages.po b/src/renderer/locales/ja/messages.po index 04463b7d9..f07505458 100644 --- a/src/renderer/locales/ja/messages.po +++ b/src/renderer/locales/ja/messages.po @@ -2125,6 +2125,10 @@ msgstr "プロジェクトフォルダーを変更" msgid "Change project icon" msgstr "プロジェクトアイコンを変更" +#: src/renderer/components/thread/ThreadDraftComposerArea.tsx +msgid "Change servers in provider settings" +msgstr "プロバイダー設定でサーバーを変更" + #: src/renderer/views/SettingsOverlay/parts/ShortcutsSettings.tsx msgid "Change shortcut" msgstr "ショートカットを変更" diff --git a/src/renderer/locales/ko/messages.po b/src/renderer/locales/ko/messages.po index 5bcbd7c69..e318cee0d 100644 --- a/src/renderer/locales/ko/messages.po +++ b/src/renderer/locales/ko/messages.po @@ -2126,6 +2126,10 @@ msgstr "프로젝트 폴더 변경" msgid "Change project icon" msgstr "프로젝트 아이콘 변경" +#: src/renderer/components/thread/ThreadDraftComposerArea.tsx +msgid "Change servers in provider settings" +msgstr "제공업체 설정에서 서버 변경" + #: src/renderer/views/SettingsOverlay/parts/ShortcutsSettings.tsx msgid "Change shortcut" msgstr "단축키 변경" diff --git a/src/renderer/locales/pl/messages.po b/src/renderer/locales/pl/messages.po index d4dcde057..f07f979d1 100644 --- a/src/renderer/locales/pl/messages.po +++ b/src/renderer/locales/pl/messages.po @@ -2126,6 +2126,10 @@ msgstr "Zmień folder projektu" msgid "Change project icon" msgstr "Zmień ikonę projektu" +#: src/renderer/components/thread/ThreadDraftComposerArea.tsx +msgid "Change servers in provider settings" +msgstr "Zmień serwery w ustawieniach dostawcy" + #: src/renderer/views/SettingsOverlay/parts/ShortcutsSettings.tsx msgid "Change shortcut" msgstr "Zmień skrót" diff --git a/src/renderer/locales/pt-BR/messages.po b/src/renderer/locales/pt-BR/messages.po index 76333874d..060243cdb 100644 --- a/src/renderer/locales/pt-BR/messages.po +++ b/src/renderer/locales/pt-BR/messages.po @@ -2126,6 +2126,10 @@ msgstr "Alterar pasta do projeto" msgid "Change project icon" msgstr "Alterar ícone do projeto" +#: src/renderer/components/thread/ThreadDraftComposerArea.tsx +msgid "Change servers in provider settings" +msgstr "Altere os servidores nas configurações do provedor" + #: src/renderer/views/SettingsOverlay/parts/ShortcutsSettings.tsx msgid "Change shortcut" msgstr "Alterar atalho" diff --git a/src/renderer/locales/ru/messages.po b/src/renderer/locales/ru/messages.po index 26a821a6b..8b2b61650 100644 --- a/src/renderer/locales/ru/messages.po +++ b/src/renderer/locales/ru/messages.po @@ -2126,6 +2126,10 @@ msgstr "Изменить папку проекта" msgid "Change project icon" msgstr "Изменить значок проекта" +#: src/renderer/components/thread/ThreadDraftComposerArea.tsx +msgid "Change servers in provider settings" +msgstr "Измените серверы в настройках провайдера" + #: src/renderer/views/SettingsOverlay/parts/ShortcutsSettings.tsx msgid "Change shortcut" msgstr "Изменить сочетание клавиш" diff --git a/src/renderer/locales/tr/messages.po b/src/renderer/locales/tr/messages.po index febd10774..1d20e1e05 100644 --- a/src/renderer/locales/tr/messages.po +++ b/src/renderer/locales/tr/messages.po @@ -2126,6 +2126,10 @@ msgstr "Proje klasörünü değiştir" msgid "Change project icon" msgstr "Proje simgesini değiştir" +#: src/renderer/components/thread/ThreadDraftComposerArea.tsx +msgid "Change servers in provider settings" +msgstr "Sunucuları sağlayıcı ayarlarından değiştirin" + #: src/renderer/views/SettingsOverlay/parts/ShortcutsSettings.tsx msgid "Change shortcut" msgstr "Kısayolu değiştir" diff --git a/src/renderer/locales/uk/messages.po b/src/renderer/locales/uk/messages.po index 73a27ffd7..0328dacd2 100644 --- a/src/renderer/locales/uk/messages.po +++ b/src/renderer/locales/uk/messages.po @@ -2126,6 +2126,10 @@ msgstr "Змінити папку проєкту" msgid "Change project icon" msgstr "Змінити іконку проєкту" +#: src/renderer/components/thread/ThreadDraftComposerArea.tsx +msgid "Change servers in provider settings" +msgstr "Змініть сервери в налаштуваннях постачальника" + #: src/renderer/views/SettingsOverlay/parts/ShortcutsSettings.tsx msgid "Change shortcut" msgstr "Змінити комбінацію клавіш" diff --git a/src/renderer/locales/vi/messages.po b/src/renderer/locales/vi/messages.po index 3c03f3bda..e50c2b6fc 100644 --- a/src/renderer/locales/vi/messages.po +++ b/src/renderer/locales/vi/messages.po @@ -2126,6 +2126,10 @@ msgstr "Thay đổi thư mục dự án" msgid "Change project icon" msgstr "Đổi biểu tượng dự án" +#: src/renderer/components/thread/ThreadDraftComposerArea.tsx +msgid "Change servers in provider settings" +msgstr "Thay đổi máy chủ trong phần cài đặt nhà cung cấp" + #: src/renderer/views/SettingsOverlay/parts/ShortcutsSettings.tsx msgid "Change shortcut" msgstr "Thay đổi phím tắt" diff --git a/src/renderer/locales/zh-CN/messages.po b/src/renderer/locales/zh-CN/messages.po index a36d44383..92f160fa6 100644 --- a/src/renderer/locales/zh-CN/messages.po +++ b/src/renderer/locales/zh-CN/messages.po @@ -2126,6 +2126,10 @@ msgstr "更改项目文件夹" msgid "Change project icon" msgstr "更改项目图标" +#: src/renderer/components/thread/ThreadDraftComposerArea.tsx +msgid "Change servers in provider settings" +msgstr "在提供商设置中更改服务器" + #: src/renderer/views/SettingsOverlay/parts/ShortcutsSettings.tsx msgid "Change shortcut" msgstr "更改快捷键" diff --git a/src/shared/contracts/agent.ts b/src/shared/contracts/agent.ts index 9851d76e3..f3d931974 100644 --- a/src/shared/contracts/agent.ts +++ b/src/shared/contracts/agent.ts @@ -326,10 +326,10 @@ export const agentCapabilitySchema = z.object({ * - absent / "thread": per-thread `ThreadConfig` flags set by the composer * MCP controls (the default for every provider). * - "agentSettings": provider-level — flags are read from - * `sharedSettings.agentSettings[kind]` at launch, the composer shows no - * MCP controls at all, and the MCP set is identical across the provider's - * threads (letting pooled servers stay stable). Configured from the - * provider's settings page. + * `sharedSettings.agentSettings[kind]` at launch, the composer shows the + * effective MCP set read-only, and the MCP set is identical across the + * provider's threads (letting pooled servers stay stable). Configured from + * the provider's settings page. */ mcpConfigSource: z.enum(["thread", "agentSettings"]).optional(), /** diff --git a/src/supervisor/agents/opencode/detection.ts b/src/supervisor/agents/opencode/detection.ts index 191ba3713..310c06010 100644 --- a/src/supervisor/agents/opencode/detection.ts +++ b/src/supervisor/agents/opencode/detection.ts @@ -64,7 +64,8 @@ export const opencodeDefaultCapabilities: AgentCapability = { presentationModes: ["terminal", "gui"], defaultApprovalPolicy: "yolo", bypassPermissions: { approvalPolicy: "yolo" }, - // MCP is provider-level for OpenCode: the composer shows no MCP controls; + // MCP is provider-level for OpenCode: the composer shows the effective set + // read-only, while changes stay on the provider settings page. // built-in server flags come from the OpenCode settings page // (`agentSettings.opencode`) at launch. OpenCode applies that set to each // project directory inside the shared runtime server instead of hosting diff --git a/src/supervisor/runtime/threadSession/invalidSessionRecovery.test.ts b/src/supervisor/runtime/threadSession/invalidSessionRecovery.test.ts index b885c704d..f973162b9 100644 --- a/src/supervisor/runtime/threadSession/invalidSessionRecovery.test.ts +++ b/src/supervisor/runtime/threadSession/invalidSessionRecovery.test.ts @@ -87,6 +87,9 @@ function createHarness() { events.push("mcp"); return []; }); + const resolveMcpLaunchConfig = vi.fn< + InvalidSessionRecoveryContext["spawnPipeline"]["resolveMcpLaunchConfig"] + >((config) => config); const composeLaunchOptions = vi.fn< InvalidSessionRecoveryContext["spawnPipeline"]["composeLaunchOptions"] >(() => { @@ -130,6 +133,7 @@ function createHarness() { const context: InvalidSessionRecoveryContext = { spawnPipeline: { + resolveMcpLaunchConfig, resolveMcpServersForLaunch, composeLaunchOptions, spawnThread, diff --git a/src/supervisor/runtime/threadSession/invalidSessionRecovery.ts b/src/supervisor/runtime/threadSession/invalidSessionRecovery.ts index 9e8a9f2f0..3a1dddd76 100644 --- a/src/supervisor/runtime/threadSession/invalidSessionRecovery.ts +++ b/src/supervisor/runtime/threadSession/invalidSessionRecovery.ts @@ -10,7 +10,7 @@ import type { ThreadOutputPipeline } from "../threadOutputPipeline"; type RecoverySpawnPipeline = Pick< SpawnPipeline, - "resolveMcpServersForLaunch" | "composeLaunchOptions" | "spawnThread" + "resolveMcpLaunchConfig" | "resolveMcpServersForLaunch" | "composeLaunchOptions" | "spawnThread" >; export interface InvalidSessionRecoveryContext { @@ -73,12 +73,17 @@ export class InvalidSessionRecoveryCoordinator { return; } - const launchConfig = workspaceLaunchConfig( - session.projectLocation, - session.config, + const launchConfig = context.spawnPipeline.resolveMcpLaunchConfig( + workspaceLaunchConfig( + session.projectLocation, + session.config, + session.adapter, + mcpLaunchSnapshot.disabledBuiltInMcpServerIds, + mcpLaunchSnapshot.pluginBuiltInMcpServerIds, + ), + mcpLaunchSnapshot, session.adapter, - mcpLaunchSnapshot.disabledBuiltInMcpServerIds, - mcpLaunchSnapshot.pluginBuiltInMcpServerIds, + session.threadId, ); const resolvedMcpServers = await context.spawnPipeline.resolveMcpServersForLaunch({ location: session.projectLocation, diff --git a/src/supervisor/runtime/threadSession/spawnPipeline.ts b/src/supervisor/runtime/threadSession/spawnPipeline.ts index b3a5399c7..53c90c48e 100644 --- a/src/supervisor/runtime/threadSession/spawnPipeline.ts +++ b/src/supervisor/runtime/threadSession/spawnPipeline.ts @@ -367,12 +367,26 @@ export class SpawnPipeline { payload.userMessageItemId, ) : undefined; - const optimisticLaunchConfig = workspaceLaunchConfig( - payload.projectLocation, - payload.config, + const mcpLaunchSnapshotBase = { + disabledBuiltInMcpServerIds: payload.disabledBuiltInMcpServerIds ?? [], + disabledBuiltInMcpTools: payload.disabledBuiltInMcpTools ?? {}, + pluginBuiltInMcpServerIds: pluginContributions.builtInMcpServerIds, + }; + const optimisticMcpLaunchSnapshot: McpLaunchSnapshot = { + mcpServers: [], + ...mcpLaunchSnapshotBase, + }; + const optimisticLaunchConfig = this.resolveMcpLaunchConfig( + workspaceLaunchConfig( + payload.projectLocation, + payload.config, + adapter, + optimisticMcpLaunchSnapshot.disabledBuiltInMcpServerIds, + optimisticMcpLaunchSnapshot.pluginBuiltInMcpServerIds, + ), + optimisticMcpLaunchSnapshot, adapter, - payload.disabledBuiltInMcpServerIds ?? [], - pluginContributions.builtInMcpServerIds, + payload.threadId, ); if (optimisticUserMessageItemId) { this.emitOptimisticWorkingState(payload.threadId, payload.config, optimisticLaunchConfig); @@ -414,16 +428,19 @@ export class SpawnPipeline { } const mcpLaunchSnapshot: McpLaunchSnapshot = { mcpServers, - disabledBuiltInMcpServerIds: payload.disabledBuiltInMcpServerIds ?? [], - disabledBuiltInMcpTools: payload.disabledBuiltInMcpTools ?? {}, - pluginBuiltInMcpServerIds: pluginContributions.builtInMcpServerIds, + ...mcpLaunchSnapshotBase, }; - const launchConfig = workspaceLaunchConfig( - payload.projectLocation, - payload.config, + const launchConfig = this.resolveMcpLaunchConfig( + workspaceLaunchConfig( + payload.projectLocation, + payload.config, + adapter, + mcpLaunchSnapshot.disabledBuiltInMcpServerIds, + mcpLaunchSnapshot.pluginBuiltInMcpServerIds, + ), + mcpLaunchSnapshot, adapter, - mcpLaunchSnapshot.disabledBuiltInMcpServerIds, - mcpLaunchSnapshot.pluginBuiltInMcpServerIds, + payload.threadId, ); const resolvedMcpServers = await this.resolveMcpServersForLaunch({ location: payload.projectLocation, @@ -714,12 +731,17 @@ export class SpawnPipeline { } const mcpIdentity = { threadId: session.threadId }; - const launchConfig = workspaceLaunchConfig( - session.projectLocation, - config, + const launchConfig = this.resolveMcpLaunchConfig( + workspaceLaunchConfig( + session.projectLocation, + config, + session.adapter, + mcpLaunchSnapshot.disabledBuiltInMcpServerIds, + mcpLaunchSnapshot.pluginBuiltInMcpServerIds, + ), + mcpLaunchSnapshot, session.adapter, - mcpLaunchSnapshot.disabledBuiltInMcpServerIds, - mcpLaunchSnapshot.pluginBuiltInMcpServerIds, + session.threadId, ); const resolvedMcpServers = await this.resolveMcpServersForLaunch({ location: session.projectLocation, @@ -1051,9 +1073,6 @@ export class SpawnPipeline { adapter?: AgentAdapter; presentationMode?: ThreadPresentationMode; }): Promise { - const crossagentRoutingAvailable = - adapter?.capabilities.crossagentMcpRouting === "provider-session" && - crossagentThreadId !== undefined; const providerSessionCrossagents = usesProviderSessionCrossagentRouting( adapter, presentationMode, @@ -1063,17 +1082,7 @@ export class SpawnPipeline { // Provider-level MCP: flags come from the provider's settings page. Drop // the general MCP identity; GUI provider-session routing uses its own // shared credential, while terminal routing keeps the thread token. - config = applyAgentSettingsMcpFlags( - config, - this.ctx.resolveAgentSettings(adapter), - mcpLaunchSnapshot.disabledBuiltInMcpServerIds, - crossagentRoutingAvailable, - ); - config = effectiveLaunchConfig( - config, - mcpLaunchSnapshot.disabledBuiltInMcpServerIds, - mcpLaunchSnapshot.pluginBuiltInMcpServerIds, - ); + config = this.resolveMcpLaunchConfig(config, mcpLaunchSnapshot, adapter, crossagentThreadId); identity = undefined; if (adapter.capabilities.crossagentMcpRouting !== "provider-session") { crossagentThreadId = undefined; @@ -1116,6 +1125,27 @@ export class SpawnPipeline { ); } + resolveMcpLaunchConfig( + config: ThreadConfig, + mcpLaunchSnapshot: McpLaunchSnapshot, + adapter: AgentAdapter, + crossagentThreadId?: string, + ): ThreadConfig { + if (adapter.capabilities.mcpConfigSource !== "agentSettings") return config; + const withProviderSettings = applyAgentSettingsMcpFlags( + config, + this.ctx.resolveAgentSettings(adapter), + mcpLaunchSnapshot.disabledBuiltInMcpServerIds, + adapter.capabilities.crossagentMcpRouting === "provider-session" && + crossagentThreadId !== undefined, + ); + return effectiveLaunchConfig( + withProviderSettings, + mcpLaunchSnapshot.disabledBuiltInMcpServerIds, + mcpLaunchSnapshot.pluginBuiltInMcpServerIds, + ); + } + async resolveBrowserMcpForLaunch( location: ProjectLocation, config: ThreadConfig, diff --git a/src/supervisor/runtime/threadSessionManager.startClose.test.ts b/src/supervisor/runtime/threadSessionManager.startClose.test.ts index efe712a16..e5fcdf4f2 100644 --- a/src/supervisor/runtime/threadSessionManager.startClose.test.ts +++ b/src/supervisor/runtime/threadSessionManager.startClose.test.ts @@ -592,22 +592,70 @@ describe("ThreadSessionManager start guards", () => { const structuredSession = createStructuredSession(Promise.resolve()); const adapter = createAdapter("opencode", structuredSession); adapter.capabilities.mcpConfigSource = "agentSettings"; - const manager = createManager("opencode", adapter); + adapter.capabilities.agentSettingsDefaults = { crossagentMcp: true }; + adapter.capabilities.crossagentMcpRouting = "provider-session"; + const events: SupervisorEvent[] = []; + const manager = createManager("opencode", adapter, (event) => events.push(event)); await manager.startThread({ threadId: "thread-opencode-empty-mcp", projectLocation: { kind: "windows", path: "C:\\repo" }, agentKind: "opencode", config: { model: "opencode/model" }, - prompt: "", + prompt: "hello", initialSize: { cols: 80, rows: 24 }, presentationMode: "gui", disabledBuiltInMcpServerIds: ["app-controls"], }); expect(adapter.createStructuredSession).toHaveBeenCalledWith( - expect.objectContaining({ mcpServers: [] }), + expect.objectContaining({ + config: expect.objectContaining({ crossagentMcp: true }), + mcpServers: [], + }), + ); + expect(manager.getThreadSnapshots()[0]?.launchConfig).toEqual( + expect.objectContaining({ crossagentMcp: true }), ); + expect( + events.find((event) => event.type === "thread-state" && event.status === "working"), + ).toEqual( + expect.objectContaining({ launchConfig: expect.objectContaining({ crossagentMcp: true }) }), + ); + }); + + it("does not emit a stale launch state after an MCP reload's session closes", async () => { + const updateMcpServers = vi.fn>(); + const update = deferred(); + updateMcpServers.mockReturnValue(update.promise); + const structuredSession = createStructuredSession(Promise.resolve()); + structuredSession.updateMcpServers = updateMcpServers; + const adapter = createAdapter("opencode", structuredSession); + adapter.capabilities.mcpConfigSource = "agentSettings"; + const events: SupervisorEvent[] = []; + const manager = createManager("opencode", adapter, (event) => events.push(event)); + + await manager.startThread({ + threadId: "thread-reload-race", + projectLocation: { kind: "windows", path: "C:\\repo" }, + agentKind: "opencode", + config: { model: "opencode/model" }, + prompt: "", + initialSize: { cols: 80, rows: 24 }, + presentationMode: "gui", + }); + + const reload = manager.reloadAgentMcpServers({ agentKind: "opencode" }); + await vi.waitFor(() => expect(updateMcpServers).toHaveBeenCalled()); + await manager.closeThread({ threadId: "thread-reload-race" }); + const eventCountAfterClose = events.length; + + update.resolve(); + await reload; + + expect( + events.slice(eventCountAfterClose).filter((event) => event.type === "thread-state"), + ).toEqual([]); }); it.each(guardedStructuredProviders)( diff --git a/src/supervisor/runtime/threadSessionManager.ts b/src/supervisor/runtime/threadSessionManager.ts index e0903b0b7..77086f0e1 100644 --- a/src/supervisor/runtime/threadSessionManager.ts +++ b/src/supervisor/runtime/threadSessionManager.ts @@ -375,12 +375,17 @@ export class ThreadSessionManager { reloads.push( (async () => { try { - const launchConfig = workspaceLaunchConfig( - session.projectLocation, - session.config, + const launchConfig = this.spawnPipeline.resolveMcpLaunchConfig( + workspaceLaunchConfig( + session.projectLocation, + session.config, + session.adapter, + session.mcpLaunchSnapshot.disabledBuiltInMcpServerIds, + session.mcpLaunchSnapshot.pluginBuiltInMcpServerIds, + ), + session.mcpLaunchSnapshot, session.adapter, - session.mcpLaunchSnapshot.disabledBuiltInMcpServerIds, - session.mcpLaunchSnapshot.pluginBuiltInMcpServerIds, + session.threadId, ); const mcpServers = await this.spawnPipeline.resolveMcpServersForLaunch({ location: session.projectLocation, @@ -388,8 +393,13 @@ export class ThreadSessionManager { mcpLaunchSnapshot: session.mcpLaunchSnapshot, crossagentThreadId: session.threadId, adapter: session.adapter, + ...(session.presentationMode ? { presentationMode: session.presentationMode } : {}), }); + if (!this.isCurrentSession(session)) return; await update(mcpServers); + if (!this.isCurrentSession(session)) return; + session.launchConfig = launchConfig; + this.outputPipeline.emitState(session); } catch (error) { console.warn( `[supervisor] failed to reload MCP servers for thread ${session.threadId}:`,