From 5cb0b79647a3ad8571bab4493f357e20c216b425 Mon Sep 17 00:00:00 2001 From: Shawn Edwards Date: Tue, 10 Feb 2026 23:14:53 +0000 Subject: [PATCH 01/10] fix(mcp): use Browser utility for auth flows and handle SSE browser.open events in vscode extension --- packages/opencode/src/cli/cmd/github.ts | 15 +-- packages/opencode/src/cli/cmd/web.ts | 6 +- packages/opencode/src/mcp/index.ts | 21 ++- packages/opencode/src/util/browser.ts | 24 ++++ .../opencode/test/mcp/oauth-browser.test.ts | 1 + packages/opencode/test/util/browser.test.ts | 122 ++++++++++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 17 +++ sdks/vscode/bun.lock | 1 + sdks/vscode/src/extension.ts | 105 ++++++++++++++- sdks/vscode/tsconfig.json | 2 +- 10 files changed, 281 insertions(+), 33 deletions(-) create mode 100644 packages/opencode/src/util/browser.ts create mode 100644 packages/opencode/test/util/browser.test.ts diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index 7f9a03d948a0..da8cc8e0ccd2 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -1,5 +1,4 @@ import path from "path" -import { exec } from "child_process" import * as prompts from "@clack/prompts" import { map, pipe, sortBy, values } from "remeda" import { Octokit } from "@octokit/rest" @@ -17,6 +16,7 @@ import type { } from "@octokit/webhooks-types" import { UI } from "../ui" import { cmd } from "./cmd" +import { Browser } from "@/util/browser" import { ModelsDev } from "../../provider/models" import { Instance } from "@/project/instance" import { bootstrap } from "../bootstrap" @@ -311,17 +311,8 @@ export const GithubInstallCommand = cmd({ // Open browser const url = "https://github.com/apps/opencode-agent" - const command = - process.platform === "darwin" - ? `open "${url}"` - : process.platform === "win32" - ? `start "" "${url}"` - : `xdg-open "${url}"` - - exec(command, (error) => { - if (error) { - prompts.log.warn(`Could not open browser. Please visit: ${url}`) - } + Browser.open(url).catch(() => { + prompts.log.warn(`Could not open browser. Please visit: ${url}`) }) // Wait for installation diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index 0fe056f21f2f..d7b6b9907d84 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -3,7 +3,7 @@ import { UI } from "../ui" import { cmd } from "./cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" import { Flag } from "../../flag/flag" -import open from "open" +import { Browser } from "@/util/browser" import { networkInterfaces } from "os" function getNetworkIPs() { @@ -68,11 +68,11 @@ export const WebCommand = cmd({ } // Open localhost in browser - open(localhostUrl.toString()).catch(() => {}) + Browser.open(localhostUrl.toString()) } else { const displayUrl = server.url.toString() UI.println(UI.Style.TEXT_INFO_BOLD + " Web interface: ", UI.Style.TEXT_NORMAL, displayUrl) - open(displayUrl).catch(() => {}) + Browser.open(displayUrl) } await new Promise(() => {}) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 29e958fe3572..d65d2b0aa7d6 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -22,7 +22,8 @@ import { McpAuth } from "./auth" import { BusEvent } from "../bus/bus-event" import { Bus } from "@/bus" import { TuiEvent } from "@/cli/cmd/tui/event" -import open from "open" +import { Browser } from "@/util/browser" +import { OAUTH_CALLBACK_PORT } from "./oauth-provider" export namespace MCP { const log = Log.create({ service: "mcp" }) @@ -803,14 +804,10 @@ export namespace MCP { // when the IdP has an active SSO session and redirects immediately const callbackPromise = McpOAuthCallback.waitForCallback(oauthState) - try { - const subprocess = await open(authorizationUrl) - // The open package spawns a detached process and returns immediately. - // We need to listen for errors which fire asynchronously: - // - "error" event: command not found (ENOENT) - // - "exit" with non-zero code: command exists but failed (e.g., no display) + const subprocess = await Browser.open(authorizationUrl, { callbackPort: OAUTH_CALLBACK_PORT }) + if (subprocess) { + // Not in VS Code context — wait for subprocess errors await new Promise((resolve, reject) => { - // Give the process a moment to fail if it's going to const timeout = setTimeout(() => resolve(), 500) subprocess.on("error", (error) => { clearTimeout(timeout) @@ -822,12 +819,10 @@ export namespace MCP { reject(new Error(`Browser open failed with exit code ${code}`)) } }) + }).catch((error) => { + log.warn("failed to open browser, user must open URL manually", { mcpName, error }) + Bus.publish(BrowserOpenFailed, { mcpName, url: authorizationUrl }) }) - } catch (error) { - // Browser opening failed (e.g., in remote/headless sessions like SSH, devcontainers) - // Emit event so CLI can display the URL for manual opening - log.warn("failed to open browser, user must open URL manually", { mcpName, error }) - Bus.publish(BrowserOpenFailed, { mcpName, url: authorizationUrl }) } // Wait for callback using the already-registered promise diff --git a/packages/opencode/src/util/browser.ts b/packages/opencode/src/util/browser.ts new file mode 100644 index 000000000000..792096418ada --- /dev/null +++ b/packages/opencode/src/util/browser.ts @@ -0,0 +1,24 @@ +import { BusEvent } from "@/bus/bus-event" +import { Bus } from "@/bus" +import { Ide } from "@/ide" +import openUrl from "open" +import z from "zod/v4" + +export const BrowserOpen = BusEvent.define( + "browser.open", + z.object({ + url: z.string(), + callbackPort: z.number().optional(), + }), +) + +export namespace Browser { + export async function open(url: string, options?: { callbackPort?: number }) { + Bus.publish(BrowserOpen, { + url, + callbackPort: options?.callbackPort, + }) + if (Ide.alreadyInstalled()) return + return openUrl(url) + } +} diff --git a/packages/opencode/test/mcp/oauth-browser.test.ts b/packages/opencode/test/mcp/oauth-browser.test.ts index ee4429be7576..bcd679cda60d 100644 --- a/packages/opencode/test/mcp/oauth-browser.test.ts +++ b/packages/opencode/test/mcp/oauth-browser.test.ts @@ -96,6 +96,7 @@ beforeEach(() => { openShouldFail = false openCalledWith = undefined transportCalls.length = 0 + delete process.env.OPENCODE_CALLER }) // Import modules after mocking diff --git a/packages/opencode/test/util/browser.test.ts b/packages/opencode/test/util/browser.test.ts new file mode 100644 index 000000000000..168ce7a8c394 --- /dev/null +++ b/packages/opencode/test/util/browser.test.ts @@ -0,0 +1,122 @@ +import { test, expect, mock, beforeEach } from "bun:test" + +let openCalledWith: string | undefined + +mock.module("open", () => ({ + default: async (url: string) => { + openCalledWith = url + return {} // Return a mock subprocess + }, +})) + +// Import modules after mocking +const { Browser, BrowserOpen } = await import("../../src/util/browser") +const { Bus } = await import("../../src/bus") +const { Instance } = await import("../../src/project/instance") +const { tmpdir } = await import("../fixture/fixture") + +beforeEach(() => { + openCalledWith = undefined + delete process.env.OPENCODE_CALLER +}) + +test("publishes BrowserOpen event but does not call open when OPENCODE_CALLER=vscode", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + process.env.OPENCODE_CALLER = "vscode" + + const events: Array<{ url: string; callbackPort?: number }> = [] + const unsubscribe = Bus.subscribe(BrowserOpen, (evt) => { + events.push(evt.properties) + }) + + await Browser.open("https://example.com") + + unsubscribe() + + expect(events.length).toBe(1) + expect(events[0].url).toBe("https://example.com") + expect(openCalledWith).toBeUndefined() + }, + }) +}) + +test("publishes BrowserOpen event and calls open when OPENCODE_CALLER is unset", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + delete process.env.OPENCODE_CALLER + + const events: Array<{ url: string; callbackPort?: number }> = [] + const unsubscribe = Bus.subscribe(BrowserOpen, (evt) => { + events.push(evt.properties) + }) + + await Browser.open("https://example.com") + + unsubscribe() + + expect(events.length).toBe(1) + expect(events[0].url).toBe("https://example.com") + expect(openCalledWith).toBe("https://example.com") + }, + }) +}) + +test("event payload contains correct url", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const events: Array<{ url: string; callbackPort?: number }> = [] + const unsubscribe = Bus.subscribe(BrowserOpen, (evt) => { + events.push(evt.properties) + }) + + await Browser.open("https://auth.example.com/login") + + unsubscribe() + + expect(events.length).toBe(1) + expect(events[0].url).toBe("https://auth.example.com/login") + }, + }) +}) + +test("event payload contains callbackPort when provided", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const events: Array<{ url: string; callbackPort?: number }> = [] + const unsubscribe = Bus.subscribe(BrowserOpen, (evt) => { + events.push(evt.properties) + }) + + await Browser.open("https://auth.example.com", { callbackPort: 19876 }) + + unsubscribe() + + expect(events.length).toBe(1) + expect(events[0].url).toBe("https://auth.example.com") + expect(events[0].callbackPort).toBe(19876) + }, + }) +}) + +test("open is called with the correct URL in non-VS Code context", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + delete process.env.OPENCODE_CALLER + + await Browser.open("https://specific-url.com") + + expect(openCalledWith).toBe("https://specific-url.com") + }, + }) +}) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 9543e5b5796d..8f2cd78bc1f2 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -734,6 +734,21 @@ export type EventTuiSessionSelect = { } } +export type EventIdeInstalled = { + type: "ide.installed" + properties: { + ide: string + } +} + +export type EventBrowserOpen = { + type: "browser.open" + properties: { + url: string + callbackPort?: number + } +} + export type EventMcpToolsChanged = { type: "mcp.tools.changed" properties: { @@ -933,6 +948,8 @@ export type Event = | EventTuiCommandExecute | EventTuiToastShow | EventTuiSessionSelect + | EventIdeInstalled + | EventBrowserOpen | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted diff --git a/sdks/vscode/bun.lock b/sdks/vscode/bun.lock index 085f0661a093..dde10fcf7976 100644 --- a/sdks/vscode/bun.lock +++ b/sdks/vscode/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "opencode-agent", diff --git a/sdks/vscode/src/extension.ts b/sdks/vscode/src/extension.ts index 105ab0293a8a..9e3781fb3f2d 100644 --- a/sdks/vscode/src/extension.ts +++ b/sdks/vscode/src/extension.ts @@ -5,6 +5,22 @@ import * as vscode from "vscode" const TERMINAL_NAME = "opencode" +interface SSEEvent { + type: string + properties: Record +} + +interface BrowserOpenEvent { + type: "browser.open" + properties: { + url: string + callbackPort?: number + } +} + +const sseConnections = new Map() +const lastOpenedUrls = new Map() + export function activate(context: vscode.ExtensionContext) { let openNewTerminalDisposable = vscode.commands.registerCommand("opencode.openNewTerminal", async () => { await openTerminal() @@ -12,7 +28,7 @@ export function activate(context: vscode.ExtensionContext) { let openTerminalDisposable = vscode.commands.registerCommand("opencode.openTerminal", async () => { // An opencode terminal already exists => focus it - const existingTerminal = vscode.window.terminals.find((t) => t.name === TERMINAL_NAME) + const existingTerminal = vscode.window.terminals.find((t: vscode.Terminal) => t.name === TERMINAL_NAME) if (existingTerminal) { existingTerminal.show() return @@ -33,14 +49,91 @@ export function activate(context: vscode.ExtensionContext) { } if (terminal.name === TERMINAL_NAME) { - // @ts-ignore - const port = terminal.creationOptions.env?.["_EXTENSION_OPENCODE_PORT"] + const options = terminal.creationOptions as vscode.TerminalOptions + const port = options.env?.["_EXTENSION_OPENCODE_PORT"] port ? await appendPrompt(parseInt(port), fileRef) : terminal.sendText(fileRef, false) terminal.show() } }) - context.subscriptions.push(openTerminalDisposable, addFilepathDisposable) + context.subscriptions.push(openTerminalDisposable, addFilepathDisposable, openNewTerminalDisposable) + + const terminalCloseListener = vscode.window.onDidCloseTerminal((terminal: vscode.Terminal) => { + const controller = sseConnections.get(terminal) + if (controller) { + controller.abort() + sseConnections.delete(terminal) + } + }) + context.subscriptions.push(terminalCloseListener) + + async function connectSSE(port: number, terminal: vscode.Terminal, signal: AbortSignal) { + let backoff = 1000 + const maxBackoff = 30000 + + async function connect() { + if (signal.aborted) return + + const response = await fetch(`http://localhost:${port}/event`, { signal }).catch(() => null) + if (!response || !response.body) { + if (signal.aborted) return + await new Promise((resolve) => setTimeout(resolve, backoff)) + backoff = Math.min(backoff * 2, maxBackoff) + return connect() + } + + backoff = 1000 + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = "" + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split("\n") + buffer = lines.pop() || "" + + for (const line of lines) { + if (!line.startsWith("data: ")) continue + + const json = line.slice(6) + const event = JSON.parse(json) as SSEEvent + + if (event.type === "browser.open") { + const browserEvent = event as BrowserOpenEvent + const url = browserEvent.properties.url + const callbackPort = browserEvent.properties.callbackPort + + const now = Date.now() + const lastOpened = lastOpenedUrls.get(url) + if (lastOpened && now - lastOpened < 5000) continue + + lastOpenedUrls.set(url, now) + + if (callbackPort) { + await vscode.env.asExternalUri(vscode.Uri.parse(`http://127.0.0.1:${callbackPort}`)) + } + + await vscode.env.openExternal(vscode.Uri.parse(url)) + } + } + } + } catch (e) { + if (signal.aborted) return + } + + if (!signal.aborted) { + await new Promise((resolve) => setTimeout(resolve, backoff)) + backoff = Math.min(backoff * 2, maxBackoff) + return connect() + } + } + + connect().catch(() => {}) + } async function openTerminal() { // Create a new terminal in split screen @@ -64,6 +157,10 @@ export function activate(context: vscode.ExtensionContext) { terminal.show() terminal.sendText(`opencode --port ${port}`) + const controller = new AbortController() + sseConnections.set(terminal, controller) + connectSSE(port, terminal, controller.signal) + const fileRef = getActiveFile() if (!fileRef) { return diff --git a/sdks/vscode/tsconfig.json b/sdks/vscode/tsconfig.json index 710f9ede464c..e5c0bcf3caee 100644 --- a/sdks/vscode/tsconfig.json +++ b/sdks/vscode/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "module": "Node16", "target": "ES2022", - "lib": ["ES2022"], + "lib": ["ES2022", "DOM"], "sourceMap": true, "rootDir": "src", "typeRoots": ["./node_modules/@types"], From e02acdb62ba1b3a3338678553a3a7e3edec168d4 Mon Sep 17 00:00:00 2001 From: Shawn Edwards Date: Wed, 11 Feb 2026 01:48:32 +0000 Subject: [PATCH 02/10] refactor(vscode): extract SSE parsing logic and add unit tests --- sdks/vscode/.gitignore | 3 +- sdks/vscode/src/extension.ts | 48 +++-------- sdks/vscode/src/sse.ts | 48 +++++++++++ sdks/vscode/test/sse.test.ts | 149 +++++++++++++++++++++++++++++++++++ sdks/vscode/tsconfig.json | 3 +- 5 files changed, 212 insertions(+), 39 deletions(-) create mode 100644 sdks/vscode/src/sse.ts create mode 100644 sdks/vscode/test/sse.test.ts diff --git a/sdks/vscode/.gitignore b/sdks/vscode/.gitignore index 53c37a16608c..15c3536758e2 100644 --- a/sdks/vscode/.gitignore +++ b/sdks/vscode/.gitignore @@ -1 +1,2 @@ -dist \ No newline at end of file +dist +*.vsix \ No newline at end of file diff --git a/sdks/vscode/src/extension.ts b/sdks/vscode/src/extension.ts index 9e3781fb3f2d..69a56a35f87b 100644 --- a/sdks/vscode/src/extension.ts +++ b/sdks/vscode/src/extension.ts @@ -2,24 +2,12 @@ export function deactivate() {} import * as vscode from "vscode" +import { parseSSELines, isBrowserOpen, createDeduplicator } from "./sse" const TERMINAL_NAME = "opencode" -interface SSEEvent { - type: string - properties: Record -} - -interface BrowserOpenEvent { - type: "browser.open" - properties: { - url: string - callbackPort?: number - } -} - const sseConnections = new Map() -const lastOpenedUrls = new Map() +const dedup = createDeduplicator() export function activate(context: vscode.ExtensionContext) { let openNewTerminalDisposable = vscode.commands.registerCommand("opencode.openNewTerminal", async () => { @@ -93,32 +81,18 @@ export function activate(context: vscode.ExtensionContext) { if (done) break buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split("\n") - buffer = lines.pop() || "" - - for (const line of lines) { - if (!line.startsWith("data: ")) continue + const result = parseSSELines(buffer) + buffer = result.remainder - const json = line.slice(6) - const event = JSON.parse(json) as SSEEvent + for (const event of result.events) { + if (!isBrowserOpen(event)) continue + if (dedup.isDuplicate(event.properties.url)) continue - if (event.type === "browser.open") { - const browserEvent = event as BrowserOpenEvent - const url = browserEvent.properties.url - const callbackPort = browserEvent.properties.callbackPort - - const now = Date.now() - const lastOpened = lastOpenedUrls.get(url) - if (lastOpened && now - lastOpened < 5000) continue - - lastOpenedUrls.set(url, now) - - if (callbackPort) { - await vscode.env.asExternalUri(vscode.Uri.parse(`http://127.0.0.1:${callbackPort}`)) - } - - await vscode.env.openExternal(vscode.Uri.parse(url)) + if (event.properties.callbackPort) { + await vscode.env.asExternalUri(vscode.Uri.parse(`http://127.0.0.1:${event.properties.callbackPort}`)) } + + await vscode.env.openExternal(vscode.Uri.parse(event.properties.url)) } } } catch (e) { diff --git a/sdks/vscode/src/sse.ts b/sdks/vscode/src/sse.ts new file mode 100644 index 000000000000..6282e294adf5 --- /dev/null +++ b/sdks/vscode/src/sse.ts @@ -0,0 +1,48 @@ +export interface SSEEvent { + type: string + properties: Record +} + +export interface BrowserOpenEvent { + type: "browser.open" + properties: { + url: string + callbackPort?: number + } +} + +export function parseSSELines(buffer: string): { events: SSEEvent[]; remainder: string } { + const lines = buffer.split("\n") + const remainder = lines.pop() || "" + const events: SSEEvent[] = [] + + for (const line of lines) { + if (!line.startsWith("data: ")) continue + const json = line.slice(6) + try { + events.push(JSON.parse(json) as SSEEvent) + } catch {} + } + + return { events, remainder } +} + +export function isBrowserOpen(event: SSEEvent): event is BrowserOpenEvent { + return event.type === "browser.open" && typeof (event.properties as Record).url === "string" +} + +export function createDeduplicator(windowMs = 5000) { + const recent = new Map() + + return { + isDuplicate(url: string, now = Date.now()) { + const last = recent.get(url) + if (last && now - last < windowMs) return true + recent.set(url, now) + return false + }, + clear() { + recent.clear() + }, + } +} diff --git a/sdks/vscode/test/sse.test.ts b/sdks/vscode/test/sse.test.ts new file mode 100644 index 000000000000..548689cd6b89 --- /dev/null +++ b/sdks/vscode/test/sse.test.ts @@ -0,0 +1,149 @@ +import { test, expect, beforeEach } from "bun:test" +import { parseSSELines, isBrowserOpen, createDeduplicator } from "../src/sse" +import type { SSEEvent } from "../src/sse" + +test("parseSSELines parses a single event", () => { + const buffer = 'data: {"type":"browser.open","properties":{"url":"https://example.com"}}\n' + const result = parseSSELines(buffer) + expect(result.events.length).toBe(1) + expect(result.events[0].type).toBe("browser.open") + expect(result.remainder).toBe("") +}) + +test("parseSSELines parses multiple events", () => { + const buffer = + 'data: {"type":"browser.open","properties":{"url":"https://a.com"}}\n' + + 'data: {"type":"browser.open","properties":{"url":"https://b.com"}}\n' + const result = parseSSELines(buffer) + expect(result.events.length).toBe(2) + expect((result.events[0].properties as { url: string }).url).toBe("https://a.com") + expect((result.events[1].properties as { url: string }).url).toBe("https://b.com") +}) + +test("parseSSELines preserves incomplete line as remainder", () => { + const buffer = 'data: {"type":"browser.open","properties":{"url":"https://a.com"}}\ndata: {"type":"bro' + const result = parseSSELines(buffer) + expect(result.events.length).toBe(1) + expect(result.remainder).toBe('data: {"type":"bro') +}) + +test("parseSSELines skips non-data lines", () => { + const buffer = 'event: message\ndata: {"type":"ping","properties":{}}\nid: 123\n' + const result = parseSSELines(buffer) + expect(result.events.length).toBe(1) + expect(result.events[0].type).toBe("ping") +}) + +test("parseSSELines returns empty events for empty buffer", () => { + const result = parseSSELines("") + expect(result.events.length).toBe(0) + expect(result.remainder).toBe("") +}) + +test("parseSSELines skips malformed JSON", () => { + const buffer = "data: not-json\ndata: {bad\n" + 'data: {"type":"good","properties":{}}\n' + const result = parseSSELines(buffer) + expect(result.events.length).toBe(1) + expect(result.events[0].type).toBe("good") +}) + +test("parseSSELines handles buffer with only newlines", () => { + const result = parseSSELines("\n\n\n") + expect(result.events.length).toBe(0) + expect(result.remainder).toBe("") +}) + +test("isBrowserOpen returns true for browser.open events", () => { + const event: SSEEvent = { type: "browser.open", properties: { url: "https://example.com" } } + expect(isBrowserOpen(event)).toBe(true) +}) + +test("isBrowserOpen returns false for other event types", () => { + const event: SSEEvent = { type: "ping", properties: {} } + expect(isBrowserOpen(event)).toBe(false) +}) + +test("isBrowserOpen returns false when url is missing", () => { + const event: SSEEvent = { type: "browser.open", properties: {} } + expect(isBrowserOpen(event)).toBe(false) +}) + +test("isBrowserOpen returns false when url is not a string", () => { + const event: SSEEvent = { type: "browser.open", properties: { url: 123 } } + expect(isBrowserOpen(event)).toBe(false) +}) + +test("isBrowserOpen preserves callbackPort", () => { + const event: SSEEvent = { type: "browser.open", properties: { url: "https://x.com", callbackPort: 9999 } } + expect(isBrowserOpen(event)).toBe(true) + if (isBrowserOpen(event)) { + expect(event.properties.callbackPort).toBe(9999) + } +}) + +let dedup: ReturnType + +beforeEach(() => { + dedup = createDeduplicator(5000) +}) + +test("first occurrence is not a duplicate", () => { + expect(dedup.isDuplicate("https://example.com", 1000)).toBe(false) +}) + +test("same URL within window is a duplicate", () => { + dedup.isDuplicate("https://example.com", 1000) + expect(dedup.isDuplicate("https://example.com", 3000)).toBe(true) +}) + +test("same URL after window expires is not a duplicate", () => { + dedup.isDuplicate("https://example.com", 1000) + expect(dedup.isDuplicate("https://example.com", 7000)).toBe(false) +}) + +test("different URLs are independent", () => { + dedup.isDuplicate("https://a.com", 1000) + expect(dedup.isDuplicate("https://b.com", 1000)).toBe(false) +}) + +test("same URL at exactly the window boundary is not a duplicate", () => { + dedup.isDuplicate("https://example.com", 1000) + expect(dedup.isDuplicate("https://example.com", 6000)).toBe(false) +}) + +test("clear resets deduplication state", () => { + dedup.isDuplicate("https://example.com", 1000) + dedup.clear() + expect(dedup.isDuplicate("https://example.com", 2000)).toBe(false) +}) + +test("dedup updates timestamp on non-duplicate re-entry", () => { + dedup.isDuplicate("https://example.com", 1000) + expect(dedup.isDuplicate("https://example.com", 7000)).toBe(false) + expect(dedup.isDuplicate("https://example.com", 10000)).toBe(true) +}) + +test("end-to-end: parse SSE buffer, filter browser.open, dedup", () => { + const dedup = createDeduplicator(5000) + const buffer = + 'data: {"type":"ping","properties":{}}\n' + + 'data: {"type":"browser.open","properties":{"url":"https://auth.com/login","callbackPort":8080}}\n' + + 'data: {"type":"browser.open","properties":{"url":"https://auth.com/login","callbackPort":8080}}\n' + + 'data: {"type":"browser.open","properties":{"url":"https://other.com"}}\n' + + const { events } = parseSSELines(buffer) + const now = Date.now() + const actions: Array<{ url: string; callbackPort?: number }> = [] + + for (const event of events) { + if (!isBrowserOpen(event)) continue + if (dedup.isDuplicate(event.properties.url, now)) continue + actions.push(event.properties) + } + + expect(actions.length).toBe(2) + expect(actions[0].url).toBe("https://auth.com/login") + expect(actions[0].callbackPort).toBe(8080) + expect(actions[1].url).toBe("https://other.com") + expect(actions[1].callbackPort).toBeUndefined() +}) diff --git a/sdks/vscode/tsconfig.json b/sdks/vscode/tsconfig.json index e5c0bcf3caee..7fbcf29c1c04 100644 --- a/sdks/vscode/tsconfig.json +++ b/sdks/vscode/tsconfig.json @@ -12,5 +12,6 @@ // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ // "noUnusedParameters": true, /* Report errors on unused parameters. */ - } + }, + "exclude": ["test"] } From d9fd8ed8678b311c5a4ca3bdc9869c672bda95be Mon Sep 17 00:00:00 2001 From: Shawn Edwards Date: Wed, 11 Feb 2026 03:05:50 +0000 Subject: [PATCH 03/10] feat(tui): add inline MCP OAuth authentication via MCPs dialog --- .../src/cli/cmd/tui/component/dialog-mcp.tsx | 67 ++++++++++++++----- .../cli/cmd/tui/component/dialog-status.tsx | 2 +- packages/opencode/src/mcp/index.ts | 3 +- 3 files changed, 52 insertions(+), 20 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx index 9cfa30d4df98..587707884542 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx @@ -7,9 +7,13 @@ import { useTheme } from "../context/theme" import { Keybind } from "@/util/keybind" import { TextAttributes } from "@opentui/core" import { useSDK } from "@tui/context/sdk" +import { useToast } from "@tui/ui/toast" -function Status(props: { enabled: boolean; loading: boolean }) { +function Status(props: { enabled: boolean; loading: boolean; authenticating: boolean }) { const { theme } = useTheme() + if (props.authenticating) { + return ⋯ Authenticating + } if (props.loading) { return ⋯ Loading } @@ -23,13 +27,15 @@ export function DialogMcp() { const local = useLocal() const sync = useSync() const sdk = useSDK() + const toast = useToast() const [, setRef] = createSignal>() const [loading, setLoading] = createSignal(null) + const [authenticating, setAuthenticating] = createSignal(null) const options = createMemo(() => { - // Track sync data and loading state to trigger re-render when they change const mcpData = sync.data.mcp const loadingMcp = loading() + const authingMcp = authenticating() return pipe( mcpData ?? {}, @@ -39,7 +45,13 @@ export function DialogMcp() { value: name, title: name, description: status.status === "failed" ? "failed" : status.status, - footer: , + footer: ( + + ), category: undefined, })), ) @@ -50,13 +62,11 @@ export function DialogMcp() { keybind: Keybind.parse("space")[0], title: "toggle", onTrigger: async (option: DialogSelectOption) => { - // Prevent toggling while an operation is already in progress - if (loading() !== null) return + if (loading() !== null || authenticating() !== null) return setLoading(option.value) try { await local.mcp.toggle(option.value) - // Refresh MCP status from server const status = await sdk.client.mcp.status() if (status.data) { sync.set("mcp", status.data) @@ -70,17 +80,40 @@ export function DialogMcp() { } }, }, + { + keybind: Keybind.parse("a")[0], + title: "auth", + disabled: !Object.values(sync.data.mcp ?? {}).some( + (s) => s.status === "needs_auth" || s.status === ("needs_auth" as string), + ), + onTrigger: async (option: DialogSelectOption) => { + if (loading() !== null || authenticating() !== null) return + const status = sync.data.mcp[option.value] + if (status?.status !== "needs_auth") return + + setAuthenticating(option.value) + try { + const result = await sdk.client.mcp.auth.authenticate({ name: option.value }) + if (result.data && "status" in result.data && result.data.status === "connected") { + toast.show({ variant: "success", message: `${option.value} authenticated` }) + } else { + toast.show({ variant: "error", message: `${option.value} authentication failed` }) + } + const refreshed = await sdk.client.mcp.status() + if (refreshed.data) { + sync.set("mcp", refreshed.data) + } + } catch (error) { + toast.show({ + variant: "error", + message: `Failed to authenticate ${option.value}`, + }) + } finally { + setAuthenticating(null) + } + }, + }, ]) - return ( - { - // Don't close on select, only on escape - }} - /> - ) + return {}} /> } diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx index 3b6b5ef21827..5740e458e400 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx @@ -79,7 +79,7 @@ export function DialogStatus() { {(val) => val().error} Disabled in configuration - Needs authentication (run: opencode mcp auth {key}) + Needs authentication (open MCPs dialog, press 'a') {(val) => (val() as { error: string }).error} diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index d65d2b0aa7d6..e086f64fa23d 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -381,10 +381,9 @@ export namespace MCP { // Store transport for later finishAuth call pendingOAuthTransports.set(key, transport) status = { status: "needs_auth" as const } - // Show toast for needs_auth Bus.publish(TuiEvent.ToastShow, { title: "MCP Authentication Required", - message: `Server "${key}" requires authentication. Run: opencode mcp auth ${key}`, + message: `Server "${key}" requires authentication. Open MCPs dialog and press 'a' to authenticate.`, variant: "warning", duration: 8000, }).catch((e) => log.debug("failed to show toast", { error: e })) From 44851e7e8bf1d23c276252724ef3288acab5daf9 Mon Sep 17 00:00:00 2001 From: Shawn Edwards Date: Wed, 11 Feb 2026 03:13:26 +0000 Subject: [PATCH 04/10] fix(mcp): use localhost instead of 127.0.0.1 for OAuth redirect URI to match provider expectations --- packages/opencode/src/mcp/oauth-provider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/mcp/oauth-provider.ts b/packages/opencode/src/mcp/oauth-provider.ts index 35ead25e8beb..3661034cc024 100644 --- a/packages/opencode/src/mcp/oauth-provider.ts +++ b/packages/opencode/src/mcp/oauth-provider.ts @@ -32,7 +32,7 @@ export class McpOAuthProvider implements OAuthClientProvider { ) {} get redirectUrl(): string { - return `http://127.0.0.1:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}` + return `http://localhost:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}` } get clientMetadata(): OAuthClientMetadata { From 79751b7cc3df7396232fc8dc5a3043c50979bd80 Mon Sep 17 00:00:00 2001 From: Shawn Edwards Date: Wed, 11 Feb 2026 03:45:55 +0000 Subject: [PATCH 05/10] fix(mcp): use dynamic port for OAuth callback server to avoid multi-instance conflicts --- packages/opencode/src/cli/cmd/mcp.ts | 3 ++ packages/opencode/src/mcp/auth.ts | 9 ++++- packages/opencode/src/mcp/index.ts | 11 +++--- packages/opencode/src/mcp/oauth-callback.ts | 39 +++++---------------- packages/opencode/src/mcp/oauth-provider.ts | 12 +++++-- 5 files changed, 35 insertions(+), 39 deletions(-) diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index 95719215e324..d02d22834e12 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -7,6 +7,7 @@ import { UI } from "../ui" import { MCP } from "../../mcp" import { McpAuth } from "../../mcp/auth" import { McpOAuthProvider } from "../../mcp/oauth-provider" +import { McpOAuthCallback } from "../../mcp/oauth-callback" import { Config } from "../../config/config" import { Instance } from "../../project/instance" import { Installation } from "../../installation" @@ -682,6 +683,7 @@ export const McpDebugCommand = cmd({ // Try to discover OAuth metadata const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined + await McpOAuthCallback.ensureRunning() const authProvider = new McpOAuthProvider( serverName, serverConfig.url, @@ -693,6 +695,7 @@ export const McpDebugCommand = cmd({ { onRedirect: async () => {}, }, + McpOAuthCallback.port(), ) prompts.log.info("Testing OAuth flow (without completing authorization)...") diff --git a/packages/opencode/src/mcp/auth.ts b/packages/opencode/src/mcp/auth.ts index 0f91a35b8754..297e2e5aa935 100644 --- a/packages/opencode/src/mcp/auth.ts +++ b/packages/opencode/src/mcp/auth.ts @@ -25,6 +25,7 @@ export namespace McpAuth { codeVerifier: z.string().optional(), oauthState: z.string().optional(), serverUrl: z.string().optional(), // Track the URL these credentials are for + redirectUri: z.string().optional(), // Track redirect URI used during client registration }) export type Entry = z.infer @@ -80,9 +81,15 @@ export namespace McpAuth { await set(mcpName, entry, serverUrl) } - export async function updateClientInfo(mcpName: string, clientInfo: ClientInfo, serverUrl?: string): Promise { + export async function updateClientInfo( + mcpName: string, + clientInfo: ClientInfo, + serverUrl?: string, + redirectUri?: string, + ): Promise { const entry = (await get(mcpName)) ?? {} entry.clientInfo = clientInfo + if (redirectUri) entry.redirectUri = redirectUri await set(mcpName, entry, serverUrl) } diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index e086f64fa23d..577a51c81bac 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -23,7 +23,6 @@ import { BusEvent } from "../bus/bus-event" import { Bus } from "@/bus" import { TuiEvent } from "@/cli/cmd/tui/event" import { Browser } from "@/util/browser" -import { OAUTH_CALLBACK_PORT } from "./oauth-provider" export namespace MCP { const log = Log.create({ service: "mcp" }) @@ -309,6 +308,7 @@ export namespace MCP { let authProvider: McpOAuthProvider | undefined if (!oauthDisabled) { + await McpOAuthCallback.ensureRunning() authProvider = new McpOAuthProvider( key, mcp.url, @@ -323,6 +323,7 @@ export namespace MCP { // Store the URL - actual browser opening is handled by startAuth }, }, + McpOAuthCallback.port(), ) } @@ -726,8 +727,9 @@ export namespace MCP { // Start the callback server await McpOAuthCallback.ensureRunning() - // Generate and store a cryptographically secure state parameter BEFORE creating the provider - // The SDK will call provider.state() to read this value + // Clear stale auth data so we start fresh + await McpAuth.remove(mcpName) + const oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32))) .map((b) => b.toString(16).padStart(2, "0")) .join("") @@ -750,6 +752,7 @@ export namespace MCP { capturedUrl = url }, }, + McpOAuthCallback.port(), ) // Create transport with auth provider @@ -803,7 +806,7 @@ export namespace MCP { // when the IdP has an active SSO session and redirects immediately const callbackPromise = McpOAuthCallback.waitForCallback(oauthState) - const subprocess = await Browser.open(authorizationUrl, { callbackPort: OAUTH_CALLBACK_PORT }) + const subprocess = await Browser.open(authorizationUrl, { callbackPort: McpOAuthCallback.port() }) if (subprocess) { // Not in VS Code context — wait for subprocess errors await new Promise((resolve, reject) => { diff --git a/packages/opencode/src/mcp/oauth-callback.ts b/packages/opencode/src/mcp/oauth-callback.ts index bb3b56f2e95f..3d4249352236 100644 --- a/packages/opencode/src/mcp/oauth-callback.ts +++ b/packages/opencode/src/mcp/oauth-callback.ts @@ -1,5 +1,5 @@ import { Log } from "../util/log" -import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider" +import { OAUTH_CALLBACK_PATH } from "./oauth-provider" const log = Log.create({ service: "mcp.oauth-callback" }) @@ -56,17 +56,16 @@ export namespace McpOAuthCallback { const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes + export function port(): number { + if (!server?.port) throw new Error("OAuth callback server not running") + return server.port + } + export async function ensureRunning(): Promise { if (server) return - const running = await isPortInUse() - if (running) { - log.info("oauth callback server already running on another instance", { port: OAUTH_CALLBACK_PORT }) - return - } - server = Bun.serve({ - port: OAUTH_CALLBACK_PORT, + port: 0, fetch(req) { const url = new URL(req.url) @@ -133,7 +132,7 @@ export namespace McpOAuthCallback { }, }) - log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT }) + log.info("oauth callback server started", { port: server.port }) } export function waitForCallback(oauthState: string): Promise { @@ -158,28 +157,6 @@ export namespace McpOAuthCallback { } } - export async function isPortInUse(): Promise { - return new Promise((resolve) => { - Bun.connect({ - hostname: "127.0.0.1", - port: OAUTH_CALLBACK_PORT, - socket: { - open(socket) { - socket.end() - resolve(true) - }, - error() { - resolve(false) - }, - data() {}, - close() {}, - }, - }).catch(() => { - resolve(false) - }) - }) - } - export async function stop(): Promise { if (server) { server.stop() diff --git a/packages/opencode/src/mcp/oauth-provider.ts b/packages/opencode/src/mcp/oauth-provider.ts index 3661034cc024..8e39b026ed25 100644 --- a/packages/opencode/src/mcp/oauth-provider.ts +++ b/packages/opencode/src/mcp/oauth-provider.ts @@ -10,7 +10,6 @@ import { Log } from "../util/log" const log = Log.create({ service: "mcp.oauth" }) -const OAUTH_CALLBACK_PORT = 19876 const OAUTH_CALLBACK_PATH = "/mcp/oauth/callback" export interface McpOAuthConfig { @@ -29,10 +28,11 @@ export class McpOAuthProvider implements OAuthClientProvider { private serverUrl: string, private config: McpOAuthConfig, private callbacks: McpOAuthCallbacks, + private callbackPort: number, ) {} get redirectUrl(): string { - return `http://localhost:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}` + return `http://localhost:${this.callbackPort}${OAUTH_CALLBACK_PATH}` } get clientMetadata(): OAuthClientMetadata { @@ -64,6 +64,11 @@ export class McpOAuthProvider implements OAuthClientProvider { log.info("client secret expired, need to re-register", { mcpName: this.mcpName }) return undefined } + // Check if redirect URI changed (dynamic port) + if (entry.redirectUri && entry.redirectUri !== this.redirectUrl) { + log.info("redirect URI changed, need to re-register", { mcpName: this.mcpName }) + return undefined + } return { client_id: entry.clientInfo.clientId, client_secret: entry.clientInfo.clientSecret, @@ -84,6 +89,7 @@ export class McpOAuthProvider implements OAuthClientProvider { clientSecretExpiresAt: info.client_secret_expires_at, }, this.serverUrl, + this.redirectUrl, ) log.info("saved dynamically registered client", { mcpName: this.mcpName, @@ -151,4 +157,4 @@ export class McpOAuthProvider implements OAuthClientProvider { } } -export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } +export { OAUTH_CALLBACK_PATH } From 4d9a8ffb0f0293fe1dc33c4e8e2be8547f452787 Mon Sep 17 00:00:00 2001 From: Shawn Edwards Date: Wed, 11 Feb 2026 03:54:21 +0000 Subject: [PATCH 06/10] fix(mcp): convert CallToolResult to expected output shape for tool result processing --- packages/opencode/src/mcp/index.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 577a51c81bac..e1b9727d6984 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -6,6 +6,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" import { CallToolResultSchema, + type CallToolResult, type Tool as MCPToolDef, ToolListChangedNotificationSchema, } from "@modelcontextprotocol/sdk/types.js" @@ -132,7 +133,7 @@ export namespace MCP { description: mcpTool.description ?? "", inputSchema: jsonSchema(schema), execute: async (args: unknown) => { - return client.callTool( + const result = (await client.callTool( { name: mcpTool.name, arguments: (args || {}) as Record, @@ -142,7 +143,16 @@ export namespace MCP { resetTimeoutOnProgress: true, timeout, }, - ) + )) as CallToolResult + const text = result.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join("\n") + return { + output: text, + title: mcpTool.name, + metadata: { isError: result.isError }, + } }, }) } From 0fa92b079750108b11eed1da3c45fb9aaddc9657 Mon Sep 17 00:00:00 2001 From: Shawn Edwards Date: Wed, 11 Feb 2026 03:56:23 +0000 Subject: [PATCH 07/10] fix(mcp): handle missing or non-standard content in CallToolResult --- packages/opencode/src/mcp/index.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index e1b9727d6984..36d5f5c96496 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -144,14 +144,15 @@ export namespace MCP { timeout, }, )) as CallToolResult - const text = result.content - .filter((c): c is { type: "text"; text: string } => c.type === "text") + const content = Array.isArray(result?.content) ? result.content : [] + const text = content + .filter((c): c is { type: "text"; text: string } => c.type === "text" && typeof c.text === "string") .map((c) => c.text) .join("\n") return { - output: text, + output: text || JSON.stringify(result ?? {}), title: mcpTool.name, - metadata: { isError: result.isError }, + metadata: { isError: result?.isError }, } }, }) From 3d966c4213346e17624f966c1443c82a39155872 Mon Sep 17 00:00:00 2001 From: Shawn Edwards Date: Tue, 10 Feb 2026 22:04:48 -0600 Subject: [PATCH 08/10] fix(mcp): remove dead content-processing block that crashes on MCP tool results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP tool wrapper in prompt.ts iterated result.content expecting raw CallToolResult from the MCP SDK, but convertMcpTool already extracts text and returns a flat { output, title, metadata } shape — so result.content was always undefined, causing TypeError on every MCP tool call. --- packages/opencode/src/session/prompt.ts | 50 +------------------------ 1 file changed, 1 insertion(+), 49 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index d7f73b4f6097..61edf4c4bacb 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -45,7 +45,6 @@ import { SessionStatus } from "./status" import { LLM } from "./llm" import { iife } from "@/util/iife" import { Shell } from "@/shell/shell" -import { Truncate } from "@/tool/truncation" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -795,54 +794,7 @@ export namespace SessionPrompt { result, ) - const textParts: string[] = [] - const attachments: MessageV2.FilePart[] = [] - - for (const contentItem of result.content) { - if (contentItem.type === "text") { - textParts.push(contentItem.text) - } else if (contentItem.type === "image") { - attachments.push({ - id: Identifier.ascending("part"), - sessionID: input.session.id, - messageID: input.processor.message.id, - type: "file", - mime: contentItem.mimeType, - url: `data:${contentItem.mimeType};base64,${contentItem.data}`, - }) - } else if (contentItem.type === "resource") { - const { resource } = contentItem - if (resource.text) { - textParts.push(resource.text) - } - if (resource.blob) { - attachments.push({ - id: Identifier.ascending("part"), - sessionID: input.session.id, - messageID: input.processor.message.id, - type: "file", - mime: resource.mimeType ?? "application/octet-stream", - url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`, - filename: resource.uri, - }) - } - } - } - - const truncated = await Truncate.output(textParts.join("\n\n"), {}, input.agent) - const metadata = { - ...(result.metadata ?? {}), - truncated: truncated.truncated, - ...(truncated.truncated && { outputPath: truncated.outputPath }), - } - - return { - title: "", - metadata, - output: truncated.content, - attachments, - content: result.content, // directly return content to preserve ordering when outputting to model - } + return result } tools[key] = item } From c0de0df2456fbb16a1d01b1aad2acac66866f1fc Mon Sep 17 00:00:00 2001 From: Shawn Edwards Date: Wed, 11 Feb 2026 04:14:01 +0000 Subject: [PATCH 09/10] refactor(mcp): auto-authenticate on connect, remove separate auth keybind --- .../src/cli/cmd/tui/component/dialog-mcp.tsx | 52 ++----------------- .../cli/cmd/tui/component/dialog-status.tsx | 2 +- packages/opencode/src/mcp/index.ts | 13 ++++- 3 files changed, 16 insertions(+), 51 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx index 587707884542..99052162cb22 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx @@ -7,13 +7,9 @@ import { useTheme } from "../context/theme" import { Keybind } from "@/util/keybind" import { TextAttributes } from "@opentui/core" import { useSDK } from "@tui/context/sdk" -import { useToast } from "@tui/ui/toast" -function Status(props: { enabled: boolean; loading: boolean; authenticating: boolean }) { +function Status(props: { enabled: boolean; loading: boolean }) { const { theme } = useTheme() - if (props.authenticating) { - return ⋯ Authenticating - } if (props.loading) { return ⋯ Loading } @@ -27,15 +23,12 @@ export function DialogMcp() { const local = useLocal() const sync = useSync() const sdk = useSDK() - const toast = useToast() const [, setRef] = createSignal>() const [loading, setLoading] = createSignal(null) - const [authenticating, setAuthenticating] = createSignal(null) const options = createMemo(() => { const mcpData = sync.data.mcp const loadingMcp = loading() - const authingMcp = authenticating() return pipe( mcpData ?? {}, @@ -45,13 +38,7 @@ export function DialogMcp() { value: name, title: name, description: status.status === "failed" ? "failed" : status.status, - footer: ( - - ), + footer: , category: undefined, })), ) @@ -62,7 +49,7 @@ export function DialogMcp() { keybind: Keybind.parse("space")[0], title: "toggle", onTrigger: async (option: DialogSelectOption) => { - if (loading() !== null || authenticating() !== null) return + if (loading() !== null) return setLoading(option.value) try { @@ -80,39 +67,6 @@ export function DialogMcp() { } }, }, - { - keybind: Keybind.parse("a")[0], - title: "auth", - disabled: !Object.values(sync.data.mcp ?? {}).some( - (s) => s.status === "needs_auth" || s.status === ("needs_auth" as string), - ), - onTrigger: async (option: DialogSelectOption) => { - if (loading() !== null || authenticating() !== null) return - const status = sync.data.mcp[option.value] - if (status?.status !== "needs_auth") return - - setAuthenticating(option.value) - try { - const result = await sdk.client.mcp.auth.authenticate({ name: option.value }) - if (result.data && "status" in result.data && result.data.status === "connected") { - toast.show({ variant: "success", message: `${option.value} authenticated` }) - } else { - toast.show({ variant: "error", message: `${option.value} authentication failed` }) - } - const refreshed = await sdk.client.mcp.status() - if (refreshed.data) { - sync.set("mcp", refreshed.data) - } - } catch (error) { - toast.show({ - variant: "error", - message: `Failed to authenticate ${option.value}`, - }) - } finally { - setAuthenticating(null) - } - }, - }, ]) return {}} /> diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx index 5740e458e400..f6ba8f0f0bd8 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx @@ -79,7 +79,7 @@ export function DialogStatus() { {(val) => val().error} Disabled in configuration - Needs authentication (open MCPs dialog, press 'a') + Needs authentication (enable to start auth flow) {(val) => (val() as { error: string }).error} diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 36d5f5c96496..8bb653001ce8 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -395,7 +395,7 @@ export namespace MCP { status = { status: "needs_auth" as const } Bus.publish(TuiEvent.ToastShow, { title: "MCP Authentication Required", - message: `Server "${key}" requires authentication. Open MCPs dialog and press 'a' to authenticate.`, + message: `Server "${key}" requires authentication. Enable it to start the auth flow.`, variant: "warning", duration: 8000, }).catch((e) => log.debug("failed to show toast", { error: e })) @@ -549,6 +549,17 @@ export namespace MCP { return } + if (result.status.status === "needs_auth") { + try { + const authResult = await authenticate(name) + const s = await state() + s.status[name] = authResult + return + } catch (error) { + log.error("auto-auth failed during connect", { name, error }) + } + } + const s = await state() s.status[name] = result.status if (result.mcpClient) { From 8665b02753b1ca722de61a8ab300268a8597b40e Mon Sep 17 00:00:00 2001 From: Shawn Edwards Date: Wed, 11 Feb 2026 06:04:43 +0000 Subject: [PATCH 10/10] fix(mcp): generate OAuth state on-demand to fix MCP auth in VS Code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP SDK's OAuthClientProvider.state() is a generator, not a retriever — the SDK never calls saveState(). The previous implementation threw when no state was pre-saved, causing all transport connections to fail silently for OAuth-protected servers. Also fixes the VS Code extension SSE endpoint path, adds payload unwrapping for global events, and promotes transport connection failure logs from debug to info for better diagnostics. --- packages/opencode/src/mcp/index.ts | 2 +- packages/opencode/src/mcp/oauth-provider.ts | 10 +++--- sdks/vscode/src/extension.ts | 35 +++++++++++++++++---- sdks/vscode/src/sse.ts | 6 +++- 4 files changed, 41 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 8bb653001ce8..c0d20c110b9e 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -403,7 +403,7 @@ export namespace MCP { break } - log.debug("transport connection failed", { + log.info("transport connection failed", { key, transport: name, url: mcp.url, diff --git a/packages/opencode/src/mcp/oauth-provider.ts b/packages/opencode/src/mcp/oauth-provider.ts index 8e39b026ed25..591937cfb86d 100644 --- a/packages/opencode/src/mcp/oauth-provider.ts +++ b/packages/opencode/src/mcp/oauth-provider.ts @@ -150,10 +150,12 @@ export class McpOAuthProvider implements OAuthClientProvider { async state(): Promise { const entry = await McpAuth.get(this.mcpName) - if (!entry?.oauthState) { - throw new Error(`No OAuth state saved for MCP server: ${this.mcpName}`) - } - return entry.oauthState + if (entry?.oauthState) return entry.oauthState + const state = Array.from(crypto.getRandomValues(new Uint8Array(32))) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") + await McpAuth.updateOAuthState(this.mcpName, state) + return state } } diff --git a/sdks/vscode/src/extension.ts b/sdks/vscode/src/extension.ts index 69a56a35f87b..76e7250752bd 100644 --- a/sdks/vscode/src/extension.ts +++ b/sdks/vscode/src/extension.ts @@ -8,8 +8,10 @@ const TERMINAL_NAME = "opencode" const sseConnections = new Map() const dedup = createDeduplicator() +const log = vscode.window.createOutputChannel("opencode", { log: true }) export function activate(context: vscode.ExtensionContext) { + context.subscriptions.push(log) let openNewTerminalDisposable = vscode.commands.registerCommand("opencode.openNewTerminal", async () => { await openTerminal() }) @@ -62,14 +64,20 @@ export function activate(context: vscode.ExtensionContext) { async function connect() { if (signal.aborted) return - const response = await fetch(`http://localhost:${port}/event`, { signal }).catch(() => null) + log.info(`[sse] connecting to http://localhost:${port}/global/event`) + const response = await fetch(`http://localhost:${port}/global/event`, { signal }).catch((e) => { + log.debug(`[sse] fetch failed: ${e}`) + return null + }) if (!response || !response.body) { if (signal.aborted) return + log.debug(`[sse] no response, retrying in ${backoff}ms`) await new Promise((resolve) => setTimeout(resolve, backoff)) backoff = Math.min(backoff * 2, maxBackoff) return connect() } + log.info(`[sse] connected to port ${port}`) backoff = 1000 const reader = response.body.getReader() const decoder = new TextDecoder() @@ -85,28 +93,43 @@ export function activate(context: vscode.ExtensionContext) { buffer = result.remainder for (const event of result.events) { + log.debug(`[sse] event: ${event.type}`) if (!isBrowserOpen(event)) continue - if (dedup.isDuplicate(event.properties.url)) continue - if (event.properties.callbackPort) { - await vscode.env.asExternalUri(vscode.Uri.parse(`http://127.0.0.1:${event.properties.callbackPort}`)) + const url = event.properties.url + const callbackPort = event.properties.callbackPort + log.info(`[sse] browser.open event: url=${url}, callbackPort=${callbackPort}`) + + if (dedup.isDuplicate(url)) { + log.debug(`[sse] duplicate url, skipping`) + continue + } + + if (callbackPort) { + log.info(`[sse] calling asExternalUri for callback port ${callbackPort}`) + const mapped = await vscode.env.asExternalUri(vscode.Uri.parse(`http://127.0.0.1:${callbackPort}`)) + log.info(`[sse] asExternalUri result: ${mapped.toString()}`) } - await vscode.env.openExternal(vscode.Uri.parse(event.properties.url)) + log.info(`[sse] calling openExternal: ${url}`) + const opened = await vscode.env.openExternal(vscode.Uri.parse(url)) + log.info(`[sse] openExternal result: ${opened}`) } } } catch (e) { if (signal.aborted) return + log.error(`[sse] stream error: ${e}`) } if (!signal.aborted) { + log.info(`[sse] stream ended, reconnecting in ${backoff}ms`) await new Promise((resolve) => setTimeout(resolve, backoff)) backoff = Math.min(backoff * 2, maxBackoff) return connect() } } - connect().catch(() => {}) + connect().catch((e) => log.error(`[sse] connect failed: ${e}`)) } async function openTerminal() { diff --git a/sdks/vscode/src/sse.ts b/sdks/vscode/src/sse.ts index 6282e294adf5..5e6a6ae517a5 100644 --- a/sdks/vscode/src/sse.ts +++ b/sdks/vscode/src/sse.ts @@ -20,7 +20,11 @@ export function parseSSELines(buffer: string): { events: SSEEvent[]; remainder: if (!line.startsWith("data: ")) continue const json = line.slice(6) try { - events.push(JSON.parse(json) as SSEEvent) + const parsed = JSON.parse(json) + // Global SSE events are wrapped as { directory, payload: { type, properties } } + // Instance SSE events are { type, properties } directly + const event = parsed.payload ?? parsed + events.push(event as SSEEvent) } catch {} }