Skip to content

Commit 63c552e

Browse files
committed
fix(mcp): show auth URL when browser cannot open in remote sessions
When running `opencode mcp auth` in headless environments (SSH, devcontainers, WSL), the browser cannot be opened automatically. Previously this would fail with a cryptic "Something went wrong" error. Now the auth URL is displayed so users can manually open it in their local browser. The fix catches three failure modes from the `open` package: - Synchronous exception: thrown when open() itself fails - "error" event: fires when the command doesn't exist (ENOENT) - "exit" event with non-zero code: fires when the command exists but fails (e.g., xdg-open can't connect to a display server)
1 parent d475107 commit 63c552e

File tree

3 files changed

+308
-1
lines changed

3 files changed

+308
-1
lines changed

packages/opencode/src/cli/cmd/mcp.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { Installation } from "../../installation"
1313
import path from "path"
1414
import { Global } from "../../global"
1515
import { modify, applyEdits } from "jsonc-parser"
16+
import { Bus } from "../../bus"
1617

1718
function getAuthStatusIcon(status: MCP.AuthStatus): string {
1819
switch (status) {
@@ -227,6 +228,16 @@ export const McpAuthCommand = cmd({
227228
const spinner = prompts.spinner()
228229
spinner.start("Starting OAuth flow...")
229230

231+
// Subscribe to browser open failure events to show URL for manual opening
232+
const unsubscribe = Bus.subscribe(MCP.BrowserOpenFailed, (evt) => {
233+
if (evt.properties.mcpName === serverName) {
234+
spinner.stop("Could not open browser automatically")
235+
prompts.log.warn("Please open this URL in your browser to authenticate:")
236+
prompts.log.info(evt.properties.url)
237+
spinner.start("Waiting for authorization...")
238+
}
239+
})
240+
230241
try {
231242
const status = await MCP.authenticate(serverName)
232243

@@ -256,6 +267,8 @@ export const McpAuthCommand = cmd({
256267
} catch (error) {
257268
spinner.stop("Authentication failed", 1)
258269
prompts.log.error(error instanceof Error ? error.message : String(error))
270+
} finally {
271+
unsubscribe()
259272
}
260273

261274
prompts.outro("Done")

packages/opencode/src/mcp/index.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,14 @@ export namespace MCP {
4646
}),
4747
)
4848

49+
export const BrowserOpenFailed = BusEvent.define(
50+
"mcp.browser.open.failed",
51+
z.object({
52+
mcpName: z.string(),
53+
url: z.string(),
54+
}),
55+
)
56+
4957
export const Failed = NamedError.create(
5058
"MCPFailed",
5159
z.object({
@@ -787,7 +795,32 @@ export namespace MCP {
787795
// The SDK has already added the state parameter to the authorization URL
788796
// We just need to open the browser
789797
log.info("opening browser for oauth", { mcpName, url: authorizationUrl, state: oauthState })
790-
await open(authorizationUrl)
798+
try {
799+
const subprocess = await open(authorizationUrl)
800+
// The open package spawns a detached process and returns immediately.
801+
// We need to listen for errors which fire asynchronously:
802+
// - "error" event: command not found (ENOENT)
803+
// - "exit" with non-zero code: command exists but failed (e.g., no display)
804+
await new Promise<void>((resolve, reject) => {
805+
// Give the process a moment to fail if it's going to
806+
const timeout = setTimeout(() => resolve(), 500)
807+
subprocess.on("error", (error) => {
808+
clearTimeout(timeout)
809+
reject(error)
810+
})
811+
subprocess.on("exit", (code) => {
812+
if (code !== null && code !== 0) {
813+
clearTimeout(timeout)
814+
reject(new Error(`Browser open failed with exit code ${code}`))
815+
}
816+
})
817+
})
818+
} catch (error) {
819+
// Browser opening failed (e.g., in remote/headless sessions like SSH, devcontainers)
820+
// Emit event so CLI can display the URL for manual opening
821+
log.warn("failed to open browser, user must open URL manually", { mcpName, error })
822+
Bus.publish(BrowserOpenFailed, { mcpName, url: authorizationUrl })
823+
}
791824

792825
// Wait for callback using the OAuth state parameter
793826
const code = await McpOAuthCallback.waitForCallback(oauthState)
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
import { test, expect, mock, beforeEach } from "bun:test"
2+
import { EventEmitter } from "events"
3+
4+
// Track open() calls and control failure behavior
5+
let openShouldFail = false
6+
let openCalledWith: string | undefined
7+
8+
mock.module("open", () => ({
9+
default: async (url: string) => {
10+
openCalledWith = url
11+
// Return a mock subprocess that emits an error if openShouldFail is true
12+
const subprocess = new EventEmitter()
13+
if (openShouldFail) {
14+
// Emit error asynchronously like a real subprocess would
15+
setTimeout(() => {
16+
subprocess.emit("error", new Error("spawn xdg-open ENOENT"))
17+
}, 10)
18+
}
19+
return subprocess
20+
},
21+
}))
22+
23+
// Mock UnauthorizedError
24+
class MockUnauthorizedError extends Error {
25+
constructor() {
26+
super("Unauthorized")
27+
this.name = "UnauthorizedError"
28+
}
29+
}
30+
31+
// Track what options were passed to each transport constructor
32+
const transportCalls: Array<{
33+
type: "streamable" | "sse"
34+
url: string
35+
options: { authProvider?: unknown }
36+
}> = []
37+
38+
// Mock the transport constructors
39+
mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
40+
StreamableHTTPClientTransport: class MockStreamableHTTP {
41+
url: string
42+
authProvider: { redirectToAuthorization?: (url: URL) => Promise<void> } | undefined
43+
constructor(url: URL, options?: { authProvider?: { redirectToAuthorization?: (url: URL) => Promise<void> } }) {
44+
this.url = url.toString()
45+
this.authProvider = options?.authProvider
46+
transportCalls.push({
47+
type: "streamable",
48+
url: url.toString(),
49+
options: options ?? {},
50+
})
51+
}
52+
async start() {
53+
// Simulate OAuth redirect by calling the authProvider's redirectToAuthorization
54+
if (this.authProvider?.redirectToAuthorization) {
55+
await this.authProvider.redirectToAuthorization(new URL("https://auth.example.com/authorize?client_id=test"))
56+
}
57+
throw new MockUnauthorizedError()
58+
}
59+
async finishAuth(_code: string) {
60+
// Mock successful auth completion
61+
}
62+
},
63+
}))
64+
65+
mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({
66+
SSEClientTransport: class MockSSE {
67+
constructor(url: URL) {
68+
transportCalls.push({
69+
type: "sse",
70+
url: url.toString(),
71+
options: {},
72+
})
73+
}
74+
async start() {
75+
throw new Error("Mock SSE transport cannot connect")
76+
}
77+
},
78+
}))
79+
80+
// Mock the MCP SDK Client to trigger OAuth flow
81+
mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
82+
Client: class MockClient {
83+
async connect(transport: { start: () => Promise<void> }) {
84+
await transport.start()
85+
}
86+
},
87+
}))
88+
89+
// Mock UnauthorizedError in the auth module
90+
mock.module("@modelcontextprotocol/sdk/client/auth.js", () => ({
91+
UnauthorizedError: MockUnauthorizedError,
92+
}))
93+
94+
beforeEach(() => {
95+
openShouldFail = false
96+
openCalledWith = undefined
97+
transportCalls.length = 0
98+
})
99+
100+
// Import modules after mocking
101+
const { MCP } = await import("../../src/mcp/index")
102+
const { Bus } = await import("../../src/bus")
103+
const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback")
104+
const { Instance } = await import("../../src/project/instance")
105+
const { tmpdir } = await import("../fixture/fixture")
106+
107+
test("BrowserOpenFailed event is published when open() throws", async () => {
108+
await using tmp = await tmpdir({
109+
init: async (dir) => {
110+
await Bun.write(
111+
`${dir}/opencode.json`,
112+
JSON.stringify({
113+
$schema: "https://opencode.ai/config.json",
114+
mcp: {
115+
"test-oauth-server": {
116+
type: "remote",
117+
url: "https://example.com/mcp",
118+
},
119+
},
120+
}),
121+
)
122+
},
123+
})
124+
125+
await Instance.provide({
126+
directory: tmp.path,
127+
fn: async () => {
128+
openShouldFail = true
129+
130+
const events: Array<{ mcpName: string; url: string }> = []
131+
const unsubscribe = Bus.subscribe(MCP.BrowserOpenFailed, (evt) => {
132+
events.push(evt.properties)
133+
})
134+
135+
// Run authenticate with a timeout to avoid waiting forever for the callback
136+
const authPromise = MCP.authenticate("test-oauth-server")
137+
138+
// Wait for the browser open attempt (error fires at 10ms, but we wait for event to be published)
139+
await new Promise((resolve) => setTimeout(resolve, 200))
140+
141+
// Stop the callback server and cancel any pending auth
142+
await McpOAuthCallback.stop()
143+
144+
// Wait for authenticate to reject (due to server stopping)
145+
try {
146+
await authPromise
147+
} catch {
148+
// Expected to fail
149+
}
150+
151+
unsubscribe()
152+
153+
// Verify the BrowserOpenFailed event was published
154+
expect(events.length).toBe(1)
155+
expect(events[0].mcpName).toBe("test-oauth-server")
156+
expect(events[0].url).toContain("https://")
157+
},
158+
})
159+
})
160+
161+
test("BrowserOpenFailed event is NOT published when open() succeeds", async () => {
162+
await using tmp = await tmpdir({
163+
init: async (dir) => {
164+
await Bun.write(
165+
`${dir}/opencode.json`,
166+
JSON.stringify({
167+
$schema: "https://opencode.ai/config.json",
168+
mcp: {
169+
"test-oauth-server-2": {
170+
type: "remote",
171+
url: "https://example.com/mcp",
172+
},
173+
},
174+
}),
175+
)
176+
},
177+
})
178+
179+
await Instance.provide({
180+
directory: tmp.path,
181+
fn: async () => {
182+
openShouldFail = false
183+
184+
const events: Array<{ mcpName: string; url: string }> = []
185+
const unsubscribe = Bus.subscribe(MCP.BrowserOpenFailed, (evt) => {
186+
events.push(evt.properties)
187+
})
188+
189+
// Run authenticate with a timeout to avoid waiting forever for the callback
190+
const authPromise = MCP.authenticate("test-oauth-server-2")
191+
192+
// Wait for the browser open attempt and the 500ms error detection timeout
193+
await new Promise((resolve) => setTimeout(resolve, 700))
194+
195+
// Stop the callback server and cancel any pending auth
196+
await McpOAuthCallback.stop()
197+
198+
// Wait for authenticate to reject (due to server stopping)
199+
try {
200+
await authPromise
201+
} catch {
202+
// Expected to fail
203+
}
204+
205+
unsubscribe()
206+
207+
// Verify NO BrowserOpenFailed event was published
208+
expect(events.length).toBe(0)
209+
// Verify open() was still called
210+
expect(openCalledWith).toBeDefined()
211+
},
212+
})
213+
})
214+
215+
test("open() is called with the authorization URL", async () => {
216+
await using tmp = await tmpdir({
217+
init: async (dir) => {
218+
await Bun.write(
219+
`${dir}/opencode.json`,
220+
JSON.stringify({
221+
$schema: "https://opencode.ai/config.json",
222+
mcp: {
223+
"test-oauth-server-3": {
224+
type: "remote",
225+
url: "https://example.com/mcp",
226+
},
227+
},
228+
}),
229+
)
230+
},
231+
})
232+
233+
await Instance.provide({
234+
directory: tmp.path,
235+
fn: async () => {
236+
openShouldFail = false
237+
openCalledWith = undefined
238+
239+
// Run authenticate with a timeout to avoid waiting forever for the callback
240+
const authPromise = MCP.authenticate("test-oauth-server-3")
241+
242+
// Wait for the browser open attempt and the 500ms error detection timeout
243+
await new Promise((resolve) => setTimeout(resolve, 700))
244+
245+
// Stop the callback server and cancel any pending auth
246+
await McpOAuthCallback.stop()
247+
248+
// Wait for authenticate to reject (due to server stopping)
249+
try {
250+
await authPromise
251+
} catch {
252+
// Expected to fail
253+
}
254+
255+
// Verify open was called with a URL
256+
expect(openCalledWith).toBeDefined()
257+
expect(typeof openCalledWith).toBe("string")
258+
expect(openCalledWith!).toContain("https://")
259+
},
260+
})
261+
})

0 commit comments

Comments
 (0)