Răsfoiți Sursa

feat: unwrap McpAuth, McpOAuthCallback namespaces to flat exports + barrel

Kit Langton 10 ore în urmă
părinte
comite
43e2617e72

+ 1 - 1
packages/opencode/src/cli/cmd/mcp.ts

@@ -5,7 +5,7 @@ import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
 import * as prompts from "@clack/prompts"
 import { UI } from "../ui"
 import { MCP } from "../../mcp"
-import { McpAuth } from "../../mcp/auth"
+import { McpAuth } from "../../mcp"
 import { McpOAuthProvider } from "../../mcp/oauth-provider"
 import { Config } from "../../config"
 import { Instance } from "../../project/instance"

+ 1 - 1
packages/opencode/src/effect/app-runtime.ts

@@ -35,7 +35,7 @@ import { Instruction } from "@/session/instruction"
 import { LLM } from "@/session/llm"
 import { LSP } from "@/lsp"
 import { MCP } from "@/mcp"
-import { McpAuth } from "@/mcp/auth"
+import { McpAuth } from "@/mcp"
 import { Command } from "@/command"
 import { Truncate } from "@/tool"
 import { ToolRegistry } from "@/tool"

+ 130 - 132
packages/opencode/src/mcp/auth.ts

@@ -4,141 +4,139 @@ import { Global } from "../global"
 import { Effect, Layer, Context } from "effect"
 import { AppFileSystem } from "@opencode-ai/shared/filesystem"
 
-export namespace McpAuth {
-  export const Tokens = z.object({
-    accessToken: z.string(),
-    refreshToken: z.string().optional(),
-    expiresAt: z.number().optional(),
-    scope: z.string().optional(),
-  })
-  export type Tokens = z.infer<typeof Tokens>
-
-  export const ClientInfo = z.object({
-    clientId: z.string(),
-    clientSecret: z.string().optional(),
-    clientIdIssuedAt: z.number().optional(),
-    clientSecretExpiresAt: z.number().optional(),
-  })
-  export type ClientInfo = z.infer<typeof ClientInfo>
-
-  export const Entry = z.object({
-    tokens: Tokens.optional(),
-    clientInfo: ClientInfo.optional(),
-    codeVerifier: z.string().optional(),
-    oauthState: z.string().optional(),
-    serverUrl: z.string().optional(),
-  })
-  export type Entry = z.infer<typeof Entry>
-
-  const filepath = path.join(Global.Path.data, "mcp-auth.json")
-
-  export interface Interface {
-    readonly all: () => Effect.Effect<Record<string, Entry>>
-    readonly get: (mcpName: string) => Effect.Effect<Entry | undefined>
-    readonly getForUrl: (mcpName: string, serverUrl: string) => Effect.Effect<Entry | undefined>
-    readonly set: (mcpName: string, entry: Entry, serverUrl?: string) => Effect.Effect<void>
-    readonly remove: (mcpName: string) => Effect.Effect<void>
-    readonly updateTokens: (mcpName: string, tokens: Tokens, serverUrl?: string) => Effect.Effect<void>
-    readonly updateClientInfo: (mcpName: string, clientInfo: ClientInfo, serverUrl?: string) => Effect.Effect<void>
-    readonly updateCodeVerifier: (mcpName: string, codeVerifier: string) => Effect.Effect<void>
-    readonly clearCodeVerifier: (mcpName: string) => Effect.Effect<void>
-    readonly updateOAuthState: (mcpName: string, oauthState: string) => Effect.Effect<void>
-    readonly getOAuthState: (mcpName: string) => Effect.Effect<string | undefined>
-    readonly clearOAuthState: (mcpName: string) => Effect.Effect<void>
-    readonly isTokenExpired: (mcpName: string) => Effect.Effect<boolean | null>
-  }
-
-  export class Service extends Context.Service<Service, Interface>()("@opencode/McpAuth") {}
-
-  export const layer = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const fs = yield* AppFileSystem.Service
-
-      const all = Effect.fn("McpAuth.all")(function* () {
-        return yield* fs.readJson(filepath).pipe(
-          Effect.map((data) => data as Record<string, Entry>),
-          Effect.catch(() => Effect.succeed({} as Record<string, Entry>)),
-        )
-      })
-
-      const get = Effect.fn("McpAuth.get")(function* (mcpName: string) {
-        const data = yield* all()
-        return data[mcpName]
-      })
-
-      const getForUrl = Effect.fn("McpAuth.getForUrl")(function* (mcpName: string, serverUrl: string) {
-        const entry = yield* get(mcpName)
-        if (!entry) return undefined
-        if (!entry.serverUrl) return undefined
-        if (entry.serverUrl !== serverUrl) return undefined
-        return entry
-      })
-
-      const set = Effect.fn("McpAuth.set")(function* (mcpName: string, entry: Entry, serverUrl?: string) {
-        const data = yield* all()
-        if (serverUrl) entry.serverUrl = serverUrl
-        yield* fs.writeJson(filepath, { ...data, [mcpName]: entry }, 0o600).pipe(Effect.orDie)
-      })
-
-      const remove = Effect.fn("McpAuth.remove")(function* (mcpName: string) {
-        const data = yield* all()
-        delete data[mcpName]
-        yield* fs.writeJson(filepath, data, 0o600).pipe(Effect.orDie)
-      })
+export const Tokens = z.object({
+  accessToken: z.string(),
+  refreshToken: z.string().optional(),
+  expiresAt: z.number().optional(),
+  scope: z.string().optional(),
+})
+export type Tokens = z.infer<typeof Tokens>
+
+export const ClientInfo = z.object({
+  clientId: z.string(),
+  clientSecret: z.string().optional(),
+  clientIdIssuedAt: z.number().optional(),
+  clientSecretExpiresAt: z.number().optional(),
+})
+export type ClientInfo = z.infer<typeof ClientInfo>
+
+export const Entry = z.object({
+  tokens: Tokens.optional(),
+  clientInfo: ClientInfo.optional(),
+  codeVerifier: z.string().optional(),
+  oauthState: z.string().optional(),
+  serverUrl: z.string().optional(),
+})
+export type Entry = z.infer<typeof Entry>
+
+const filepath = path.join(Global.Path.data, "mcp-auth.json")
+
+export interface Interface {
+  readonly all: () => Effect.Effect<Record<string, Entry>>
+  readonly get: (mcpName: string) => Effect.Effect<Entry | undefined>
+  readonly getForUrl: (mcpName: string, serverUrl: string) => Effect.Effect<Entry | undefined>
+  readonly set: (mcpName: string, entry: Entry, serverUrl?: string) => Effect.Effect<void>
+  readonly remove: (mcpName: string) => Effect.Effect<void>
+  readonly updateTokens: (mcpName: string, tokens: Tokens, serverUrl?: string) => Effect.Effect<void>
+  readonly updateClientInfo: (mcpName: string, clientInfo: ClientInfo, serverUrl?: string) => Effect.Effect<void>
+  readonly updateCodeVerifier: (mcpName: string, codeVerifier: string) => Effect.Effect<void>
+  readonly clearCodeVerifier: (mcpName: string) => Effect.Effect<void>
+  readonly updateOAuthState: (mcpName: string, oauthState: string) => Effect.Effect<void>
+  readonly getOAuthState: (mcpName: string) => Effect.Effect<string | undefined>
+  readonly clearOAuthState: (mcpName: string) => Effect.Effect<void>
+  readonly isTokenExpired: (mcpName: string) => Effect.Effect<boolean | null>
+}
 
-      const updateField = <K extends keyof Entry>(field: K, spanName: string) =>
-        Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string, value: NonNullable<Entry[K]>, serverUrl?: string) {
-          const entry = (yield* get(mcpName)) ?? {}
-          entry[field] = value
-          yield* set(mcpName, entry, serverUrl)
-        })
-
-      const clearField = <K extends keyof Entry>(field: K, spanName: string) =>
-        Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string) {
-          const entry = yield* get(mcpName)
-          if (entry) {
-            delete entry[field]
-            yield* set(mcpName, entry)
-          }
-        })
-
-      const updateTokens = updateField("tokens", "updateTokens")
-      const updateClientInfo = updateField("clientInfo", "updateClientInfo")
-      const updateCodeVerifier = updateField("codeVerifier", "updateCodeVerifier")
-      const updateOAuthState = updateField("oauthState", "updateOAuthState")
-      const clearCodeVerifier = clearField("codeVerifier", "clearCodeVerifier")
-      const clearOAuthState = clearField("oauthState", "clearOAuthState")
-
-      const getOAuthState = Effect.fn("McpAuth.getOAuthState")(function* (mcpName: string) {
-        const entry = yield* get(mcpName)
-        return entry?.oauthState
+export class Service extends Context.Service<Service, Interface>()("@opencode/McpAuth") {}
+
+export const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const fs = yield* AppFileSystem.Service
+
+    const all = Effect.fn("McpAuth.all")(function* () {
+      return yield* fs.readJson(filepath).pipe(
+        Effect.map((data) => data as Record<string, Entry>),
+        Effect.catch(() => Effect.succeed({} as Record<string, Entry>)),
+      )
+    })
+
+    const get = Effect.fn("McpAuth.get")(function* (mcpName: string) {
+      const data = yield* all()
+      return data[mcpName]
+    })
+
+    const getForUrl = Effect.fn("McpAuth.getForUrl")(function* (mcpName: string, serverUrl: string) {
+      const entry = yield* get(mcpName)
+      if (!entry) return undefined
+      if (!entry.serverUrl) return undefined
+      if (entry.serverUrl !== serverUrl) return undefined
+      return entry
+    })
+
+    const set = Effect.fn("McpAuth.set")(function* (mcpName: string, entry: Entry, serverUrl?: string) {
+      const data = yield* all()
+      if (serverUrl) entry.serverUrl = serverUrl
+      yield* fs.writeJson(filepath, { ...data, [mcpName]: entry }, 0o600).pipe(Effect.orDie)
+    })
+
+    const remove = Effect.fn("McpAuth.remove")(function* (mcpName: string) {
+      const data = yield* all()
+      delete data[mcpName]
+      yield* fs.writeJson(filepath, data, 0o600).pipe(Effect.orDie)
+    })
+
+    const updateField = <K extends keyof Entry>(field: K, spanName: string) =>
+      Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string, value: NonNullable<Entry[K]>, serverUrl?: string) {
+        const entry = (yield* get(mcpName)) ?? {}
+        entry[field] = value
+        yield* set(mcpName, entry, serverUrl)
       })
 
-      const isTokenExpired = Effect.fn("McpAuth.isTokenExpired")(function* (mcpName: string) {
+    const clearField = <K extends keyof Entry>(field: K, spanName: string) =>
+      Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string) {
         const entry = yield* get(mcpName)
-        if (!entry?.tokens) return null
-        if (!entry.tokens.expiresAt) return false
-        return entry.tokens.expiresAt < Date.now() / 1000
+        if (entry) {
+          delete entry[field]
+          yield* set(mcpName, entry)
+        }
       })
 
-      return Service.of({
-        all,
-        get,
-        getForUrl,
-        set,
-        remove,
-        updateTokens,
-        updateClientInfo,
-        updateCodeVerifier,
-        clearCodeVerifier,
-        updateOAuthState,
-        getOAuthState,
-        clearOAuthState,
-        isTokenExpired,
-      })
-    }),
-  )
-
-  export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
-}
+    const updateTokens = updateField("tokens", "updateTokens")
+    const updateClientInfo = updateField("clientInfo", "updateClientInfo")
+    const updateCodeVerifier = updateField("codeVerifier", "updateCodeVerifier")
+    const updateOAuthState = updateField("oauthState", "updateOAuthState")
+    const clearCodeVerifier = clearField("codeVerifier", "clearCodeVerifier")
+    const clearOAuthState = clearField("oauthState", "clearOAuthState")
+
+    const getOAuthState = Effect.fn("McpAuth.getOAuthState")(function* (mcpName: string) {
+      const entry = yield* get(mcpName)
+      return entry?.oauthState
+    })
+
+    const isTokenExpired = Effect.fn("McpAuth.isTokenExpired")(function* (mcpName: string) {
+      const entry = yield* get(mcpName)
+      if (!entry?.tokens) return null
+      if (!entry.tokens.expiresAt) return false
+      return entry.tokens.expiresAt < Date.now() / 1000
+    })
+
+    return Service.of({
+      all,
+      get,
+      getForUrl,
+      set,
+      remove,
+      updateTokens,
+      updateClientInfo,
+      updateCodeVerifier,
+      clearCodeVerifier,
+      updateOAuthState,
+      getOAuthState,
+      clearOAuthState,
+      isTokenExpired,
+    })
+  }),
+)
+
+export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))

+ 2 - 0
packages/opencode/src/mcp/index.ts

@@ -1 +1,3 @@
 export * as MCP from "./mcp"
+export * as McpAuth from "./auth"
+export * as McpOAuthCallback from "./oauth-callback"

+ 2 - 2
packages/opencode/src/mcp/mcp.ts

@@ -19,8 +19,8 @@ import { InstallationVersion } from "../installation/version"
 import { withTimeout } from "@/util/timeout"
 import { AppFileSystem } from "@opencode-ai/shared/filesystem"
 import { McpOAuthProvider } from "./oauth-provider"
-import { McpOAuthCallback } from "./oauth-callback"
-import { McpAuth } from "./auth"
+import * as McpOAuthCallback from "./oauth-callback"
+import * as McpAuth from "./auth"
 import { BusEvent } from "../bus/bus-event"
 import { Bus } from "@/bus"
 import { TuiEvent } from "@/cli/cmd/tui/event"

+ 141 - 143
packages/opencode/src/mcp/oauth-callback.ts

@@ -56,177 +56,175 @@ interface PendingAuth {
   timeout: ReturnType<typeof setTimeout>
 }
 
-export namespace McpOAuthCallback {
-  let server: ReturnType<typeof createServer> | undefined
-  const pendingAuths = new Map<string, PendingAuth>()
-  // Reverse index: mcpName → oauthState, so cancelPending(mcpName) can
-  // find the right entry in pendingAuths (which is keyed by oauthState).
-  const mcpNameToState = new Map<string, string>()
-
-  const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
-
-  function cleanupStateIndex(oauthState: string) {
-    for (const [name, state] of mcpNameToState) {
-      if (state === oauthState) {
-        mcpNameToState.delete(name)
-        break
-      }
+let server: ReturnType<typeof createServer> | undefined
+const pendingAuths = new Map<string, PendingAuth>()
+// Reverse index: mcpName → oauthState, so cancelPending(mcpName) can
+// find the right entry in pendingAuths (which is keyed by oauthState).
+const mcpNameToState = new Map<string, string>()
+
+const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
+
+function cleanupStateIndex(oauthState: string) {
+  for (const [name, state] of mcpNameToState) {
+    if (state === oauthState) {
+      mcpNameToState.delete(name)
+      break
     }
   }
+}
 
-  function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) {
-    const url = new URL(req.url || "/", `http://localhost:${currentPort}`)
+function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) {
+  const url = new URL(req.url || "/", `http://localhost:${currentPort}`)
 
-    if (url.pathname !== currentPath) {
-      res.writeHead(404)
-      res.end("Not found")
-      return
-    }
+  if (url.pathname !== currentPath) {
+    res.writeHead(404)
+    res.end("Not found")
+    return
+  }
 
-    const code = url.searchParams.get("code")
-    const state = url.searchParams.get("state")
-    const error = url.searchParams.get("error")
-    const errorDescription = url.searchParams.get("error_description")
+  const code = url.searchParams.get("code")
+  const state = url.searchParams.get("state")
+  const error = url.searchParams.get("error")
+  const errorDescription = url.searchParams.get("error_description")
 
-    log.info("received oauth callback", { hasCode: !!code, state, error })
+  log.info("received oauth callback", { hasCode: !!code, state, error })
 
-    // Enforce state parameter presence
-    if (!state) {
-      const errorMsg = "Missing required state parameter - potential CSRF attack"
-      log.error("oauth callback missing state parameter", { url: url.toString() })
-      res.writeHead(400, { "Content-Type": "text/html" })
-      res.end(HTML_ERROR(errorMsg))
-      return
-    }
+  // Enforce state parameter presence
+  if (!state) {
+    const errorMsg = "Missing required state parameter - potential CSRF attack"
+    log.error("oauth callback missing state parameter", { url: url.toString() })
+    res.writeHead(400, { "Content-Type": "text/html" })
+    res.end(HTML_ERROR(errorMsg))
+    return
+  }
 
-    if (error) {
-      const errorMsg = errorDescription || error
-      if (pendingAuths.has(state)) {
-        const pending = pendingAuths.get(state)!
-        clearTimeout(pending.timeout)
-        pendingAuths.delete(state)
-        cleanupStateIndex(state)
-        pending.reject(new Error(errorMsg))
-      }
-      res.writeHead(200, { "Content-Type": "text/html" })
-      res.end(HTML_ERROR(errorMsg))
-      return
+  if (error) {
+    const errorMsg = errorDescription || error
+    if (pendingAuths.has(state)) {
+      const pending = pendingAuths.get(state)!
+      clearTimeout(pending.timeout)
+      pendingAuths.delete(state)
+      cleanupStateIndex(state)
+      pending.reject(new Error(errorMsg))
     }
+    res.writeHead(200, { "Content-Type": "text/html" })
+    res.end(HTML_ERROR(errorMsg))
+    return
+  }
 
-    if (!code) {
-      res.writeHead(400, { "Content-Type": "text/html" })
-      res.end(HTML_ERROR("No authorization code provided"))
-      return
-    }
+  if (!code) {
+    res.writeHead(400, { "Content-Type": "text/html" })
+    res.end(HTML_ERROR("No authorization code provided"))
+    return
+  }
 
-    // Validate state parameter
-    if (!pendingAuths.has(state)) {
-      const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
-      log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) })
-      res.writeHead(400, { "Content-Type": "text/html" })
-      res.end(HTML_ERROR(errorMsg))
-      return
-    }
+  // Validate state parameter
+  if (!pendingAuths.has(state)) {
+    const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
+    log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) })
+    res.writeHead(400, { "Content-Type": "text/html" })
+    res.end(HTML_ERROR(errorMsg))
+    return
+  }
 
-    const pending = pendingAuths.get(state)!
+  const pending = pendingAuths.get(state)!
 
-    clearTimeout(pending.timeout)
-    pendingAuths.delete(state)
-    cleanupStateIndex(state)
-    pending.resolve(code)
+  clearTimeout(pending.timeout)
+  pendingAuths.delete(state)
+  cleanupStateIndex(state)
+  pending.resolve(code)
 
-    res.writeHead(200, { "Content-Type": "text/html" })
-    res.end(HTML_SUCCESS)
-  }
+  res.writeHead(200, { "Content-Type": "text/html" })
+  res.end(HTML_SUCCESS)
+}
 
-  export async function ensureRunning(redirectUri?: string): Promise<void> {
-    // Parse the redirect URI to get port and path (uses defaults if not provided)
-    const { port, path } = parseRedirectUri(redirectUri)
+export async function ensureRunning(redirectUri?: string): Promise<void> {
+  // Parse the redirect URI to get port and path (uses defaults if not provided)
+  const { port, path } = parseRedirectUri(redirectUri)
 
-    // If server is running on a different port/path, stop it first
-    if (server && (currentPort !== port || currentPath !== path)) {
-      log.info("stopping oauth callback server to reconfigure", { oldPort: currentPort, newPort: port })
-      await stop()
-    }
+  // If server is running on a different port/path, stop it first
+  if (server && (currentPort !== port || currentPath !== path)) {
+    log.info("stopping oauth callback server to reconfigure", { oldPort: currentPort, newPort: port })
+    await stop()
+  }
 
-    if (server) return
+  if (server) return
 
-    const running = await isPortInUse(port)
-    if (running) {
-      log.info("oauth callback server already running on another instance", { port })
-      return
-    }
+  const running = await isPortInUse(port)
+  if (running) {
+    log.info("oauth callback server already running on another instance", { port })
+    return
+  }
 
-    currentPort = port
-    currentPath = path
+  currentPort = port
+  currentPath = path
 
-    server = createServer(handleRequest)
-    await new Promise<void>((resolve, reject) => {
-      server!.listen(currentPort, () => {
-        log.info("oauth callback server started", { port: currentPort, path: currentPath })
-        resolve()
-      })
-      server!.on("error", reject)
+  server = createServer(handleRequest)
+  await new Promise<void>((resolve, reject) => {
+    server!.listen(currentPort, () => {
+      log.info("oauth callback server started", { port: currentPort, path: currentPath })
+      resolve()
     })
-  }
+    server!.on("error", reject)
+  })
+}
 
-  export function waitForCallback(oauthState: string, mcpName?: string): Promise<string> {
-    if (mcpName) mcpNameToState.set(mcpName, oauthState)
-    return new Promise((resolve, reject) => {
-      const timeout = setTimeout(() => {
-        if (pendingAuths.has(oauthState)) {
-          pendingAuths.delete(oauthState)
-          if (mcpName) mcpNameToState.delete(mcpName)
-          reject(new Error("OAuth callback timeout - authorization took too long"))
-        }
-      }, CALLBACK_TIMEOUT_MS)
-
-      pendingAuths.set(oauthState, { resolve, reject, timeout })
-    })
-  }
+export function waitForCallback(oauthState: string, mcpName?: string): Promise<string> {
+  if (mcpName) mcpNameToState.set(mcpName, oauthState)
+  return new Promise((resolve, reject) => {
+    const timeout = setTimeout(() => {
+      if (pendingAuths.has(oauthState)) {
+        pendingAuths.delete(oauthState)
+        if (mcpName) mcpNameToState.delete(mcpName)
+        reject(new Error("OAuth callback timeout - authorization took too long"))
+      }
+    }, CALLBACK_TIMEOUT_MS)
 
-  export function cancelPending(mcpName: string): void {
-    // Look up the oauthState for this mcpName via the reverse index
-    const oauthState = mcpNameToState.get(mcpName)
-    const key = oauthState ?? mcpName
-    const pending = pendingAuths.get(key)
-    if (pending) {
-      clearTimeout(pending.timeout)
-      pendingAuths.delete(key)
-      mcpNameToState.delete(mcpName)
-      pending.reject(new Error("Authorization cancelled"))
-    }
-  }
+    pendingAuths.set(oauthState, { resolve, reject, timeout })
+  })
+}
 
-  export async function isPortInUse(port: number = OAUTH_CALLBACK_PORT): Promise<boolean> {
-    return new Promise((resolve) => {
-      const socket = createConnection(port, "127.0.0.1")
-      socket.on("connect", () => {
-        socket.destroy()
-        resolve(true)
-      })
-      socket.on("error", () => {
-        resolve(false)
-      })
-    })
+export function cancelPending(mcpName: string): void {
+  // Look up the oauthState for this mcpName via the reverse index
+  const oauthState = mcpNameToState.get(mcpName)
+  const key = oauthState ?? mcpName
+  const pending = pendingAuths.get(key)
+  if (pending) {
+    clearTimeout(pending.timeout)
+    pendingAuths.delete(key)
+    mcpNameToState.delete(mcpName)
+    pending.reject(new Error("Authorization cancelled"))
   }
+}
 
-  export async function stop(): Promise<void> {
-    if (server) {
-      await new Promise<void>((resolve) => server!.close(() => resolve()))
-      server = undefined
-      log.info("oauth callback server stopped")
-    }
+export async function isPortInUse(port: number = OAUTH_CALLBACK_PORT): Promise<boolean> {
+  return new Promise((resolve) => {
+    const socket = createConnection(port, "127.0.0.1")
+    socket.on("connect", () => {
+      socket.destroy()
+      resolve(true)
+    })
+    socket.on("error", () => {
+      resolve(false)
+    })
+  })
+}
 
-    for (const [_name, pending] of pendingAuths) {
-      clearTimeout(pending.timeout)
-      pending.reject(new Error("OAuth callback server stopped"))
-    }
-    pendingAuths.clear()
-    mcpNameToState.clear()
+export async function stop(): Promise<void> {
+  if (server) {
+    await new Promise<void>((resolve) => server!.close(() => resolve()))
+    server = undefined
+    log.info("oauth callback server stopped")
   }
 
-  export function isRunning(): boolean {
-    return server !== undefined
+  for (const [_name, pending] of pendingAuths) {
+    clearTimeout(pending.timeout)
+    pending.reject(new Error("OAuth callback server stopped"))
   }
+  pendingAuths.clear()
+  mcpNameToState.clear()
+}
+
+export function isRunning(): boolean {
+  return server !== undefined
 }

+ 1 - 1
packages/opencode/src/mcp/oauth-provider.ts

@@ -6,7 +6,7 @@ import type {
   OAuthClientInformationFull,
 } from "@modelcontextprotocol/sdk/shared/auth.js"
 import { Effect } from "effect"
-import { McpAuth } from "./auth"
+import * as McpAuth from "./auth"
 import { Log } from "../util"
 
 const log = Log.create({ service: "mcp.oauth" })

+ 1 - 1
packages/opencode/test/mcp/lifecycle.test.ts

@@ -641,7 +641,7 @@ test(
 // ========================================================================
 
 test("McpOAuthCallback.cancelPending is keyed by mcpName but pendingAuths uses oauthState", async () => {
-  const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback")
+  const McpOAuthCallback = await import("../../src/mcp/oauth-callback")
 
   // Register a pending auth with an oauthState key, associated to an mcpName
   const oauthState = "abc123hexstate"

+ 2 - 2
packages/opencode/test/mcp/oauth-auto-connect.test.ts

@@ -158,7 +158,7 @@ test("first connect to OAuth server shows needs_auth instead of failed", async (
 
 test("state() generates a new state when none is saved", async () => {
   const { McpOAuthProvider } = await import("../../src/mcp/oauth-provider")
-  const { McpAuth } = await import("../../src/mcp/auth")
+  const McpAuth = await import("../../src/mcp/auth")
 
   await using tmp = await tmpdir()
 
@@ -199,7 +199,7 @@ test("state() generates a new state when none is saved", async () => {
 
 test("state() returns existing state when one is saved", async () => {
   const { McpOAuthProvider } = await import("../../src/mcp/oauth-provider")
-  const { McpAuth } = await import("../../src/mcp/auth")
+  const McpAuth = await import("../../src/mcp/auth")
 
   await using tmp = await tmpdir()
 

+ 1 - 1
packages/opencode/test/mcp/oauth-browser.test.ts

@@ -104,7 +104,7 @@ beforeEach(() => {
 const { MCP } = await import("../../src/mcp/index")
 const { AppRuntime } = await import("../../src/effect/app-runtime")
 const { Bus } = await import("../../src/bus")
-const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback")
+const McpOAuthCallback = await import("../../src/mcp/oauth-callback")
 const { Instance } = await import("../../src/project/instance")
 const { tmpdir } = await import("../fixture/fixture")
 const service = MCP.Service as unknown as Effect.Effect<MCPNS.Interface, never, never>

+ 1 - 1
packages/opencode/test/mcp/oauth-callback.test.ts

@@ -1,5 +1,5 @@
 import { test, expect, describe, afterEach } from "bun:test"
-import { McpOAuthCallback } from "../../src/mcp/oauth-callback"
+import { McpOAuthCallback } from "../../src/mcp"
 import { parseRedirectUri } from "../../src/mcp/oauth-provider"
 
 describe("parseRedirectUri", () => {