Kaynağa Gözat

fix+refactor(mcp): lifecycle tests, cancelPending fix, Effect migration (#19042)

Kit Langton 5 ay önce
ebeveyn
işleme
b90de755f9

+ 1 - 1
packages/opencode/specs/effect-migration.md

@@ -175,4 +175,4 @@ Still open and likely worth migrating:
 - [ ] `Provider`
 - [x] `Project`
 - [ ] `LSP`
-- [ ] `MCP`
+- [x] `MCP`

+ 147 - 96
packages/opencode/src/mcp/auth.ts

@@ -1,7 +1,9 @@
 import path from "path"
 import z from "zod"
 import { Global } from "../global"
-import { Filesystem } from "../util/filesystem"
+import { Effect, Layer, ServiceMap } from "effect"
+import { AppFileSystem } from "@/filesystem"
+import { makeRunPromise } from "@/effect/run-service"
 
 export namespace McpAuth {
   export const Tokens = z.object({
@@ -25,106 +27,155 @@ export namespace McpAuth {
     clientInfo: ClientInfo.optional(),
     codeVerifier: z.string().optional(),
     oauthState: z.string().optional(),
-    serverUrl: z.string().optional(), // Track the URL these credentials are for
+    serverUrl: z.string().optional(),
   })
   export type Entry = z.infer<typeof Entry>
 
   const filepath = path.join(Global.Path.data, "mcp-auth.json")
 
-  export async function get(mcpName: string): Promise<Entry | undefined> {
-    const data = await all()
-    return data[mcpName]
+  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>
   }
 
-  /**
-   * Get auth entry and validate it's for the correct URL.
-   * Returns undefined if URL has changed (credentials are invalid).
-   */
-  export async function getForUrl(mcpName: string, serverUrl: string): Promise<Entry | undefined> {
-    const entry = await get(mcpName)
-    if (!entry) return undefined
-
-    // If no serverUrl is stored, this is from an old version - consider it invalid
-    if (!entry.serverUrl) return undefined
-
-    // If URL has changed, credentials are invalid
-    if (entry.serverUrl !== serverUrl) return undefined
-
-    return entry
-  }
-
-  export async function all(): Promise<Record<string, Entry>> {
-    return Filesystem.readJson<Record<string, Entry>>(filepath).catch(() => ({}))
-  }
-
-  export async function set(mcpName: string, entry: Entry, serverUrl?: string): Promise<void> {
-    const data = await all()
-    // Always update serverUrl if provided
-    if (serverUrl) {
-      entry.serverUrl = serverUrl
-    }
-    await Filesystem.writeJson(filepath, { ...data, [mcpName]: entry }, 0o600)
-  }
-
-  export async function remove(mcpName: string): Promise<void> {
-    const data = await all()
-    delete data[mcpName]
-    await Filesystem.writeJson(filepath, data, 0o600)
-  }
-
-  export async function updateTokens(mcpName: string, tokens: Tokens, serverUrl?: string): Promise<void> {
-    const entry = (await get(mcpName)) ?? {}
-    entry.tokens = tokens
-    await set(mcpName, entry, serverUrl)
-  }
-
-  export async function updateClientInfo(mcpName: string, clientInfo: ClientInfo, serverUrl?: string): Promise<void> {
-    const entry = (await get(mcpName)) ?? {}
-    entry.clientInfo = clientInfo
-    await set(mcpName, entry, serverUrl)
-  }
-
-  export async function updateCodeVerifier(mcpName: string, codeVerifier: string): Promise<void> {
-    const entry = (await get(mcpName)) ?? {}
-    entry.codeVerifier = codeVerifier
-    await set(mcpName, entry)
-  }
-
-  export async function clearCodeVerifier(mcpName: string): Promise<void> {
-    const entry = await get(mcpName)
-    if (entry) {
-      delete entry.codeVerifier
-      await set(mcpName, entry)
-    }
-  }
-
-  export async function updateOAuthState(mcpName: string, oauthState: string): Promise<void> {
-    const entry = (await get(mcpName)) ?? {}
-    entry.oauthState = oauthState
-    await set(mcpName, entry)
-  }
-
-  export async function getOAuthState(mcpName: string): Promise<string | undefined> {
-    const entry = await get(mcpName)
-    return entry?.oauthState
-  }
-
-  export async function clearOAuthState(mcpName: string): Promise<void> {
-    const entry = await get(mcpName)
-    if (entry) {
-      delete entry.oauthState
-      await set(mcpName, entry)
-    }
-  }
-
-  /**
-   * Check if stored tokens are expired.
-   * Returns null if no tokens exist, false if no expiry or not expired, true if expired.
-   */
-  export async function isTokenExpired(mcpName: string): Promise<boolean | null> {
-    const entry = await get(mcpName)
-    if (!entry?.tokens) return null
-    if (!entry.tokens.expiresAt) return false
-    return entry.tokens.expiresAt < Date.now() / 1000
-  }
+  export class Service extends ServiceMap.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 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
+      })
+
+      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,
+      })
+    }),
+  )
+
+  const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
+
+  const runPromise = makeRunPromise(Service, defaultLayer)
+
+  // Async facades for backward compat (used by McpOAuthProvider, CLI)
+
+  export const get = async (mcpName: string) => runPromise((svc) => svc.get(mcpName))
+
+  export const getForUrl = async (mcpName: string, serverUrl: string) =>
+    runPromise((svc) => svc.getForUrl(mcpName, serverUrl))
+
+  export const all = async () => runPromise((svc) => svc.all())
+
+  export const set = async (mcpName: string, entry: Entry, serverUrl?: string) =>
+    runPromise((svc) => svc.set(mcpName, entry, serverUrl))
+
+  export const remove = async (mcpName: string) => runPromise((svc) => svc.remove(mcpName))
+
+  export const updateTokens = async (mcpName: string, tokens: Tokens, serverUrl?: string) =>
+    runPromise((svc) => svc.updateTokens(mcpName, tokens, serverUrl))
+
+  export const updateClientInfo = async (mcpName: string, clientInfo: ClientInfo, serverUrl?: string) =>
+    runPromise((svc) => svc.updateClientInfo(mcpName, clientInfo, serverUrl))
+
+  export const updateCodeVerifier = async (mcpName: string, codeVerifier: string) =>
+    runPromise((svc) => svc.updateCodeVerifier(mcpName, codeVerifier))
+
+  export const clearCodeVerifier = async (mcpName: string) => runPromise((svc) => svc.clearCodeVerifier(mcpName))
+
+  export const updateOAuthState = async (mcpName: string, oauthState: string) =>
+    runPromise((svc) => svc.updateOAuthState(mcpName, oauthState))
+
+  export const getOAuthState = async (mcpName: string) => runPromise((svc) => svc.getOAuthState(mcpName))
+
+  export const clearOAuthState = async (mcpName: string) => runPromise((svc) => svc.clearOAuthState(mcpName))
+
+  export const isTokenExpired = async (mcpName: string) => runPromise((svc) => svc.isTokenExpired(mcpName))
 }

+ 519 - 560
packages/opencode/src/mcp/index.ts

@@ -11,12 +11,12 @@ import {
 } from "@modelcontextprotocol/sdk/types.js"
 import { Config } from "../config/config"
 import { Log } from "../util/log"
-import { Process } from "../util/process"
 import { NamedError } from "@opencode-ai/util/error"
 import z from "zod/v4"
 import { Instance } from "../project/instance"
 import { Installation } from "../installation"
 import { withTimeout } from "@/util/timeout"
+import { AppFileSystem } from "@/filesystem"
 import { McpOAuthProvider } from "./oauth-provider"
 import { McpOAuthCallback } from "./oauth-callback"
 import { McpAuth } from "./auth"
@@ -24,6 +24,13 @@ import { BusEvent } from "../bus/bus-event"
 import { Bus } from "@/bus"
 import { TuiEvent } from "@/cli/cmd/tui/event"
 import open from "open"
+import { Effect, Layer, Option, ServiceMap, Stream } from "effect"
+import { InstanceState } from "@/effect/instance-state"
+import { makeRunPromise } from "@/effect/run-service"
+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
+import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
+import { NodeFileSystem } from "@effect/platform-node"
+import * as NodePath from "@effect/platform-node/NodePath"
 
 export namespace MCP {
   const log = Log.create({ service: "mcp" })
@@ -109,16 +116,21 @@ export namespace MCP {
     })
   export type Status = z.infer<typeof Status>
 
-  // Register notification handlers for MCP client
-  function registerNotificationHandlers(client: MCPClient, serverName: string) {
-    client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
-      log.info("tools list changed notification received", { server: serverName })
-      Bus.publish(ToolsChanged, { server: serverName })
-    })
+  // Store transports for OAuth servers to allow finishing auth
+  type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport
+  const pendingOAuthTransports = new Map<string, TransportWithAuth>()
+
+  // Prompt cache types
+  type PromptInfo = Awaited<ReturnType<MCPClient["listPrompts"]>>["prompts"][number]
+  type ResourceInfo = Awaited<ReturnType<MCPClient["listResources"]>>["resources"][number]
+  type McpEntry = NonNullable<Config.Info["mcp"]>[string]
+
+  function isMcpConfigured(entry: McpEntry): entry is Config.Mcp {
+    return typeof entry === "object" && entry !== null && "type" in entry
   }
 
   // Convert MCP tool definition to AI SDK Tool type
-  async function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Promise<Tool> {
+  function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Tool {
     const inputSchema = mcpTool.inputSchema
 
     // Spread first, then override type to ensure it's always "object"
@@ -148,178 +160,33 @@ export namespace MCP {
     })
   }
 
-  // Store transports for OAuth servers to allow finishing auth
-  type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport
-  const pendingOAuthTransports = new Map<string, TransportWithAuth>()
-
-  // Prompt cache types
-  type PromptInfo = Awaited<ReturnType<MCPClient["listPrompts"]>>["prompts"][number]
-
-  type ResourceInfo = Awaited<ReturnType<MCPClient["listResources"]>>["resources"][number]
-  type McpEntry = NonNullable<Config.Info["mcp"]>[string]
-  function isMcpConfigured(entry: McpEntry): entry is Config.Mcp {
-    return typeof entry === "object" && entry !== null && "type" in entry
-  }
-
-  async function descendants(pid: number): Promise<number[]> {
-    if (process.platform === "win32") return []
-    const pids: number[] = []
-    const queue = [pid]
-    while (queue.length > 0) {
-      const current = queue.shift()!
-      const lines = await Process.lines(["pgrep", "-P", String(current)], { nothrow: true })
-      for (const tok of lines) {
-        const cpid = parseInt(tok, 10)
-        if (!isNaN(cpid) && !pids.includes(cpid)) {
-          pids.push(cpid)
-          queue.push(cpid)
-        }
-      }
-    }
-    return pids
-  }
-
-  const state = Instance.state(
-    async () => {
-      const cfg = await Config.get()
-      const config = cfg.mcp ?? {}
-      const clients: Record<string, MCPClient> = {}
-      const status: Record<string, Status> = {}
-
-      await Promise.all(
-        Object.entries(config).map(async ([key, mcp]) => {
-          if (!isMcpConfigured(mcp)) {
-            log.error("Ignoring MCP config entry without type", { key })
-            return
-          }
-
-          // If disabled by config, mark as disabled without trying to connect
-          if (mcp.enabled === false) {
-            status[key] = { status: "disabled" }
-            return
-          }
-
-          const result = await create(key, mcp).catch(() => undefined)
-          if (!result) return
-
-          status[key] = result.status
-
-          if (result.mcpClient) {
-            clients[key] = result.mcpClient
-          }
-        }),
-      )
-      return {
-        status,
-        clients,
-      }
-    },
-    async (state) => {
-      // The MCP SDK only signals the direct child process on close.
-      // Servers like chrome-devtools-mcp spawn grandchild processes
-      // (e.g. Chrome) that the SDK never reaches, leaving them orphaned.
-      // Kill the full descendant tree first so the server exits promptly
-      // and no processes are left behind.
-      for (const client of Object.values(state.clients)) {
-        const pid = (client.transport as any)?.pid
-        if (typeof pid !== "number") continue
-        for (const dpid of await descendants(pid)) {
-          try {
-            process.kill(dpid, "SIGTERM")
-          } catch {}
-        }
-      }
-
-      await Promise.all(
-        Object.values(state.clients).map((client) =>
-          client.close().catch((error) => {
-            log.error("Failed to close MCP client", {
-              error,
-            })
-          }),
-        ),
-      )
-      pendingOAuthTransports.clear()
-    },
-  )
-
-  // Helper function to fetch prompts for a specific client
-  async function fetchPromptsForClient(clientName: string, client: Client) {
-    const prompts = await client.listPrompts().catch((e) => {
-      log.error("failed to get prompts", { clientName, error: e.message })
+  async function defs(key: string, client: MCPClient, timeout?: number) {
+    const result = await withTimeout(client.listTools(), timeout ?? DEFAULT_TIMEOUT).catch((err) => {
+      log.error("failed to get tools from client", { key, error: err })
       return undefined
     })
-
-    if (!prompts) {
-      return
-    }
-
-    const commands: Record<string, PromptInfo & { client: string }> = {}
-
-    for (const prompt of prompts.prompts) {
-      const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
-      const sanitizedPromptName = prompt.name.replace(/[^a-zA-Z0-9_-]/g, "_")
-      const key = sanitizedClientName + ":" + sanitizedPromptName
-
-      commands[key] = { ...prompt, client: clientName }
-    }
-    return commands
+    return result?.tools
   }
 
-  async function fetchResourcesForClient(clientName: string, client: Client) {
-    const resources = await client.listResources().catch((e) => {
-      log.error("failed to get prompts", { clientName, error: e.message })
+  async function fetchFromClient<T extends { name: string }>(
+    clientName: string,
+    client: Client,
+    listFn: (c: Client) => Promise<T[]>,
+    label: string,
+  ): Promise<Record<string, T & { client: string }> | undefined> {
+    const items = await listFn(client).catch((e: any) => {
+      log.error(`failed to get ${label}`, { clientName, error: e.message })
       return undefined
     })
+    if (!items) return undefined
 
-    if (!resources) {
-      return
-    }
-
-    const commands: Record<string, ResourceInfo & { client: string }> = {}
-
-    for (const resource of resources.resources) {
-      const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
-      const sanitizedResourceName = resource.name.replace(/[^a-zA-Z0-9_-]/g, "_")
-      const key = sanitizedClientName + ":" + sanitizedResourceName
-
-      commands[key] = { ...resource, client: clientName }
-    }
-    return commands
-  }
-
-  export async function add(name: string, mcp: Config.Mcp) {
-    const s = await state()
-    const result = await create(name, mcp)
-    if (!result) {
-      const status = {
-        status: "failed" as const,
-        error: "unknown error",
-      }
-      s.status[name] = status
-      return {
-        status,
-      }
-    }
-    if (!result.mcpClient) {
-      s.status[name] = result.status
-      return {
-        status: s.status,
-      }
-    }
-    // Close existing client if present to prevent memory leaks
-    const existingClient = s.clients[name]
-    if (existingClient) {
-      await existingClient.close().catch((error) => {
-        log.error("Failed to close existing MCP client", { name, error })
-      })
-    }
-    s.clients[name] = result.mcpClient
-    s.status[name] = result.status
-
-    return {
-      status: s.status,
+    const out: Record<string, T & { client: string }> = {}
+    const sanitizedClient = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
+    for (const item of items) {
+      const sanitizedName = item.name.replace(/[^a-zA-Z0-9_-]/g, "_")
+      out[sanitizedClient + ":" + sanitizedName] = { ...item, client: clientName }
     }
+    return out
   }
 
   async function create(key: string, mcp: Config.Mcp) {
@@ -385,7 +252,6 @@ export namespace MCP {
             version: Installation.VERSION,
           })
           await withTimeout(client.connect(transport), connectTimeout)
-          registerNotificationHandlers(client, key)
           mcpClient = client
           log.info("connected", { key, transport: name })
           status = { status: "connected" }
@@ -470,7 +336,6 @@ export namespace MCP {
           version: Installation.VERSION,
         })
         await withTimeout(client.connect(transport), connectTimeout)
-        registerNotificationHandlers(client, key)
         mcpClient = client
         status = {
           status: "connected",
@@ -503,475 +368,569 @@ export namespace MCP {
       }
     }
 
-    const result = await withTimeout(mcpClient.listTools(), mcp.timeout ?? DEFAULT_TIMEOUT).catch((err) => {
-      log.error("failed to get tools from client", { key, error: err })
-      return undefined
-    })
-    if (!result) {
+    const listed = await defs(key, mcpClient, mcp.timeout)
+    if (!listed) {
       await mcpClient.close().catch((error) => {
         log.error("Failed to close MCP client", {
           error,
         })
       })
-      status = {
-        status: "failed",
-        error: "Failed to get tools",
-      }
       return {
         mcpClient: undefined,
-        status: {
-          status: "failed" as const,
-          error: "Failed to get tools",
-        },
+        status: { status: "failed" as const, error: "Failed to get tools" },
       }
     }
 
-    log.info("create() successfully created client", { key, toolCount: result.tools.length })
+    log.info("create() successfully created client", { key, toolCount: listed.length })
     return {
       mcpClient,
       status,
+      defs: listed,
     }
   }
 
-  export async function status() {
-    const s = await state()
-    const cfg = await Config.get()
-    const config = cfg.mcp ?? {}
-    const result: Record<string, Status> = {}
+  // --- Effect Service ---
 
-    // Include all configured MCPs from config, not just connected ones
-    for (const [key, mcp] of Object.entries(config)) {
-      if (!isMcpConfigured(mcp)) continue
-      result[key] = s.status[key] ?? { status: "disabled" }
-    }
-
-    return result
+  interface State {
+    status: Record<string, Status>
+    clients: Record<string, MCPClient>
+    defs: Record<string, MCPToolDef[]>
   }
 
-  export async function clients() {
-    return state().then((state) => state.clients)
+  export interface Interface {
+    readonly status: () => Effect.Effect<Record<string, Status>>
+    readonly clients: () => Effect.Effect<Record<string, MCPClient>>
+    readonly tools: () => Effect.Effect<Record<string, Tool>>
+    readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
+    readonly resources: () => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
+    readonly add: (name: string, mcp: Config.Mcp) => Effect.Effect<{ status: Record<string, Status> | Status }>
+    readonly connect: (name: string) => Effect.Effect<void>
+    readonly disconnect: (name: string) => Effect.Effect<void>
+    readonly getPrompt: (
+      clientName: string,
+      name: string,
+      args?: Record<string, string>,
+    ) => Effect.Effect<Awaited<ReturnType<MCPClient["getPrompt"]>> | undefined>
+    readonly readResource: (
+      clientName: string,
+      resourceUri: string,
+    ) => Effect.Effect<Awaited<ReturnType<MCPClient["readResource"]>> | undefined>
+    readonly startAuth: (mcpName: string) => Effect.Effect<{ authorizationUrl: string; oauthState: string }>
+    readonly authenticate: (mcpName: string) => Effect.Effect<Status>
+    readonly finishAuth: (mcpName: string, authorizationCode: string) => Effect.Effect<Status>
+    readonly removeAuth: (mcpName: string) => Effect.Effect<void>
+    readonly supportsOAuth: (mcpName: string) => Effect.Effect<boolean>
+    readonly hasStoredTokens: (mcpName: string) => Effect.Effect<boolean>
+    readonly getAuthStatus: (mcpName: string) => Effect.Effect<AuthStatus>
   }
 
-  export async function connect(name: string) {
-    const cfg = await Config.get()
-    const config = cfg.mcp ?? {}
-    const mcp = config[name]
-    if (!mcp) {
-      log.error("MCP config not found", { name })
-      return
-    }
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/MCP") {}
+
+  export const layer = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
+      const auth = yield* McpAuth.Service
+
+      const descendants = Effect.fnUntraced(
+        function* (pid: number) {
+          if (process.platform === "win32") return [] as number[]
+          const pids: number[] = []
+          const queue = [pid]
+          while (queue.length > 0) {
+            const current = queue.shift()!
+            const handle = yield* spawner.spawn(
+              ChildProcess.make("pgrep", ["-P", String(current)], { stdin: "ignore" }),
+            )
+            const text = yield* Stream.mkString(Stream.decodeText(handle.stdout))
+            yield* handle.exitCode
+            for (const tok of text.split("\n")) {
+              const cpid = parseInt(tok, 10)
+              if (!isNaN(cpid) && !pids.includes(cpid)) {
+                pids.push(cpid)
+                queue.push(cpid)
+              }
+            }
+          }
+          return pids
+        },
+        Effect.scoped,
+        Effect.catch(() => Effect.succeed([] as number[])),
+      )
 
-    if (!isMcpConfigured(mcp)) {
-      log.error("Ignoring MCP connect request for config without type", { name })
-      return
-    }
+      function watch(s: State, name: string, client: MCPClient, timeout?: number) {
+        client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
+          log.info("tools list changed notification received", { server: name })
+          if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
 
-    const result = await create(name, { ...mcp, enabled: true })
+          const listed = await defs(name, client, timeout)
+          if (!listed) return
+          if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
 
-    if (!result) {
-      const s = await state()
-      s.status[name] = {
-        status: "failed",
-        error: "Unknown error during connection",
+          s.defs[name] = listed
+          await Bus.publish(ToolsChanged, { server: name }).catch((error) =>
+            log.warn("failed to publish tools changed", { server: name, error }),
+          )
+        })
       }
-      return
-    }
 
-    const s = await state()
-    s.status[name] = result.status
-    if (result.mcpClient) {
-      // Close existing client if present to prevent memory leaks
-      const existingClient = s.clients[name]
-      if (existingClient) {
-        await existingClient.close().catch((error) => {
-          log.error("Failed to close existing MCP client", { name, error })
-        })
+      const cache = yield* InstanceState.make<State>(
+        Effect.fn("MCP.state")(function* () {
+          const cfg = yield* Effect.promise(() => Config.get())
+          const config = cfg.mcp ?? {}
+          const s: State = {
+            status: {},
+            clients: {},
+            defs: {},
+          }
+
+          yield* Effect.forEach(
+            Object.entries(config),
+            ([key, mcp]) =>
+              Effect.gen(function* () {
+                if (!isMcpConfigured(mcp)) {
+                  log.error("Ignoring MCP config entry without type", { key })
+                  return
+                }
+
+                if (mcp.enabled === false) {
+                  s.status[key] = { status: "disabled" }
+                  return
+                }
+
+                const result = yield* Effect.promise(() => create(key, mcp).catch(() => undefined))
+                if (!result) return
+
+                s.status[key] = result.status
+                if (result.mcpClient) {
+                  s.clients[key] = result.mcpClient
+                  s.defs[key] = result.defs
+                  watch(s, key, result.mcpClient, mcp.timeout)
+                }
+              }),
+            { concurrency: "unbounded" },
+          )
+
+          yield* Effect.addFinalizer(() =>
+            Effect.gen(function* () {
+              yield* Effect.forEach(
+                Object.values(s.clients),
+                (client) =>
+                  Effect.gen(function* () {
+                    const pid = (client.transport as any)?.pid
+                    if (typeof pid === "number") {
+                      const pids = yield* descendants(pid)
+                      for (const dpid of pids) {
+                        try {
+                          process.kill(dpid, "SIGTERM")
+                        } catch {}
+                      }
+                    }
+                    yield* Effect.tryPromise(() => client.close()).pipe(Effect.ignore)
+                  }),
+                { concurrency: "unbounded" },
+              )
+              pendingOAuthTransports.clear()
+            }),
+          )
+
+          return s
+        }),
+      )
+
+      function closeClient(s: State, name: string) {
+        const client = s.clients[name]
+        delete s.defs[name]
+        if (!client) return Effect.void
+        return Effect.promise(() =>
+          client.close().catch((error: any) => log.error("failed to close MCP client", { name, error })),
+        )
       }
-      s.clients[name] = result.mcpClient
-    }
-  }
 
-  export async function disconnect(name: string) {
-    const s = await state()
-    const client = s.clients[name]
-    if (client) {
-      await client.close().catch((error) => {
-        log.error("Failed to close MCP client", { name, error })
+      const status = Effect.fn("MCP.status")(function* () {
+        const s = yield* InstanceState.get(cache)
+        const cfg = yield* Effect.promise(() => Config.get())
+        const config = cfg.mcp ?? {}
+        const result: Record<string, Status> = {}
+
+        for (const [key, mcp] of Object.entries(config)) {
+          if (!isMcpConfigured(mcp)) continue
+          result[key] = s.status[key] ?? { status: "disabled" }
+        }
+
+        return result
       })
-      delete s.clients[name]
-    }
-    s.status[name] = { status: "disabled" }
-  }
 
-  export async function tools() {
-    const result: Record<string, Tool> = {}
-    const s = await state()
-    const cfg = await Config.get()
-    const config = cfg.mcp ?? {}
-    const clientsSnapshot = await clients()
-    const defaultTimeout = cfg.experimental?.mcp_timeout
-
-    const connectedClients = Object.entries(clientsSnapshot).filter(
-      ([clientName]) => s.status[clientName]?.status === "connected",
-    )
-
-    const toolsResults = await Promise.all(
-      connectedClients.map(async ([clientName, client]) => {
-        const toolsResult = await client.listTools().catch((e) => {
-          log.error("failed to get tools", { clientName, error: e.message })
-          const failedStatus = {
-            status: "failed" as const,
-            error: e instanceof Error ? e.message : String(e),
-          }
-          s.status[clientName] = failedStatus
-          delete s.clients[clientName]
-          return undefined
-        })
-        return { clientName, client, toolsResult }
-      }),
-    )
-
-    for (const { clientName, client, toolsResult } of toolsResults) {
-      if (!toolsResult) continue
-      const mcpConfig = config[clientName]
-      const entry = isMcpConfigured(mcpConfig) ? mcpConfig : undefined
-      const timeout = entry?.timeout ?? defaultTimeout
-      for (const mcpTool of toolsResult.tools) {
-        const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
-        const sanitizedToolName = mcpTool.name.replace(/[^a-zA-Z0-9_-]/g, "_")
-        result[sanitizedClientName + "_" + sanitizedToolName] = await convertMcpTool(mcpTool, client, timeout)
-      }
-    }
-    return result
-  }
+      const clients = Effect.fn("MCP.clients")(function* () {
+        const s = yield* InstanceState.get(cache)
+        return s.clients
+      })
 
-  export async function prompts() {
-    const s = await state()
-    const clientsSnapshot = await clients()
+      const createAndStore = Effect.fn("MCP.createAndStore")(function* (name: string, mcp: Config.Mcp) {
+        const s = yield* InstanceState.get(cache)
+        const result = yield* Effect.promise(() => create(name, mcp))
 
-    const prompts = Object.fromEntries<PromptInfo & { client: string }>(
-      (
-        await Promise.all(
-          Object.entries(clientsSnapshot).map(async ([clientName, client]) => {
-            if (s.status[clientName]?.status !== "connected") {
-              return []
-            }
+        if (!result) {
+          yield* closeClient(s, name)
+          delete s.clients[name]
+          s.status[name] = { status: "failed" as const, error: "unknown error" }
+          return s.status[name]
+        }
 
-            return Object.entries((await fetchPromptsForClient(clientName, client)) ?? {})
-          }),
-        )
-      ).flat(),
-    )
+        s.status[name] = result.status
+        if (!result.mcpClient) {
+          yield* closeClient(s, name)
+          delete s.clients[name]
+          return result.status
+        }
 
-    return prompts
-  }
+        yield* closeClient(s, name)
+        s.clients[name] = result.mcpClient
+        s.defs[name] = result.defs
+        watch(s, name, result.mcpClient, mcp.timeout)
+        return result.status
+      })
 
-  export async function resources() {
-    const s = await state()
-    const clientsSnapshot = await clients()
+      const add = Effect.fn("MCP.add")(function* (name: string, mcp: Config.Mcp) {
+        yield* createAndStore(name, mcp)
+        const s = yield* InstanceState.get(cache)
+        return { status: s.status }
+      })
 
-    const result = Object.fromEntries<ResourceInfo & { client: string }>(
-      (
-        await Promise.all(
-          Object.entries(clientsSnapshot).map(async ([clientName, client]) => {
-            if (s.status[clientName]?.status !== "connected") {
-              return []
-            }
+      const connect = Effect.fn("MCP.connect")(function* (name: string) {
+        const mcp = yield* getMcpConfig(name)
+        if (!mcp) {
+          log.error("MCP config not found or invalid", { name })
+          return
+        }
+        yield* createAndStore(name, { ...mcp, enabled: true })
+      })
 
-            return Object.entries((await fetchResourcesForClient(clientName, client)) ?? {})
-          }),
+      const disconnect = Effect.fn("MCP.disconnect")(function* (name: string) {
+        const s = yield* InstanceState.get(cache)
+        yield* closeClient(s, name)
+        delete s.clients[name]
+        s.status[name] = { status: "disabled" }
+      })
+
+      const tools = Effect.fn("MCP.tools")(function* () {
+        const result: Record<string, Tool> = {}
+        const s = yield* InstanceState.get(cache)
+        const cfg = yield* Effect.promise(() => Config.get())
+        const config = cfg.mcp ?? {}
+        const defaultTimeout = cfg.experimental?.mcp_timeout
+
+        const connectedClients = Object.entries(s.clients).filter(
+          ([clientName]) => s.status[clientName]?.status === "connected",
         )
-      ).flat(),
-    )
 
-    return result
-  }
+        yield* Effect.forEach(
+          connectedClients,
+          ([clientName, client]) =>
+            Effect.gen(function* () {
+              const mcpConfig = config[clientName]
+              const entry = mcpConfig && isMcpConfigured(mcpConfig) ? mcpConfig : undefined
+
+              const listed = s.defs[clientName]
+              if (!listed) {
+                log.warn("missing cached tools for connected server", { clientName })
+                return
+              }
+
+              const timeout = entry?.timeout ?? defaultTimeout
+              for (const mcpTool of listed) {
+                const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
+                const sanitizedToolName = mcpTool.name.replace(/[^a-zA-Z0-9_-]/g, "_")
+                result[sanitizedClientName + "_" + sanitizedToolName] = convertMcpTool(mcpTool, client, timeout)
+              }
+            }),
+          { concurrency: "unbounded" },
+        )
+        return result
+      })
 
-  export async function getPrompt(clientName: string, name: string, args?: Record<string, string>) {
-    const clientsSnapshot = await clients()
-    const client = clientsSnapshot[clientName]
+      function collectFromConnected<T>(
+        s: State,
+        fetchFn: (clientName: string, client: Client) => Promise<Record<string, T> | undefined>,
+      ) {
+        return Effect.forEach(
+          Object.entries(s.clients).filter(([name]) => s.status[name]?.status === "connected"),
+          ([clientName, client]) =>
+            Effect.promise(async () => Object.entries((await fetchFn(clientName, client)) ?? {})),
+          { concurrency: "unbounded" },
+        ).pipe(Effect.map((results) => Object.fromEntries<T>(results.flat())))
+      }
 
-    if (!client) {
-      log.warn("client not found for prompt", {
-        clientName,
+      const prompts = Effect.fn("MCP.prompts")(function* () {
+        const s = yield* InstanceState.get(cache)
+        return yield* collectFromConnected(s, (name, client) =>
+          fetchFromClient(name, client, (c) => c.listPrompts().then((r) => r.prompts), "prompts"),
+        )
       })
-      return undefined
-    }
 
-    const result = await client
-      .getPrompt({
-        name: name,
-        arguments: args,
+      const resources = Effect.fn("MCP.resources")(function* () {
+        const s = yield* InstanceState.get(cache)
+        return yield* collectFromConnected(s, (name, client) =>
+          fetchFromClient(name, client, (c) => c.listResources().then((r) => r.resources), "resources"),
+        )
       })
-      .catch((e) => {
-        log.error("failed to get prompt from MCP server", {
-          clientName,
+
+      const withClient = Effect.fnUntraced(function* <A>(
+        clientName: string,
+        fn: (client: MCPClient) => Promise<A>,
+        label: string,
+        meta?: Record<string, unknown>,
+      ) {
+        const s = yield* InstanceState.get(cache)
+        const client = s.clients[clientName]
+        if (!client) {
+          log.warn(`client not found for ${label}`, { clientName })
+          return undefined
+        }
+        return yield* Effect.tryPromise({
+          try: () => fn(client),
+          catch: (e: any) => {
+            log.error(`failed to ${label}`, { clientName, ...meta, error: e?.message })
+            return e
+          },
+        }).pipe(Effect.orElseSucceed(() => undefined))
+      })
+
+      const getPrompt = Effect.fn("MCP.getPrompt")(function* (
+        clientName: string,
+        name: string,
+        args?: Record<string, string>,
+      ) {
+        return yield* withClient(clientName, (client) => client.getPrompt({ name, arguments: args }), "getPrompt", {
           promptName: name,
-          error: e.message,
         })
-        return undefined
       })
 
-    return result
-  }
-
-  export async function readResource(clientName: string, resourceUri: string) {
-    const clientsSnapshot = await clients()
-    const client = clientsSnapshot[clientName]
-
-    if (!client) {
-      log.warn("client not found for prompt", {
-        clientName: clientName,
+      const readResource = Effect.fn("MCP.readResource")(function* (clientName: string, resourceUri: string) {
+        return yield* withClient(clientName, (client) => client.readResource({ uri: resourceUri }), "readResource", {
+          resourceUri,
+        })
       })
-      return undefined
-    }
 
-    const result = await client
-      .readResource({
-        uri: resourceUri,
+      const getMcpConfig = Effect.fnUntraced(function* (mcpName: string) {
+        const cfg = yield* Effect.promise(() => Config.get())
+        const mcpConfig = cfg.mcp?.[mcpName]
+        if (!mcpConfig || !isMcpConfigured(mcpConfig)) return undefined
+        return mcpConfig
       })
-      .catch((e) => {
-        log.error("failed to get prompt from MCP server", {
-          clientName: clientName,
-          resourceUri: resourceUri,
-          error: e.message,
+
+      const startAuth = Effect.fn("MCP.startAuth")(function* (mcpName: string) {
+        const mcpConfig = yield* getMcpConfig(mcpName)
+        if (!mcpConfig) throw new Error(`MCP server ${mcpName} not found or disabled`)
+        if (mcpConfig.type !== "remote") throw new Error(`MCP server ${mcpName} is not a remote server`)
+        if (mcpConfig.oauth === false) throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`)
+
+        yield* Effect.promise(() => McpOAuthCallback.ensureRunning())
+
+        const oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32)))
+          .map((b) => b.toString(16).padStart(2, "0"))
+          .join("")
+        yield* auth.updateOAuthState(mcpName, oauthState)
+        const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
+        let capturedUrl: URL | undefined
+        const authProvider = new McpOAuthProvider(
+          mcpName,
+          mcpConfig.url,
+          {
+            clientId: oauthConfig?.clientId,
+            clientSecret: oauthConfig?.clientSecret,
+            scope: oauthConfig?.scope,
+          },
+          {
+            onRedirect: async (url) => {
+              capturedUrl = url
+            },
+          },
+        )
+
+        const transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url), { authProvider })
+
+        return yield* Effect.promise(async () => {
+          try {
+            const client = new Client({ name: "opencode", version: Installation.VERSION })
+            await client.connect(transport)
+            return { authorizationUrl: "", oauthState }
+          } catch (error) {
+            if (error instanceof UnauthorizedError && capturedUrl) {
+              pendingOAuthTransports.set(mcpName, transport)
+              return { authorizationUrl: capturedUrl.toString(), oauthState }
+            }
+            throw error
+          }
         })
-        return undefined
       })
 
-    return result
-  }
+      const authenticate = Effect.fn("MCP.authenticate")(function* (mcpName: string) {
+        const { authorizationUrl, oauthState } = yield* startAuth(mcpName)
+        if (!authorizationUrl) return { status: "connected" } as Status
+
+        log.info("opening browser for oauth", { mcpName, url: authorizationUrl, state: oauthState })
+
+        const callbackPromise = McpOAuthCallback.waitForCallback(oauthState, mcpName)
+
+        yield* Effect.tryPromise(() => open(authorizationUrl)).pipe(
+          Effect.flatMap((subprocess) =>
+            Effect.callback<void, Error>((resume) => {
+              const timer = setTimeout(() => resume(Effect.void), 500)
+              subprocess.on("error", (err) => {
+                clearTimeout(timer)
+                resume(Effect.fail(err))
+              })
+              subprocess.on("exit", (code) => {
+                if (code !== null && code !== 0) {
+                  clearTimeout(timer)
+                  resume(Effect.fail(new Error(`Browser open failed with exit code ${code}`)))
+                }
+              })
+            }),
+          ),
+          Effect.catch(() => {
+            log.warn("failed to open browser, user must open URL manually", { mcpName })
+            return Effect.promise(() => Bus.publish(BrowserOpenFailed, { mcpName, url: authorizationUrl }))
+          }),
+        )
 
-  /**
-   * Start OAuth authentication flow for an MCP server.
-   * Returns the authorization URL that should be opened in a browser.
-   */
-  export async function startAuth(mcpName: string): Promise<{ authorizationUrl: string }> {
-    const cfg = await Config.get()
-    const mcpConfig = cfg.mcp?.[mcpName]
+        const code = yield* Effect.promise(() => callbackPromise)
 
-    if (!mcpConfig) {
-      throw new Error(`MCP server not found: ${mcpName}`)
-    }
+        const storedState = yield* auth.getOAuthState(mcpName)
+        if (storedState !== oauthState) {
+          yield* auth.clearOAuthState(mcpName)
+          throw new Error("OAuth state mismatch - potential CSRF attack")
+        }
+        yield* auth.clearOAuthState(mcpName)
+        return yield* finishAuth(mcpName, code)
+      })
 
-    if (!isMcpConfigured(mcpConfig)) {
-      throw new Error(`MCP server ${mcpName} is disabled or missing configuration`)
-    }
+      const finishAuth = Effect.fn("MCP.finishAuth")(function* (mcpName: string, authorizationCode: string) {
+        const transport = pendingOAuthTransports.get(mcpName)
+        if (!transport) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`)
 
-    if (mcpConfig.type !== "remote") {
-      throw new Error(`MCP server ${mcpName} is not a remote server`)
-    }
+        const result = yield* Effect.tryPromise({
+          try: async () => {
+            await transport.finishAuth(authorizationCode)
+            return true
+          },
+          catch: (error) => {
+            log.error("failed to finish oauth", { mcpName, error })
+            return error
+          },
+        }).pipe(Effect.option)
 
-    if (mcpConfig.oauth === false) {
-      throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`)
-    }
+        if (Option.isNone(result)) {
+          return { status: "failed", error: "OAuth completion failed" } as Status
+        }
 
-    // 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
-    const oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32)))
-      .map((b) => b.toString(16).padStart(2, "0"))
-      .join("")
-    await McpAuth.updateOAuthState(mcpName, oauthState)
-
-    // Create a new auth provider for this flow
-    // OAuth config is optional - if not provided, we'll use auto-discovery
-    const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
-    let capturedUrl: URL | undefined
-    const authProvider = new McpOAuthProvider(
-      mcpName,
-      mcpConfig.url,
-      {
-        clientId: oauthConfig?.clientId,
-        clientSecret: oauthConfig?.clientSecret,
-        scope: oauthConfig?.scope,
-      },
-      {
-        onRedirect: async (url) => {
-          capturedUrl = url
-        },
-      },
-    )
+        yield* auth.clearCodeVerifier(mcpName)
+        pendingOAuthTransports.delete(mcpName)
 
-    // Create transport with auth provider
-    const transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url), {
-      authProvider,
-    })
+        const mcpConfig = yield* getMcpConfig(mcpName)
+        if (!mcpConfig) return { status: "failed", error: "MCP config not found after auth" } as Status
 
-    // Try to connect - this will trigger the OAuth flow
-    try {
-      const client = new Client({
-        name: "opencode",
-        version: Installation.VERSION,
+        return yield* createAndStore(mcpName, mcpConfig)
       })
-      await client.connect(transport)
-      // If we get here, we're already authenticated
-      return { authorizationUrl: "" }
-    } catch (error) {
-      if (error instanceof UnauthorizedError && capturedUrl) {
-        // Store transport for finishAuth
-        pendingOAuthTransports.set(mcpName, transport)
-        return { authorizationUrl: capturedUrl.toString() }
-      }
-      throw error
-    }
-  }
 
-  /**
-   * Complete OAuth authentication after user authorizes in browser.
-   * Opens the browser and waits for callback.
-   */
-  export async function authenticate(mcpName: string): Promise<Status> {
-    const { authorizationUrl } = await startAuth(mcpName)
-
-    if (!authorizationUrl) {
-      // Already authenticated
-      const s = await state()
-      return s.status[mcpName] ?? { status: "connected" }
-    }
+      const removeAuth = Effect.fn("MCP.removeAuth")(function* (mcpName: string) {
+        yield* auth.remove(mcpName)
+        McpOAuthCallback.cancelPending(mcpName)
+        pendingOAuthTransports.delete(mcpName)
+        log.info("removed oauth credentials", { mcpName })
+      })
 
-    // Get the state that was already generated and stored in startAuth()
-    const oauthState = await McpAuth.getOAuthState(mcpName)
-    if (!oauthState) {
-      throw new Error("OAuth state not found - this should not happen")
-    }
+      const supportsOAuth = Effect.fn("MCP.supportsOAuth")(function* (mcpName: string) {
+        const mcpConfig = yield* getMcpConfig(mcpName)
+        if (!mcpConfig) return false
+        return mcpConfig.type === "remote" && mcpConfig.oauth !== false
+      })
 
-    // The SDK has already added the state parameter to the authorization URL
-    // We just need to open the browser
-    log.info("opening browser for oauth", { mcpName, url: authorizationUrl, state: oauthState })
-
-    // Register the callback BEFORE opening the browser to avoid race condition
-    // 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)
-      await new Promise<void>((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)
-          reject(error)
-        })
-        subprocess.on("exit", (code) => {
-          if (code !== null && code !== 0) {
-            clearTimeout(timeout)
-            reject(new Error(`Browser open failed with exit code ${code}`))
-          }
-        })
+      const hasStoredTokens = Effect.fn("MCP.hasStoredTokens")(function* (mcpName: string) {
+        const entry = yield* auth.get(mcpName)
+        return !!entry?.tokens
       })
-    } 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
-    const code = await callbackPromise
+      const getAuthStatus = Effect.fn("MCP.getAuthStatus")(function* (mcpName: string) {
+        const entry = yield* auth.get(mcpName)
+        if (!entry?.tokens) return "not_authenticated" as AuthStatus
+        const expired = yield* auth.isTokenExpired(mcpName)
+        return (expired ? "expired" : "authenticated") as AuthStatus
+      })
 
-    // Validate and clear the state
-    const storedState = await McpAuth.getOAuthState(mcpName)
-    if (storedState !== oauthState) {
-      await McpAuth.clearOAuthState(mcpName)
-      throw new Error("OAuth state mismatch - potential CSRF attack")
-    }
+      return Service.of({
+        status,
+        clients,
+        tools,
+        prompts,
+        resources,
+        add,
+        connect,
+        disconnect,
+        getPrompt,
+        readResource,
+        startAuth,
+        authenticate,
+        finishAuth,
+        removeAuth,
+        supportsOAuth,
+        hasStoredTokens,
+        getAuthStatus,
+      })
+    }),
+  )
 
-    await McpAuth.clearOAuthState(mcpName)
+  export type AuthStatus = "authenticated" | "expired" | "not_authenticated"
 
-    // Finish auth
-    return finishAuth(mcpName, code)
-  }
+  // --- Per-service runtime ---
 
-  /**
-   * Complete OAuth authentication with the authorization code.
-   */
-  export async function finishAuth(mcpName: string, authorizationCode: string): Promise<Status> {
-    const transport = pendingOAuthTransports.get(mcpName)
+  const defaultLayer = layer.pipe(
+    Layer.provide(McpAuth.layer),
+    Layer.provide(CrossSpawnSpawner.layer),
+    Layer.provide(AppFileSystem.defaultLayer),
+    Layer.provide(NodeFileSystem.layer),
+    Layer.provide(NodePath.layer),
+  )
 
-    if (!transport) {
-      throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`)
-    }
+  const runPromise = makeRunPromise(Service, defaultLayer)
 
-    try {
-      // Call finishAuth on the transport
-      await transport.finishAuth(authorizationCode)
+  // --- Async facade functions ---
 
-      // Clear the code verifier after successful auth
-      await McpAuth.clearCodeVerifier(mcpName)
+  export const status = async () => runPromise((svc) => svc.status())
 
-      // Now try to reconnect
-      const cfg = await Config.get()
-      const mcpConfig = cfg.mcp?.[mcpName]
+  export const clients = async () => runPromise((svc) => svc.clients())
 
-      if (!mcpConfig) {
-        throw new Error(`MCP server not found: ${mcpName}`)
-      }
+  export const tools = async () => runPromise((svc) => svc.tools())
 
-      if (!isMcpConfigured(mcpConfig)) {
-        throw new Error(`MCP server ${mcpName} is disabled or missing configuration`)
-      }
+  export const prompts = async () => runPromise((svc) => svc.prompts())
 
-      // Re-add the MCP server to establish connection
-      pendingOAuthTransports.delete(mcpName)
-      const result = await add(mcpName, mcpConfig)
+  export const resources = async () => runPromise((svc) => svc.resources())
 
-      const statusRecord = result.status as Record<string, Status>
-      return statusRecord[mcpName] ?? { status: "failed", error: "Unknown error after auth" }
-    } catch (error) {
-      log.error("failed to finish oauth", { mcpName, error })
-      return {
-        status: "failed",
-        error: error instanceof Error ? error.message : String(error),
-      }
-    }
-  }
+  export const add = async (name: string, mcp: Config.Mcp) => runPromise((svc) => svc.add(name, mcp))
 
-  /**
-   * Remove OAuth credentials for an MCP server.
-   */
-  export async function removeAuth(mcpName: string): Promise<void> {
-    await McpAuth.remove(mcpName)
-    McpOAuthCallback.cancelPending(mcpName)
-    pendingOAuthTransports.delete(mcpName)
-    await McpAuth.clearOAuthState(mcpName)
-    log.info("removed oauth credentials", { mcpName })
-  }
+  export const connect = async (name: string) => runPromise((svc) => svc.connect(name))
 
-  /**
-   * Check if an MCP server supports OAuth (remote servers support OAuth by default unless explicitly disabled).
-   */
-  export async function supportsOAuth(mcpName: string): Promise<boolean> {
-    const cfg = await Config.get()
-    const mcpConfig = cfg.mcp?.[mcpName]
-    if (!mcpConfig) return false
-    if (!isMcpConfigured(mcpConfig)) return false
-    return mcpConfig.type === "remote" && mcpConfig.oauth !== false
-  }
+  export const disconnect = async (name: string) => runPromise((svc) => svc.disconnect(name))
 
-  /**
-   * Check if an MCP server has stored OAuth tokens.
-   */
-  export async function hasStoredTokens(mcpName: string): Promise<boolean> {
-    const entry = await McpAuth.get(mcpName)
-    return !!entry?.tokens
-  }
+  export const getPrompt = async (clientName: string, name: string, args?: Record<string, string>) =>
+    runPromise((svc) => svc.getPrompt(clientName, name, args))
 
-  export type AuthStatus = "authenticated" | "expired" | "not_authenticated"
+  export const readResource = async (clientName: string, resourceUri: string) =>
+    runPromise((svc) => svc.readResource(clientName, resourceUri))
 
-  /**
-   * Get the authentication status for an MCP server.
-   */
-  export async function getAuthStatus(mcpName: string): Promise<AuthStatus> {
-    const hasTokens = await hasStoredTokens(mcpName)
-    if (!hasTokens) return "not_authenticated"
-    const expired = await McpAuth.isTokenExpired(mcpName)
-    return expired ? "expired" : "authenticated"
-  }
+  export const startAuth = async (mcpName: string) => runPromise((svc) => svc.startAuth(mcpName))
+
+  export const authenticate = async (mcpName: string) => runPromise((svc) => svc.authenticate(mcpName))
+
+  export const finishAuth = async (mcpName: string, authorizationCode: string) =>
+    runPromise((svc) => svc.finishAuth(mcpName, authorizationCode))
+
+  export const removeAuth = async (mcpName: string) => runPromise((svc) => svc.removeAuth(mcpName))
+
+  export const supportsOAuth = async (mcpName: string) => runPromise((svc) => svc.supportsOAuth(mcpName))
+
+  export const hasStoredTokens = async (mcpName: string) => runPromise((svc) => svc.hasStoredTokens(mcpName))
+
+  export const getAuthStatus = async (mcpName: string) => runPromise((svc) => svc.getAuthStatus(mcpName))
 }

+ 26 - 3
packages/opencode/src/mcp/oauth-callback.ts

@@ -54,6 +54,9 @@ interface PendingAuth {
 export namespace McpOAuthCallback {
   let server: ReturnType<typeof Bun.serve> | 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
 
@@ -98,6 +101,12 @@ export namespace McpOAuthCallback {
             const pending = pendingAuths.get(state)!
             clearTimeout(pending.timeout)
             pendingAuths.delete(state)
+            for (const [name, s] of mcpNameToState) {
+              if (s === state) {
+                mcpNameToState.delete(name)
+                break
+              }
+            }
             pending.reject(new Error(errorMsg))
           }
           return new Response(HTML_ERROR(errorMsg), {
@@ -126,6 +135,13 @@ export namespace McpOAuthCallback {
 
         clearTimeout(pending.timeout)
         pendingAuths.delete(state)
+        // Clean up reverse index
+        for (const [name, s] of mcpNameToState) {
+          if (s === state) {
+            mcpNameToState.delete(name)
+            break
+          }
+        }
         pending.resolve(code)
 
         return new Response(HTML_SUCCESS, {
@@ -137,11 +153,13 @@ export namespace McpOAuthCallback {
     log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
   }
 
-  export function waitForCallback(oauthState: string): Promise<string> {
+  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)
@@ -151,10 +169,14 @@ export namespace McpOAuthCallback {
   }
 
   export function cancelPending(mcpName: string): void {
-    const pending = pendingAuths.get(mcpName)
+    // 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(mcpName)
+      pendingAuths.delete(key)
+      mcpNameToState.delete(mcpName)
       pending.reject(new Error("Authorization cancelled"))
     }
   }
@@ -184,6 +206,7 @@ export namespace McpOAuthCallback {
       pending.reject(new Error("OAuth callback server stopped"))
     }
     pendingAuths.clear()
+    mcpNameToState.clear()
   }
 
   export function isRunning(): boolean {

+ 660 - 0
packages/opencode/test/mcp/lifecycle.test.ts

@@ -0,0 +1,660 @@
+import { test, expect, mock, beforeEach } from "bun:test"
+
+// --- Mock infrastructure ---
+
+// Per-client state for controlling mock behavior
+interface MockClientState {
+  tools: Array<{ name: string; description?: string; inputSchema: object }>
+  listToolsCalls: number
+  listToolsShouldFail: boolean
+  listToolsError: string
+  listPromptsShouldFail: boolean
+  listResourcesShouldFail: boolean
+  prompts: Array<{ name: string; description?: string }>
+  resources: Array<{ name: string; uri: string; description?: string }>
+  closed: boolean
+  notificationHandlers: Map<unknown, (...args: any[]) => any>
+}
+
+const clientStates = new Map<string, MockClientState>()
+let lastCreatedClientName: string | undefined
+let connectShouldFail = false
+let connectError = "Mock transport cannot connect"
+// Tracks how many Client instances were created (detects leaks)
+let clientCreateCount = 0
+
+function getOrCreateClientState(name?: string): MockClientState {
+  const key = name ?? "default"
+  let state = clientStates.get(key)
+  if (!state) {
+    state = {
+      tools: [{ name: "test_tool", description: "A test tool", inputSchema: { type: "object", properties: {} } }],
+      listToolsCalls: 0,
+      listToolsShouldFail: false,
+      listToolsError: "listTools failed",
+      listPromptsShouldFail: false,
+      listResourcesShouldFail: false,
+      prompts: [],
+      resources: [],
+      closed: false,
+      notificationHandlers: new Map(),
+    }
+    clientStates.set(key, state)
+  }
+  return state
+}
+
+// Mock transport that succeeds or fails based on connectShouldFail
+class MockStdioTransport {
+  stderr: null = null
+  pid = 12345
+  constructor(_opts: any) {}
+  async start() {
+    if (connectShouldFail) throw new Error(connectError)
+  }
+  async close() {}
+}
+
+class MockStreamableHTTP {
+  constructor(_url: URL, _opts?: any) {}
+  async start() {
+    if (connectShouldFail) throw new Error(connectError)
+  }
+  async close() {}
+  async finishAuth() {}
+}
+
+class MockSSE {
+  constructor(_url: URL, _opts?: any) {}
+  async start() {
+    throw new Error("SSE fallback - not used in these tests")
+  }
+  async close() {}
+}
+
+mock.module("@modelcontextprotocol/sdk/client/stdio.js", () => ({
+  StdioClientTransport: MockStdioTransport,
+}))
+
+mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
+  StreamableHTTPClientTransport: MockStreamableHTTP,
+}))
+
+mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({
+  SSEClientTransport: MockSSE,
+}))
+
+mock.module("@modelcontextprotocol/sdk/client/auth.js", () => ({
+  UnauthorizedError: class extends Error {
+    constructor() {
+      super("Unauthorized")
+    }
+  },
+}))
+
+// Mock Client that delegates to per-name MockClientState
+mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
+  Client: class MockClient {
+    _state!: MockClientState
+    transport: any
+
+    constructor(_opts: any) {
+      clientCreateCount++
+    }
+
+    async connect(transport: { start: () => Promise<void> }) {
+      this.transport = transport
+      await transport.start()
+      // After successful connect, bind to the last-created client name
+      this._state = getOrCreateClientState(lastCreatedClientName)
+    }
+
+    setNotificationHandler(schema: unknown, handler: (...args: any[]) => any) {
+      this._state?.notificationHandlers.set(schema, handler)
+    }
+
+    async listTools() {
+      if (this._state) this._state.listToolsCalls++
+      if (this._state?.listToolsShouldFail) {
+        throw new Error(this._state.listToolsError)
+      }
+      return { tools: this._state?.tools ?? [] }
+    }
+
+    async listPrompts() {
+      if (this._state?.listPromptsShouldFail) {
+        throw new Error("listPrompts failed")
+      }
+      return { prompts: this._state?.prompts ?? [] }
+    }
+
+    async listResources() {
+      if (this._state?.listResourcesShouldFail) {
+        throw new Error("listResources failed")
+      }
+      return { resources: this._state?.resources ?? [] }
+    }
+
+    async close() {
+      if (this._state) this._state.closed = true
+    }
+  },
+}))
+
+beforeEach(() => {
+  clientStates.clear()
+  lastCreatedClientName = undefined
+  connectShouldFail = false
+  connectError = "Mock transport cannot connect"
+  clientCreateCount = 0
+})
+
+// Import after mocks
+const { MCP } = await import("../../src/mcp/index")
+const { Instance } = await import("../../src/project/instance")
+const { tmpdir } = await import("../fixture/fixture")
+
+// --- Helper ---
+
+function withInstance(config: Record<string, any>, fn: () => Promise<void>) {
+  return async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        await Bun.write(
+          `${dir}/opencode.json`,
+          JSON.stringify({
+            $schema: "https://opencode.ai/config.json",
+            mcp: config,
+          }),
+        )
+      },
+    })
+
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        await fn()
+        // dispose instance to clean up state between tests
+        await Instance.dispose()
+      },
+    })
+  }
+}
+
+// ========================================================================
+// Test: tools() are cached after connect
+// ========================================================================
+
+test(
+  "tools() reuses cached tool definitions after connect",
+  withInstance({}, async () => {
+    lastCreatedClientName = "my-server"
+    const serverState = getOrCreateClientState("my-server")
+    serverState.tools = [
+      { name: "do_thing", description: "does a thing", inputSchema: { type: "object", properties: {} } },
+    ]
+
+    // First: add the server successfully
+    const addResult = await MCP.add("my-server", {
+      type: "local",
+      command: ["echo", "test"],
+    })
+    expect((addResult.status as any)["my-server"]?.status ?? (addResult.status as any).status).toBe("connected")
+
+    expect(serverState.listToolsCalls).toBe(1)
+
+    const toolsA = await MCP.tools()
+    const toolsB = await MCP.tools()
+    expect(Object.keys(toolsA).length).toBeGreaterThan(0)
+    expect(Object.keys(toolsB).length).toBeGreaterThan(0)
+    expect(serverState.listToolsCalls).toBe(1)
+  }),
+)
+
+// ========================================================================
+// Test: tool change notifications refresh the cache
+// ========================================================================
+
+test(
+  "tool change notifications refresh cached tool definitions",
+  withInstance({}, async () => {
+    lastCreatedClientName = "status-server"
+    const serverState = getOrCreateClientState("status-server")
+
+    await MCP.add("status-server", {
+      type: "local",
+      command: ["echo", "test"],
+    })
+
+    const before = await MCP.tools()
+    expect(Object.keys(before).some((key) => key.includes("test_tool"))).toBe(true)
+    expect(serverState.listToolsCalls).toBe(1)
+
+    serverState.tools = [{ name: "next_tool", description: "next", inputSchema: { type: "object", properties: {} } }]
+
+    const handler = Array.from(serverState.notificationHandlers.values())[0]
+    expect(handler).toBeDefined()
+    await handler?.()
+
+    const after = await MCP.tools()
+    expect(Object.keys(after).some((key) => key.includes("next_tool"))).toBe(true)
+    expect(Object.keys(after).some((key) => key.includes("test_tool"))).toBe(false)
+    expect(serverState.listToolsCalls).toBe(2)
+  }),
+)
+
+// ========================================================================
+// Test: connect() / disconnect() lifecycle
+// ========================================================================
+
+test(
+  "disconnect sets status to disabled and removes client",
+  withInstance(
+    {
+      "disc-server": {
+        type: "local",
+        command: ["echo", "test"],
+      },
+    },
+    async () => {
+      lastCreatedClientName = "disc-server"
+      getOrCreateClientState("disc-server")
+
+      await MCP.add("disc-server", {
+        type: "local",
+        command: ["echo", "test"],
+      })
+
+      const statusBefore = await MCP.status()
+      expect(statusBefore["disc-server"]?.status).toBe("connected")
+
+      await MCP.disconnect("disc-server")
+
+      const statusAfter = await MCP.status()
+      expect(statusAfter["disc-server"]?.status).toBe("disabled")
+
+      // Tools should be empty after disconnect
+      const tools = await MCP.tools()
+      const serverTools = Object.keys(tools).filter((k) => k.startsWith("disc-server"))
+      expect(serverTools.length).toBe(0)
+    },
+  ),
+)
+
+test(
+  "connect() after disconnect() re-establishes the server",
+  withInstance(
+    {
+      "reconn-server": {
+        type: "local",
+        command: ["echo", "test"],
+      },
+    },
+    async () => {
+      lastCreatedClientName = "reconn-server"
+      const serverState = getOrCreateClientState("reconn-server")
+      serverState.tools = [{ name: "my_tool", description: "a tool", inputSchema: { type: "object", properties: {} } }]
+
+      await MCP.add("reconn-server", {
+        type: "local",
+        command: ["echo", "test"],
+      })
+
+      await MCP.disconnect("reconn-server")
+      expect((await MCP.status())["reconn-server"]?.status).toBe("disabled")
+
+      // Reconnect
+      await MCP.connect("reconn-server")
+      expect((await MCP.status())["reconn-server"]?.status).toBe("connected")
+
+      const tools = await MCP.tools()
+      expect(Object.keys(tools).some((k) => k.includes("my_tool"))).toBe(true)
+    },
+  ),
+)
+
+// ========================================================================
+// Test: add() closes existing client before replacing
+// ========================================================================
+
+test(
+  "add() closes the old client when replacing a server",
+  // Don't put the server in config — add it dynamically so we control
+  // exactly which client instance is "first" vs "second".
+  withInstance({}, async () => {
+    lastCreatedClientName = "replace-server"
+    const firstState = getOrCreateClientState("replace-server")
+
+    await MCP.add("replace-server", {
+      type: "local",
+      command: ["echo", "test"],
+    })
+
+    expect(firstState.closed).toBe(false)
+
+    // Create new state for second client
+    clientStates.delete("replace-server")
+    const secondState = getOrCreateClientState("replace-server")
+
+    // Re-add should close the first client
+    await MCP.add("replace-server", {
+      type: "local",
+      command: ["echo", "test"],
+    })
+
+    expect(firstState.closed).toBe(true)
+    expect(secondState.closed).toBe(false)
+  }),
+)
+
+// ========================================================================
+// Test: state init with mixed success/failure
+// ========================================================================
+
+test(
+  "init connects available servers even when one fails",
+  withInstance(
+    {
+      "good-server": {
+        type: "local",
+        command: ["echo", "good"],
+      },
+      "bad-server": {
+        type: "local",
+        command: ["echo", "bad"],
+      },
+    },
+    async () => {
+      // Set up good server
+      const goodState = getOrCreateClientState("good-server")
+      goodState.tools = [{ name: "good_tool", description: "works", inputSchema: { type: "object", properties: {} } }]
+
+      // Set up bad server - will fail on listTools during create()
+      const badState = getOrCreateClientState("bad-server")
+      badState.listToolsShouldFail = true
+
+      // Add good server first
+      lastCreatedClientName = "good-server"
+      await MCP.add("good-server", {
+        type: "local",
+        command: ["echo", "good"],
+      })
+
+      // Add bad server - should fail but not affect good server
+      lastCreatedClientName = "bad-server"
+      await MCP.add("bad-server", {
+        type: "local",
+        command: ["echo", "bad"],
+      })
+
+      const status = await MCP.status()
+      expect(status["good-server"]?.status).toBe("connected")
+      expect(status["bad-server"]?.status).toBe("failed")
+
+      // Good server's tools should still be available
+      const tools = await MCP.tools()
+      expect(Object.keys(tools).some((k) => k.includes("good_tool"))).toBe(true)
+    },
+  ),
+)
+
+// ========================================================================
+// Test: disabled server via config
+// ========================================================================
+
+test(
+  "disabled server is marked as disabled without attempting connection",
+  withInstance(
+    {
+      "disabled-server": {
+        type: "local",
+        command: ["echo", "test"],
+        enabled: false,
+      },
+    },
+    async () => {
+      const countBefore = clientCreateCount
+
+      await MCP.add("disabled-server", {
+        type: "local",
+        command: ["echo", "test"],
+        enabled: false,
+      } as any)
+
+      // No client should have been created
+      expect(clientCreateCount).toBe(countBefore)
+
+      const status = await MCP.status()
+      expect(status["disabled-server"]?.status).toBe("disabled")
+    },
+  ),
+)
+
+// ========================================================================
+// Test: prompts() and resources()
+// ========================================================================
+
+test(
+  "prompts() returns prompts from connected servers",
+  withInstance(
+    {
+      "prompt-server": {
+        type: "local",
+        command: ["echo", "test"],
+      },
+    },
+    async () => {
+      lastCreatedClientName = "prompt-server"
+      const serverState = getOrCreateClientState("prompt-server")
+      serverState.prompts = [{ name: "my-prompt", description: "A test prompt" }]
+
+      await MCP.add("prompt-server", {
+        type: "local",
+        command: ["echo", "test"],
+      })
+
+      const prompts = await MCP.prompts()
+      expect(Object.keys(prompts).length).toBe(1)
+      const key = Object.keys(prompts)[0]
+      expect(key).toContain("prompt-server")
+      expect(key).toContain("my-prompt")
+    },
+  ),
+)
+
+test(
+  "resources() returns resources from connected servers",
+  withInstance(
+    {
+      "resource-server": {
+        type: "local",
+        command: ["echo", "test"],
+      },
+    },
+    async () => {
+      lastCreatedClientName = "resource-server"
+      const serverState = getOrCreateClientState("resource-server")
+      serverState.resources = [{ name: "my-resource", uri: "file:///test.txt", description: "A test resource" }]
+
+      await MCP.add("resource-server", {
+        type: "local",
+        command: ["echo", "test"],
+      })
+
+      const resources = await MCP.resources()
+      expect(Object.keys(resources).length).toBe(1)
+      const key = Object.keys(resources)[0]
+      expect(key).toContain("resource-server")
+      expect(key).toContain("my-resource")
+    },
+  ),
+)
+
+test(
+  "prompts() skips disconnected servers",
+  withInstance(
+    {
+      "prompt-disc-server": {
+        type: "local",
+        command: ["echo", "test"],
+      },
+    },
+    async () => {
+      lastCreatedClientName = "prompt-disc-server"
+      const serverState = getOrCreateClientState("prompt-disc-server")
+      serverState.prompts = [{ name: "hidden-prompt", description: "Should not appear" }]
+
+      await MCP.add("prompt-disc-server", {
+        type: "local",
+        command: ["echo", "test"],
+      })
+
+      await MCP.disconnect("prompt-disc-server")
+
+      const prompts = await MCP.prompts()
+      expect(Object.keys(prompts).length).toBe(0)
+    },
+  ),
+)
+
+// ========================================================================
+// Test: connect() on nonexistent server
+// ========================================================================
+
+test(
+  "connect() on nonexistent server does not throw",
+  withInstance({}, async () => {
+    // Should not throw
+    await MCP.connect("nonexistent")
+    const status = await MCP.status()
+    expect(status["nonexistent"]).toBeUndefined()
+  }),
+)
+
+// ========================================================================
+// Test: disconnect() on nonexistent server
+// ========================================================================
+
+test(
+  "disconnect() on nonexistent server does not throw",
+  withInstance({}, async () => {
+    await MCP.disconnect("nonexistent")
+    // Should complete without error
+  }),
+)
+
+// ========================================================================
+// Test: tools() with no MCP servers configured
+// ========================================================================
+
+test(
+  "tools() returns empty when no MCP servers are configured",
+  withInstance({}, async () => {
+    const tools = await MCP.tools()
+    expect(Object.keys(tools).length).toBe(0)
+  }),
+)
+
+// ========================================================================
+// Test: connect failure during create()
+// ========================================================================
+
+test(
+  "server that fails to connect is marked as failed",
+  withInstance(
+    {
+      "fail-connect": {
+        type: "local",
+        command: ["echo", "test"],
+      },
+    },
+    async () => {
+      lastCreatedClientName = "fail-connect"
+      getOrCreateClientState("fail-connect")
+      connectShouldFail = true
+      connectError = "Connection refused"
+
+      await MCP.add("fail-connect", {
+        type: "local",
+        command: ["echo", "test"],
+      })
+
+      const status = await MCP.status()
+      expect(status["fail-connect"]?.status).toBe("failed")
+      if (status["fail-connect"]?.status === "failed") {
+        expect(status["fail-connect"].error).toContain("Connection refused")
+      }
+
+      // No tools should be available
+      const tools = await MCP.tools()
+      expect(Object.keys(tools).length).toBe(0)
+    },
+  ),
+)
+
+// ========================================================================
+// Bug #5: McpOAuthCallback.cancelPending uses wrong key
+// ========================================================================
+
+test("McpOAuthCallback.cancelPending is keyed by mcpName but pendingAuths uses oauthState", async () => {
+  const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback")
+
+  // Register a pending auth with an oauthState key, associated to an mcpName
+  const oauthState = "abc123hexstate"
+  const callbackPromise = McpOAuthCallback.waitForCallback(oauthState, "my-mcp-server")
+
+  // cancelPending is called with mcpName — should find the entry via reverse index
+  McpOAuthCallback.cancelPending("my-mcp-server")
+
+  // The callback should still be pending because cancelPending looked up
+  // "my-mcp-server" in a map keyed by "abc123hexstate"
+  let resolved = false
+  let rejected = false
+  callbackPromise.then(() => (resolved = true)).catch(() => (rejected = true))
+
+  // Give it a tick
+  await new Promise((r) => setTimeout(r, 50))
+
+  // cancelPending("my-mcp-server") should have rejected the pending callback
+  expect(rejected).toBe(true)
+
+  await McpOAuthCallback.stop()
+})
+
+// ========================================================================
+// Test: multiple tools from same server get correct name prefixes
+// ========================================================================
+
+test(
+  "tools() prefixes tool names with sanitized server name",
+  withInstance(
+    {
+      "my.special-server": {
+        type: "local",
+        command: ["echo", "test"],
+      },
+    },
+    async () => {
+      lastCreatedClientName = "my.special-server"
+      const serverState = getOrCreateClientState("my.special-server")
+      serverState.tools = [
+        { name: "tool-a", description: "Tool A", inputSchema: { type: "object", properties: {} } },
+        { name: "tool.b", description: "Tool B", inputSchema: { type: "object", properties: {} } },
+      ]
+
+      await MCP.add("my.special-server", {
+        type: "local",
+        command: ["echo", "test"],
+      })
+
+      const tools = await MCP.tools()
+      const keys = Object.keys(tools)
+
+      // Server name dots should be replaced with underscores
+      expect(keys.some((k) => k.startsWith("my_special-server_"))).toBe(true)
+      // Tool name dots should be replaced with underscores
+      expect(keys.some((k) => k.endsWith("tool_b"))).toBe(true)
+      expect(keys.length).toBe(2)
+    },
+  ),
+)