Skip to content

Commit 9628d72

Browse files
committed
feat(opencode): make Browser MCP a per-thread provider setting
- expose Browser MCP toggle via OpenCode setting def and shared helper - sync MCP config and server acquisition based on agentSettings at launch - thread agentSettings through launch options and session manager - render read-only Browser chip in ThreadView when setting is enabled
1 parent d8ba17d commit 9628d72

11 files changed

Lines changed: 202 additions & 27 deletions

File tree

src/renderer/components/composer/AttachmentBar.tsx

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,10 @@ import { getEntryIconUrl } from "@/renderer/components/common/fileIcons";
44
import { toLocalFileUrl } from "@/shared/promptContent";
55
import type { Attachment } from "./useAttachments";
66

7-
export function BrowserChip(props: { onRemove?: (() => void) | undefined }) {
8-
const { onRemove } = props;
7+
export function BrowserChip(props: { onRemove?: (() => void) | undefined; title?: string }) {
8+
const { onRemove, title = "Browser MCP enabled for this thread" } = props;
99
return (
10-
<div
11-
className="lightcode-attachment-chip lightcode-browser-chip"
12-
title="Browser MCP enabled for this thread"
13-
>
10+
<div className="lightcode-attachment-chip lightcode-browser-chip" title={title}>
1411
<Globe className="size-3 text-muted" />
1512
<span className="lightcode-attachment-chip__name">Browser</span>
1613
{onRemove ? (

src/renderer/components/thread/ThreadView.test.tsx

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ describe("ThreadView", () => {
6060
beforeEach(() => {
6161
vi.clearAllMocks();
6262
localStorage.clear();
63-
useSharedSettings.setState({ collapseTerminalComposer: false });
63+
useSharedSettings.setState({ agentSettings: {}, collapseTerminalComposer: false });
6464
useThreadTodoDockStore.setState({
6565
defaultPlacement: "composer",
6666
defaultCollapsed: false,
@@ -137,6 +137,60 @@ describe("ThreadView", () => {
137137
});
138138
});
139139

140+
it("renders OpenCode Browser MCP as a read-only header chip when provider setting is enabled", () => {
141+
const onConfigChange = vi.fn<(config: ThreadConfig) => void>();
142+
143+
renderThreadView({
144+
thread: {
145+
id: "thread-opencode-browser-mcp",
146+
projectId: "project-1",
147+
title: "OpenCode browser thread",
148+
agentKind: "opencode",
149+
config: {
150+
model: "opencode/big-pickle",
151+
},
152+
status: "idle",
153+
attention: "none",
154+
canResumeWithConfig: true,
155+
archived: false,
156+
done: false,
157+
starred: false,
158+
createdAt: new Date().toISOString(),
159+
updatedAt: new Date().toISOString(),
160+
},
161+
agentStatus: {
162+
kind: "opencode",
163+
label: "OpenCode",
164+
installed: true,
165+
authState: "authenticated",
166+
capabilities: {
167+
models: [{ id: "opencode/big-pickle", label: "Big Pickle" }],
168+
efforts: [],
169+
modelEfforts: {},
170+
modes: ["agent"],
171+
approvalPolicies: [{ id: "default", label: "Default" }],
172+
sandboxModes: [],
173+
supportsResume: true,
174+
supportsDirectInput: true,
175+
liveInputMode: "server",
176+
presentationMode: "gui",
177+
settingDefs: [],
178+
},
179+
},
180+
projectLocation: {
181+
kind: "windows",
182+
path: "C:\\repo",
183+
},
184+
onConfigChange,
185+
onResolveServerRequest: async () => undefined,
186+
onSubmitInput: async () => undefined,
187+
});
188+
189+
expect(screen.getByText("Browser")).toBeInTheDocument();
190+
expect(screen.queryByLabelText("Disable Browser MCP")).toBeNull();
191+
expect(onConfigChange).not.toHaveBeenCalled();
192+
});
193+
140194
it("starts a queued launch after the terminal reports its first size", async () => {
141195
const onLaunchConsumed = vi.fn<() => void>();
142196

src/renderer/components/thread/ThreadView.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
ThreadServerRequestId,
1313
} from "@/shared/contracts";
1414
import { isHomeProjectId } from "@/shared/homeScope";
15+
import { isOpenCodeBrowserMcpEnabled } from "@/shared/opencodeSettings";
1516
import { buildPromptContentBlocks } from "@/shared/promptContent";
1617

1718
import { useAppStore } from "@/renderer/state/appStore";
@@ -181,6 +182,9 @@ export const ThreadView = memo(function ThreadView(props: ThreadViewProps) {
181182
const launchRequestRef = useRef<string | null>(null);
182183
const titleRef = useRef<HTMLSpanElement>(null);
183184
const [isTitleTooltipOpen, setIsTitleTooltipOpen] = useState(false);
185+
const opencodeBrowserMcpEnabled = useSharedSettings((s) =>
186+
isOpenCodeBrowserMcpEnabled(s.agentSettings.opencode),
187+
);
184188

185189
// Thread-level mode wins over the adapter-declared default. Existing rows
186190
// load from DB with `presentationMode: "terminal"` thanks to the schema
@@ -189,6 +193,10 @@ export const ThreadView = memo(function ThreadView(props: ThreadViewProps) {
189193
(thread.presentationMode ?? agentStatus?.capabilities.presentationMode ?? "terminal") ===
190194
"terminal";
191195
const launchTerminalSize = usesTerminalPresentation ? terminalSize : DEFAULT_HIDDEN_TERMINAL_SIZE;
196+
const showBrowserChip =
197+
thread.config.browserMcp === true ||
198+
(thread.agentKind === "opencode" && opencodeBrowserMcpEnabled);
199+
const browserChipRemovable = thread.agentKind !== "opencode" && thread.config.browserMcp === true;
192200

193201
useEffect(() => {
194202
const presentation =
@@ -370,10 +378,15 @@ export const ThreadView = memo(function ThreadView(props: ThreadViewProps) {
370378
{thread.title}
371379
</Tooltip.Content>
372380
</Tooltip>
373-
{thread.config.browserMcp === true ? (
381+
{showBrowserChip ? (
374382
<div className="lightcode-overlay-header__controls shrink-0">
375383
<BrowserChip
376-
onRemove={() => onConfigChange({ ...thread.config, browserMcp: false })}
384+
{...(thread.agentKind === "opencode"
385+
? { title: "Browser MCP enabled for OpenCode" }
386+
: {})}
387+
{...(browserChipRemovable
388+
? { onRemove: () => onConfigChange({ ...thread.config, browserMcp: false }) }
389+
: {})}
377390
/>
378391
</div>
379392
) : null}

src/shared/opencodeSettings.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export const OPENCODE_BROWSER_MCP_SETTING_KEY = "browserMcp";
2+
export const OPENCODE_BROWSER_MCP_DEFAULT = true;
3+
4+
export function isOpenCodeBrowserMcpEnabled(
5+
settings: Record<string, boolean | string> | undefined,
6+
): boolean {
7+
const value = settings?.[OPENCODE_BROWSER_MCP_SETTING_KEY];
8+
return typeof value === "boolean" ? value : OPENCODE_BROWSER_MCP_DEFAULT;
9+
}

src/supervisor/agents/base/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export interface AgentEnvContext {
4747
export interface AgentLaunchOptions {
4848
suppressResumeConfigOverrides?: boolean;
4949
resumeThreadId?: string;
50+
agentSettings?: Record<string, boolean | string>;
5051
}
5152

5253
export interface StructuredSessionUpdate {
@@ -107,6 +108,7 @@ export interface CreateStructuredSessionInput {
107108
threadId: string;
108109
projectLocation: ProjectLocation;
109110
config: ThreadConfig;
111+
agentSettings?: Record<string, boolean | string>;
110112
sessionRef?: SessionRef;
111113
presentationMode?: ThreadPresentationMode;
112114
loadSessionErrorRewriter?: (error: unknown, sessionId: string) => Error;

src/supervisor/agents/opencode/detection.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { homedir } from "node:os";
22
import { join } from "node:path";
33
import { stripAnsi } from "@/shared/ansi";
4+
import {
5+
OPENCODE_BROWSER_MCP_DEFAULT,
6+
OPENCODE_BROWSER_MCP_SETTING_KEY,
7+
} from "@/shared/opencodeSettings";
48
import {
59
type AgentSlashCommand,
610
compactAgentProviderMetadata,
@@ -56,7 +60,16 @@ export const opencodeDefaultCapabilities: AgentCapability = {
5660
// the same SDK helper for one-shot session-id allocation.
5761
presentationModes: ["terminal", "gui"],
5862
bypassApprovalPolicy: "yolo",
59-
settingDefs: [],
63+
settingDefs: [
64+
{
65+
key: OPENCODE_BROWSER_MCP_SETTING_KEY,
66+
type: "toggle",
67+
env: {},
68+
label: "Use Browser",
69+
description: "Expose Lightcode's internal browser to OpenCode via MCP.",
70+
default: OPENCODE_BROWSER_MCP_DEFAULT,
71+
},
72+
],
6073
};
6174

6275
/**

src/supervisor/agents/opencode/index.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { AgentCapability, PromptSegment } from "@/shared/contracts";
2+
import { isOpenCodeBrowserMcpEnabled } from "@/shared/opencodeSettings";
23
import { EXTRACTION_PROMPT } from "@/supervisor/contextExtractor";
34
import {
45
createKnownSessionRef,
@@ -17,6 +18,7 @@ import {
1718
installOpenCodePlugin,
1819
isOpenCodePluginInstalled,
1920
readBundledOpenCodePluginVersion,
21+
syncOpenCodeBrowserMcpConfigFile,
2022
uninstallOpenCodePlugin,
2123
} from "./plugin/install";
2224
import { runOpenCodeOneShot } from "./sdkOneShot";
@@ -105,6 +107,10 @@ export function createOpenCodeAdapter(): AgentAdapter {
105107
// so the supervisor knows the providerSessionId synchronously instead of
106108
// polling `opencode session list` after spawn.
107109
buildLaunchArgv(_location, config, prompt, _sessionRef, launchOptions) {
110+
syncOpenCodeBrowserMcpConfigFile(
111+
_location,
112+
isOpenCodeBrowserMcpEnabled(launchOptions?.agentSettings),
113+
);
108114
const sessionId = launchOptions?.resumeThreadId;
109115
const args = buildOpenCodeArgs(config, prompt, sessionId);
110116
return {
@@ -113,7 +119,11 @@ export function createOpenCodeAdapter(): AgentAdapter {
113119
...(sessionId ? { sessionRef: createKnownSessionRef(sessionId) } : {}),
114120
};
115121
},
116-
buildResumeArgv(_location, config, prompt, sessionRef) {
122+
buildResumeArgv(_location, config, prompt, sessionRef, launchOptions) {
123+
syncOpenCodeBrowserMcpConfigFile(
124+
_location,
125+
isOpenCodeBrowserMcpEnabled(launchOptions?.agentSettings),
126+
);
117127
return {
118128
binary: "opencode",
119129
args: buildOpenCodeArgs(config, prompt, sessionRef.providerSessionId),

src/supervisor/agents/opencode/plugin/install.ts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import { homedir } from "node:os";
1111
import { dirname, join, resolve } from "node:path";
1212
import { fileURLToPath } from "node:url";
13+
import type { ProjectLocation } from "@/shared/contracts";
1314
import { toWslUncPath } from "@/shared/wsl";
1415
import type { AgentEnvContext } from "../../base";
1516
import { resolveWslHomeDirectory } from "../../base";
@@ -233,11 +234,10 @@ export function installOpenCodePlugin(
233234
}
234235

235236
// Best-effort: scrub the dead `file://...` entry older lightcode versions
236-
// wrote into opencode.json, and merge the browser MCP entry in one
237-
// read/write cycle. Failure here doesn't block install — the plugin is
238-
// already loadable via auto-discovery.
237+
// wrote into opencode.json. Browser MCP is synced at OpenCode launch time so
238+
// it can honor the user's provider setting.
239239
const nativeConfigPath = join(resolveOpenCodeNativeConfigDir(), OPENCODE_CONFIG_FILE_NAME);
240-
updateOpenCodeConfigFile(nativeConfigPath, buildOpenCodeBrowserMcp({ kind: "windows" }));
240+
updateOpenCodeConfigFile(nativeConfigPath, undefined);
241241

242242
console.log(
243243
`[supervisor] OpenCode hook plugin staged v${manifest.version} at ${pluginDir} ` +
@@ -286,11 +286,12 @@ function installOpenCodePluginWsl(
286286
};
287287
}
288288

289-
// Same scrub on the WSL-side opencode.json — in one read/write cycle.
289+
// Same scrub on the WSL-side opencode.json. Browser MCP is synced at launch
290+
// time so it can honor the user's provider setting.
290291
const cfgDir = resolveOpenCodeWslConfigDir(distro);
291292
if (cfgDir) {
292293
const wslConfigPath = `${cfgDir.uncDir}\\${OPENCODE_CONFIG_FILE_NAME}`;
293-
updateOpenCodeConfigFile(wslConfigPath, buildOpenCodeBrowserMcp({ kind: "wsl", distro }));
294+
updateOpenCodeConfigFile(wslConfigPath, undefined);
294295
}
295296

296297
console.log(
@@ -469,6 +470,26 @@ function updateOpenCodeConfigFile(configPath: string, servers: BrowserMcpServers
469470
}
470471
}
471472

473+
export function syncOpenCodeBrowserMcpConfigFile(
474+
location: ProjectLocation,
475+
enabled: boolean,
476+
): void {
477+
if (location.kind === "wsl") {
478+
const cfgDir = resolveOpenCodeWslConfigDir(location.distro);
479+
if (!cfgDir) return;
480+
updateOpenCodeConfigFile(
481+
`${cfgDir.uncDir}\\${OPENCODE_CONFIG_FILE_NAME}`,
482+
enabled ? buildOpenCodeBrowserMcp(location) : undefined,
483+
);
484+
return;
485+
}
486+
487+
updateOpenCodeConfigFile(
488+
join(resolveOpenCodeNativeConfigDir(), OPENCODE_CONFIG_FILE_NAME),
489+
enabled ? buildOpenCodeBrowserMcp(location) : undefined,
490+
);
491+
}
492+
472493
/**
473494
* Removes the dropped plugin file from OpenCode's plugins/ directory and any
474495
* legacy drops, plus scrubs the lightcode entry from opencode.json. Staging

src/supervisor/agents/opencode/sdkClient.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client";
22
import type { ProjectLocation } from "@/shared/contracts";
33
import { resolveAgentBinaryPath } from "../binaryResolver";
4+
import { BROWSER_MCP_SERVER_NAME } from "../browserMcp";
45
import { buildOpenCodeServerCommand } from "./argv";
6+
import { buildOpenCodeBrowserMcp } from "./mcpBrowser";
57
import { spawnOpenCodeServer, type OpenCodeServerHandle } from "./sdkServer";
68

79
/** Agent-side cwd that the SDK passes through to the server's session config. */
@@ -97,6 +99,7 @@ async function spawnAndWire(projectLocation: ProjectLocation): Promise<ServerSna
9799
*/
98100
export interface AcquireOpenCodeServerInput {
99101
projectLocation: ProjectLocation;
102+
browserMcpEnabled?: boolean;
100103
/**
101104
* If set, the server stays alive for this many milliseconds after the last
102105
* release before being torn down. A re-acquire within the window reuses the
@@ -106,6 +109,29 @@ export interface AcquireOpenCodeServerInput {
106109
idleCloseDelayMs?: number;
107110
}
108111

112+
async function syncBrowserMcp(
113+
input: Pick<AcquireOpenCodeServerInput, "projectLocation" | "browserMcpEnabled">,
114+
client: OpencodeClient,
115+
): Promise<void> {
116+
const directory = resolveOpenCodeSessionDirectory(input.projectLocation);
117+
if (input.browserMcpEnabled === undefined) return;
118+
if (!input.browserMcpEnabled) {
119+
await client.mcp
120+
.disconnect({ directory, name: BROWSER_MCP_SERVER_NAME })
121+
.catch(() => undefined);
122+
return;
123+
}
124+
125+
const servers = buildOpenCodeBrowserMcp(input.projectLocation);
126+
const browser = servers?.[BROWSER_MCP_SERVER_NAME];
127+
if (!browser) return;
128+
129+
await client.mcp
130+
.add({ directory, name: BROWSER_MCP_SERVER_NAME, config: browser })
131+
.catch(() => undefined);
132+
await client.mcp.connect({ directory, name: BROWSER_MCP_SERVER_NAME });
133+
}
134+
109135
export async function acquireOpenCodeServer(
110136
input: AcquireOpenCodeServerInput,
111137
): Promise<AcquiredOpenCodeServer> {
@@ -153,6 +179,10 @@ export async function acquireOpenCodeServer(
153179

154180
let released = false;
155181
const idleCloseDelayMs = input.idleCloseDelayMs;
182+
await syncBrowserMcp(input, snapshot.client).catch((error) => {
183+
console.warn("[opencode] failed to sync Browser MCP:", error);
184+
});
185+
156186
return {
157187
client: snapshot.client,
158188
baseUrl: snapshot.baseUrl,

src/supervisor/agents/opencode/sdkSession.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import type {
2929
ThreadStatus,
3030
} from "@/shared/contracts";
3131
import { areAgentSlashCommandsEqual } from "@/shared/contracts";
32+
import { isOpenCodeBrowserMcpEnabled } from "@/shared/opencodeSettings";
3233
import {
3334
createKnownSessionRef,
3435
type AgentLaunchOptions,
@@ -47,6 +48,7 @@ import {
4748
import { mapOpenCodeSlashCommands } from "./detection";
4849
import { classifyOpenCodeError } from "./opencodeErrors";
4950
import { buildOpenCodePermissionRules } from "./permissionRules";
51+
import { syncOpenCodeBrowserMcpConfigFile } from "./plugin/install";
5052
import {
5153
acquireOpenCodeServer,
5254
resolveOpenCodeSessionDirectory,
@@ -351,8 +353,11 @@ export class OpencodeSdkSession implements StructuredSessionHandle {
351353
this.activated = true;
352354

353355
try {
356+
const browserMcpEnabled = isOpenCodeBrowserMcpEnabled(this.input.agentSettings);
357+
syncOpenCodeBrowserMcpConfigFile(this.input.projectLocation, browserMcpEnabled);
354358
this.acquired = await acquireOpenCodeServer({
355359
projectLocation: this.input.projectLocation,
360+
browserMcpEnabled,
356361
});
357362
} catch (cause) {
358363
// Surface server-startup failures (sandbox blocks, ENOENT, port races,

0 commit comments

Comments
 (0)