Bladeren bron

test(mcp): replace module mocks with real servers (#35450)

Aiden Cline 1 maand geleden
bovenliggende
commit
d3459eb740

+ 37 - 0
packages/opencode/src/mcp/browser.ts

@@ -0,0 +1,37 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
+import { Context, Effect, Layer } from "effect"
+import open from "open"
+
+export interface Interface {
+  readonly open: (url: string) => Effect.Effect<void, Error>
+}
+
+export class Service extends Context.Service<Service, Interface>()("@opencode/McpBrowser") {}
+
+const layer = Layer.succeed(
+  Service,
+  Service.of({
+    open: Effect.fn("McpBrowser.open")(function* (url: string) {
+      const subprocess = yield* Effect.tryPromise({
+        try: () => open(url),
+        catch: (error) => (error instanceof Error ? error : new Error(String(error))),
+      })
+      yield* Effect.callback<void, Error>((resume) => {
+        const timer = setTimeout(() => resume(Effect.void), 500)
+        subprocess.on("error", (error) => {
+          clearTimeout(timer)
+          resume(Effect.fail(error))
+        })
+        subprocess.on("exit", (code) => {
+          if (code === null || code === 0) return
+          clearTimeout(timer)
+          resume(Effect.fail(new Error(`Browser open failed with exit code ${code}`)))
+        })
+      })
+    }),
+  }),
+)
+
+export const node = LayerNode.make({ service: Service, layer, deps: [] })
+
+export * as McpBrowser from "./browser"

+ 4 - 18
packages/opencode/src/mcp/index.ts

@@ -26,7 +26,6 @@ import { McpOAuthCallback } from "./oauth-callback"
 import { McpAuth } from "./auth"
 import { EventV2Bridge } from "@/event-v2-bridge"
 import { TuiEvent } from "@/server/tui-event"
-import open from "open"
 import { Cause, Effect, Exit, Layer, Context, Schema, Stream } from "effect"
 import { EffectBridge } from "@/effect/bridge"
 import { InstanceState } from "@/effect/instance-state"
@@ -34,6 +33,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { McpCatalog } from "./catalog"
 import { McpEvent } from "@opencode-ai/schema/mcp-event"
+import { McpBrowser } from "./browser"
 
 const DEFAULT_TIMEOUT = 30_000
 const CLIENT_OPTIONS = {
@@ -207,6 +207,7 @@ const layer = Layer.effect(
     const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
     const auth = yield* McpAuth.Service
     const events = yield* EventV2Bridge.Service
+    const browser = yield* McpBrowser.Service
 
     type Transport = StdioClientTransport | StreamableHTTPClientTransport | SSEClientTransport
 
@@ -897,22 +898,7 @@ const layer = Layer.effect(
       const callbackPromise = McpOAuthCallback.waitForCallback(result.oauthState, mcpName)
       onAuthorization?.(result.authorizationUrl)
 
-      yield* Effect.tryPromise(() => open(result.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}`)))
-              }
-            })
-          }),
-        ),
+      yield* browser.open(result.authorizationUrl).pipe(
         Effect.catch(() => {
           return events.publish(BrowserOpenFailed, { mcpName, url: result.authorizationUrl }).pipe(Effect.ignore)
         }),
@@ -1012,7 +998,7 @@ export type AuthStatus = "authenticated" | "expired" | "not_authenticated"
 export const node = LayerNode.make({
   service: Service,
   layer: layer,
-  deps: [CrossSpawnSpawner.node, McpAuth.node, EventV2Bridge.node, Config.node],
+  deps: [CrossSpawnSpawner.node, McpAuth.node, EventV2Bridge.node, Config.node, McpBrowser.node],
 })
 
 export * as MCP from "."

+ 26 - 0
packages/opencode/test/fixture/mcp-lifecycle-stdio.ts

@@ -0,0 +1,26 @@
+import { Server } from "@modelcontextprotocol/sdk/server/index.js"
+import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
+import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
+
+if (process.argv.includes("--hang")) {
+  const pidFile = process.env.MCP_LIFECYCLE_PID_FILE
+  if (!pidFile) throw new Error("MCP_LIFECYCLE_PID_FILE is required")
+  await Bun.write(pidFile, String(process.pid))
+  await new Promise(() => {})
+}
+
+const server = new Server({ name: "mcp-lifecycle-stdio", version: "1.0.0" }, { capabilities: { tools: {} } })
+
+server.setRequestHandler(ListToolsRequestSchema, () =>
+  Promise.resolve({
+    tools: [
+      {
+        name: "current_directory",
+        description: process.cwd(),
+        inputSchema: { type: "object", properties: {} },
+      },
+    ],
+  }),
+)
+
+await server.connect(new StdioServerTransport())

+ 69 - 94
packages/opencode/test/mcp/headers.test.ts

@@ -1,126 +1,101 @@
-import { describe, expect, mock, beforeEach } from "bun:test"
+import { describe, expect } from "bun:test"
+import { Server } from "@modelcontextprotocol/sdk/server/index.js"
+import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
+import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
 import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { Effect } from "effect"
 import { testEffect } from "../lib/effect"
+import { MCP } from "../../src/mcp/index"
 
-// Track what options were passed to each transport constructor
-const transportCalls: Array<{
-  type: "streamable" | "sse"
-  url: string
-  options: { authProvider?: unknown; requestInit?: RequestInit }
-}> = []
-
-// Mock the transport constructors to capture their arguments
-void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
-  StreamableHTTPClientTransport: class MockStreamableHTTP {
-    constructor(url: URL, options?: { authProvider?: unknown; requestInit?: RequestInit }) {
-      transportCalls.push({
-        type: "streamable",
-        url: url.toString(),
-        options: options ?? {},
-      })
-    }
-    async start() {
-      throw new Error("Mock transport cannot connect")
-    }
-  },
-}))
+const it = testEffect(LayerNode.compile(MCP.node))
 
-void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({
-  SSEClientTransport: class MockSSE {
-    constructor(url: URL, options?: { authProvider?: unknown; requestInit?: RequestInit }) {
-      transportCalls.push({
-        type: "sse",
-        url: url.toString(),
-        options: options ?? {},
-      })
+const serve = Effect.acquireRelease(
+  Effect.promise(async () => {
+    const requests: Headers[] = []
+    const protocol = new Server({ name: "headers", version: "1.0.0" }, { capabilities: { tools: {} } })
+    protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] }))
+    const transport = new WebStandardStreamableHTTPServerTransport({
+      sessionIdGenerator: () => crypto.randomUUID(),
+      enableJsonResponse: true,
+    })
+    await protocol.connect(transport)
+    const http = Bun.serve({
+      port: 0,
+      fetch(request) {
+        requests.push(new Headers(request.headers))
+        return transport.handleRequest(request)
+      },
+    })
+    return {
+      requests,
+      url: http.url.toString(),
+      close: async () => {
+        await http.stop(true)
+        await protocol.close()
+      },
     }
-    async start() {
-      throw new Error("Mock transport cannot connect")
-    }
-  },
-}))
-
-beforeEach(() => {
-  transportCalls.length = 0
-})
-
-// Import MCP after mocking
-const { MCP } = await import("../../src/mcp/index")
-const it = testEffect(LayerNode.compile(MCP.node))
+  }),
+  (server) => Effect.promise(server.close),
+)
 
 describe("mcp.headers", () => {
   it.instance("headers are passed to transports when oauth is enabled (default)", () =>
     Effect.gen(function* () {
+      const server = yield* serve
       const mcp = yield* MCP.Service
-      yield* mcp
-        .add("test-server", {
-          type: "remote",
-          url: "https://example.com/mcp",
-          headers: {
-            Authorization: "Bearer test-token",
-            "X-Custom-Header": "custom-value",
-          },
-        })
-        .pipe(Effect.catch(() => Effect.void))
-
-      // Both transports should have been created with headers
-      expect(transportCalls.length).toBeGreaterThanOrEqual(1)
-
-      for (const call of transportCalls) {
-        expect(call.options.requestInit).toBeDefined()
-        expect(call.options.requestInit?.headers).toEqual({
+      const result = yield* mcp.add("test-server", {
+        type: "remote",
+        url: server.url,
+        headers: {
           Authorization: "Bearer test-token",
           "X-Custom-Header": "custom-value",
-        })
-        // OAuth should be enabled by default, so authProvider should exist
-        expect(call.options.authProvider).toBeDefined()
+        },
+      })
+
+      expect(result.status).toMatchObject({ "test-server": { status: "connected" } })
+      expect(server.requests.length).toBeGreaterThan(0)
+      for (const headers of server.requests) {
+        expect(headers.get("authorization")).toBe("Bearer test-token")
+        expect(headers.get("x-custom-header")).toBe("custom-value")
       }
     }),
   )
 
   it.instance("headers are passed to transports when oauth is explicitly disabled", () =>
     Effect.gen(function* () {
+      const server = yield* serve
       const mcp = yield* MCP.Service
-      yield* mcp
-        .add("test-server-no-oauth", {
-          type: "remote",
-          url: "https://example.com/mcp",
-          oauth: false,
-          headers: {
-            Authorization: "Bearer test-token",
-          },
-        })
-        .pipe(Effect.catch(() => Effect.void))
-
-      expect(transportCalls.length).toBeGreaterThanOrEqual(1)
-
-      for (const call of transportCalls) {
-        expect(call.options.requestInit).toBeDefined()
-        expect(call.options.requestInit?.headers).toEqual({
+      const result = yield* mcp.add("test-server-no-oauth", {
+        type: "remote",
+        url: server.url,
+        oauth: false,
+        headers: {
           Authorization: "Bearer test-token",
-        })
-        // OAuth is disabled, so no authProvider
-        expect(call.options.authProvider).toBeUndefined()
+        },
+      })
+
+      expect(result.status).toMatchObject({ "test-server-no-oauth": { status: "connected" } })
+      expect(server.requests.length).toBeGreaterThan(0)
+      for (const headers of server.requests) {
+        expect(headers.get("authorization")).toBe("Bearer test-token")
       }
     }),
   )
 
   it.instance("no requestInit when headers are not provided", () =>
     Effect.gen(function* () {
+      const server = yield* serve
       const mcp = yield* MCP.Service
-      yield* mcp
-        .add("test-server-no-headers", {
-          type: "remote",
-          url: "https://example.com/mcp",
-        })
-        .pipe(Effect.catch(() => Effect.void))
-
-      expect(transportCalls.length).toBeGreaterThanOrEqual(1)
+      const result = yield* mcp.add("test-server-no-headers", {
+        type: "remote",
+        url: server.url,
+      })
 
-      for (const call of transportCalls) {
-        // No headers means requestInit should be undefined
-        expect(call.options.requestInit).toBeUndefined()
+      expect(result.status).toMatchObject({ "test-server-no-headers": { status: "connected" } })
+      expect(server.requests.length).toBeGreaterThan(0)
+      for (const headers of server.requests) {
+        expect(headers.has("authorization")).toBe(false)
+        expect(headers.has("x-custom-header")).toBe(false)
       }
     }),
   )

+ 501 - 1245
packages/opencode/test/mcp/lifecycle.test.ts

@@ -1,1304 +1,560 @@
 import path from "node:path"
 import { pathToFileURL } from "node:url"
-import { expect, mock, beforeEach } from "bun:test"
-import { ListRootsRequestSchema, ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js"
+import { expect } from "bun:test"
+import { Server } from "@modelcontextprotocol/sdk/server/index.js"
+import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
+import {
+  GetPromptRequestSchema,
+  ListPromptsRequestSchema,
+  ListResourcesRequestSchema,
+  ListResourceTemplatesRequestSchema,
+  ListToolsRequestSchema,
+  ReadResourceRequestSchema,
+  type ServerCapabilities,
+  type Tool,
+} from "@modelcontextprotocol/sdk/types.js"
 import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { Cause, Effect, Exit } from "effect"
 import type { MCP as MCPNS } from "../../src/mcp/index"
-import { testEffect } from "../lib/effect"
+import { MCP } from "../../src/mcp/index"
+import { McpOAuthCallback } from "../../src/mcp/oauth-callback"
 import { TestInstance } from "../fixture/fixture"
+import { pollWithTimeout, testEffect } from "../lib/effect"
 
-// --- Mock infrastructure ---
+const it = testEffect(LayerNode.compile(MCP.node))
+const stdioFixture = path.join(import.meta.dir, "../fixture/mcp-lifecycle-stdio.ts")
+
+type Page<T> = { items: T[]; nextCursor?: string }
 
-// Per-client state for controlling mock behavior
-interface MockClientState {
-  capabilities: { tools?: object; prompts?: object; resources?: object }
-  capabilitiesShouldThrow: boolean
-  instructions?: string
-  tools: Array<{ name: string; description?: string; inputSchema: object; outputSchema?: object }>
-  listToolsCalls: number
-  listPromptsCalls: number
-  listResourcesCalls: number
-  listResourceTemplatesCalls: number
-  getPromptTimeout?: number
-  readResourceTimeout?: number
-  requestCalls: number
-  listToolsShouldFail: boolean
-  listToolsError: string
-  listPromptsShouldFail: boolean
-  listResourcesShouldFail: boolean
+interface LifecycleServerState {
+  tools: Tool[]
   prompts: Array<{ name: string; description?: string }>
   resources: Array<{ name: string; uri: string; description?: string }>
   resourceTemplates: Array<{ name: string; uriTemplate: string; description?: string }>
-  toolPages: Record<
-    string,
-    {
-      tools: Array<{ name: string; description?: string; inputSchema: object; outputSchema?: object }>
-      nextCursor?: string
-    }
-  >
-  promptPages: Record<string, { prompts: Array<{ name: string; description?: string }>; nextCursor?: string }>
-  resourcePages: Record<
-    string,
-    { resources: Array<{ name: string; uri: string; description?: string }>; nextCursor?: string }
-  >
-  resourceTemplatePages: Record<
-    string,
-    { resourceTemplates: Array<{ name: string; uriTemplate: string; description?: string }>; nextCursor?: string }
-  >
-  closed: boolean
-  clientOptions?: { capabilities?: { roots?: { listChanged?: boolean } } }
-  requestHandlers: Map<unknown, (...args: any[]) => Promise<any>>
-  notificationHandlers: Map<unknown, (...args: any[]) => any>
-}
-
-const clientStates = new Map<string, MockClientState>()
-let lastCreatedClientName: string | undefined
-let connectShouldFail = false
-let connectShouldHang = false
-let connectError = "Mock transport cannot connect"
-// Tracks how many Client instances were created (detects leaks)
-let clientCreateCount = 0
-// Tracks how many times transport.close() is called across all mock transports
-let transportCloseCount = 0
-// Captures the opts passed to each MockStdioTransport, keyed by lastCreatedClientName
-const stdioOptsByName = new Map<string, any>()
-
-function getOrCreateClientState(name?: string): MockClientState {
-  const key = name ?? "default"
-  let state = clientStates.get(key)
-  if (!state) {
-    state = {
-      capabilities: { tools: {}, prompts: {}, resources: {} },
-      capabilitiesShouldThrow: false,
-      tools: [{ name: "test_tool", description: "A test tool", inputSchema: { type: "object", properties: {} } }],
-      listToolsCalls: 0,
-      listPromptsCalls: 0,
-      listResourcesCalls: 0,
-      listResourceTemplatesCalls: 0,
-      requestCalls: 0,
-      listToolsShouldFail: false,
-      listToolsError: "listTools failed",
-      listPromptsShouldFail: false,
-      listResourcesShouldFail: false,
-      prompts: [],
-      resources: [],
-      resourceTemplates: [],
-      toolPages: {},
-      promptPages: {},
-      resourcePages: {},
-      resourceTemplatePages: {},
-      closed: false,
-      requestHandlers: new Map(),
-      notificationHandlers: new Map(),
-    }
-    clientStates.set(key, state)
-  }
-  return state
-}
-
-// Mock transport that succeeds or fails based on connectShouldFail / connectShouldHang
-class MockStdioTransport {
-  stderr: null = null
-  pid = 12345
-  constructor(opts: any) {
-    if (lastCreatedClientName) stdioOptsByName.set(lastCreatedClientName, opts)
-  }
-  async start() {
-    if (connectShouldHang) return new Promise<void>(() => {}) // never resolves
-    if (connectShouldFail) throw new Error(connectError)
-  }
-  async close() {
-    transportCloseCount++
-  }
-}
-
-class MockStreamableHTTP {
-  // oxlint-disable-next-line no-useless-constructor
-  constructor(_url: URL, _opts?: any) {}
-  async start() {
-    if (connectShouldHang) return new Promise<void>(() => {}) // never resolves
-    if (connectShouldFail) throw new Error(connectError)
-  }
-  async close() {
-    transportCloseCount++
-  }
-  async finishAuth() {}
-}
-
-class MockSSE {
-  // oxlint-disable-next-line no-useless-constructor
-  constructor(_url: URL, _opts?: any) {}
-  async start() {
-    if (connectShouldHang) return new Promise<void>(() => {}) // never resolves
-    if (connectShouldFail) throw new Error(connectError)
-  }
-  async close() {
-    transportCloseCount++
-  }
+  toolPages?: Record<string, Page<Tool>>
+  promptPages?: Record<string, Page<{ name: string; description?: string }>>
+  resourcePages?: Record<string, Page<{ name: string; uri: string; description?: string }>>
+  resourceTemplatePages?: Record<string, Page<{ name: string; uriTemplate: string; description?: string }>>
+  listToolsError?: string
+  requestDelay?: number
+  roots?: Array<{ uri: string; name?: string }>
+  requests: string[]
+  aborted: number
 }
 
-void mock.module("@modelcontextprotocol/sdk/client/stdio.js", () => ({
-  StdioClientTransport: MockStdioTransport,
-}))
-
-void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
-  StreamableHTTPClientTransport: MockStreamableHTTP,
-}))
-
-void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({
-  SSEClientTransport: MockSSE,
-}))
-
-void mock.module("@modelcontextprotocol/sdk/client/auth.js", () => ({
-  UnauthorizedError: class extends Error {
-    constructor() {
-      super("Unauthorized")
-    }
-  },
-}))
-
-// Mock Client that delegates to per-name MockClientState
-void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
-  Client: class MockClient {
-    _state!: MockClientState
-    transport: any
-
-    constructor(_info: any, options?: MockClientState["clientOptions"]) {
-      clientCreateCount++
-      this._state = getOrCreateClientState(lastCreatedClientName)
-      this._state.clientOptions = options
-    }
-
-    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)
-    }
-
-    setRequestHandler(schema: unknown, handler: (...args: any[]) => Promise<any>) {
-      this._state.requestHandlers.set(schema, handler)
-    }
-
-    setNotificationHandler(schema: unknown, handler: (...args: any[]) => any) {
-      this._state?.notificationHandlers.set(schema, handler)
-    }
-
-    getServerCapabilities() {
-      if (this._state?.capabilitiesShouldThrow) throw new Error("capability discovery failed")
-      return this._state?.capabilities
-    }
-
-    getInstructions() {
-      return this._state?.instructions
-    }
-
-    async listTools(params?: { cursor?: string }) {
-      if (this._state) this._state.listToolsCalls++
-      if (this._state?.listToolsShouldFail) {
-        throw new Error(this._state.listToolsError)
+function lifecycleServer(input?: { capabilities?: ServerCapabilities; instructions?: string; requestRoots?: boolean }) {
+  const capabilities = input?.capabilities ?? { tools: {}, prompts: {}, resources: {} }
+  return Effect.acquireRelease(
+    Effect.promise(async () => {
+      const state: LifecycleServerState = {
+        tools: [{ name: "test_tool", description: "A test tool", inputSchema: { type: "object", properties: {} } }],
+        prompts: [],
+        resources: [],
+        resourceTemplates: [],
+        requests: [],
+        aborted: 0,
       }
-      const page = this._state?.toolPages[params === undefined ? "initial" : (params.cursor ?? "")]
-      if (page) return page
-      return { tools: this._state?.tools ?? [] }
-    }
 
-    async request(
-      request: { method: string; params?: { cursor?: string } },
-      schema: { parse: (value: unknown) => unknown },
-    ) {
-      if (this._state) this._state.requestCalls++
-      if (request.method === "tools/list") {
-        return schema.parse(
-          this._state?.toolPages[request.params === undefined ? "initial" : (request.params.cursor ?? "")] ?? {
-            tools: this._state?.tools ?? [],
-          },
+      const makeProtocol = async () => {
+        const protocol = new Server(
+          { name: "mcp-lifecycle", version: "1.0.0" },
+          { capabilities, instructions: input?.instructions },
         )
-      }
-      throw new Error(`unsupported request: ${request.method}`)
-    }
-
-    async listPrompts(params?: { cursor?: string }) {
-      if (this._state) this._state.listPromptsCalls++
-      if (this._state?.listPromptsShouldFail) {
-        throw new Error("listPrompts failed")
-      }
-      const page = this._state?.promptPages[params === undefined ? "initial" : (params.cursor ?? "")]
-      if (page) return page
-      return { prompts: this._state?.prompts ?? [] }
-    }
-
-    async listResources(params?: { cursor?: string }) {
-      if (this._state) this._state.listResourcesCalls++
-      if (this._state?.listResourcesShouldFail) {
-        throw new Error("listResources failed")
-      }
-      const page = this._state?.resourcePages[params === undefined ? "initial" : (params.cursor ?? "")]
-      if (page) return page
-      return { resources: this._state?.resources ?? [] }
-    }
-
-    async listResourceTemplates(params?: { cursor?: string }) {
-      if (this._state) this._state.listResourceTemplatesCalls++
-      const page = this._state?.resourceTemplatePages[params === undefined ? "initial" : (params.cursor ?? "")]
-      if (page) return page
-      return { resourceTemplates: this._state?.resourceTemplates ?? [] }
-    }
-
-    async getPrompt(_params: unknown, options?: { timeout?: number }) {
-      if (this._state) this._state.getPromptTimeout = options?.timeout
-      return { messages: [] }
-    }
-
-    async readResource(params: { uri: string }, options?: { timeout?: number }) {
-      if (this._state) this._state.readResourceTimeout = options?.timeout
-      return { contents: [{ uri: params.uri, text: "test" }] }
-    }
-
-    async close() {
-      if (this._state) this._state.closed = true
-    }
-  },
-}))
-
-beforeEach(() => {
-  clientStates.clear()
-  lastCreatedClientName = undefined
-  connectShouldFail = false
-  connectShouldHang = false
-  connectError = "Mock transport cannot connect"
-  clientCreateCount = 0
-  transportCloseCount = 0
-})
-
-// Import after mocks
-const { MCP } = await import("../../src/mcp/index")
-const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback")
-
-const it = testEffect(LayerNode.compile(MCP.node))
-
-function statusName(status: Record<string, MCPNS.Status> | MCPNS.Status, server: string) {
-  if ("status" in status) return status.status
-  return status[server]?.status
-}
-
-it.instance(
-  "advertises and lists the instance directory as its root",
-  () =>
-    MCP.Service.use((mcp: MCPNS.Interface) =>
-      Effect.gen(function* () {
-        const { directory } = yield* TestInstance
-        lastCreatedClientName = "roots"
-        yield* mcp.add("roots", { type: "local", command: ["echo", "test"] })
-
-        const state = getOrCreateClientState("roots")
-        expect(state.clientOptions?.capabilities?.roots).toEqual({})
-        expect(state.clientOptions?.capabilities?.roots?.listChanged).toBeUndefined()
-
-        const handler = state.requestHandlers.get(ListRootsRequestSchema)
-        expect(handler).toBeDefined()
-        const result = yield* Effect.promise(() => handler?.() ?? Promise.reject(new Error("roots handler missing")))
-        expect(result).toEqual({ roots: [{ uri: pathToFileURL(directory).href }] })
-      }),
-    ),
-  { config: { mcp: {} } },
-)
-
-it.instance(
-  "local mcp cwd resolves relative paths against instance directory",
-  () =>
-    MCP.Service.use((mcp: MCPNS.Interface) =>
-      Effect.gen(function* () {
-        const { directory } = yield* TestInstance
-        lastCreatedClientName = "rel-cwd"
-        yield* mcp.add("rel-cwd", { type: "local", command: ["echo", "test"], cwd: "plugins/sub" })
-        expect(stdioOptsByName.get("rel-cwd")?.cwd).toBe(path.resolve(directory, "plugins/sub"))
-      }),
-    ),
-  { config: { mcp: {} } },
-)
-
-// ========================================================================
-// Test: tools() are cached after connect
-// ========================================================================
-
-it.instance(
-  "tools() reuses cached tool definitions after connect",
-  () =>
-    MCP.Service.use((mcp: MCPNS.Interface) =>
-      Effect.gen(function* () {
-        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 = yield* 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 = yield* mcp.tools()
-        const toolsB = yield* mcp.tools()
-        expect(Object.keys(toolsA).length).toBeGreaterThan(0)
-        expect(Object.keys(toolsB).length).toBeGreaterThan(0)
-        expect(serverState.listToolsCalls).toBe(1)
-      }),
-    ),
-  { config: { mcp: {} } },
-)
-
-it.instance(
-  "instructions() returns connected server instructions with tool names",
-  () =>
-    MCP.Service.use((mcp: MCPNS.Interface) =>
-      Effect.gen(function* () {
-        lastCreatedClientName = "guide-server"
-        const serverState = getOrCreateClientState("guide-server")
-        serverState.instructions = "Use lookup before mutate."
-
-        yield* mcp.add("guide-server", {
-          type: "local",
-          command: ["echo", "test"],
+        const transport = new WebStandardStreamableHTTPServerTransport({
+          sessionIdGenerator: () => crypto.randomUUID(),
+          enableJsonResponse: true,
         })
 
-        expect(yield* mcp.instructions()).toContainEqual({
-          name: "guide-server",
-          instructions: "Use lookup before mutate.",
-          tools: ["guide-server_test_tool"],
-        })
-      }),
-    ),
-  { config: { mcp: {} } },
-)
-
-it.instance(
-  "instructions() omits empty and disconnected server instructions",
-  () =>
-    MCP.Service.use((mcp: MCPNS.Interface) =>
-      Effect.gen(function* () {
-        lastCreatedClientName = "temporary-server"
-        getOrCreateClientState("temporary-server").instructions = "Temporary guidance."
-
-        yield* mcp.add("temporary-server", {
-          type: "local",
-          command: ["echo", "test"],
-        })
-        yield* mcp.disconnect("temporary-server")
-
-        lastCreatedClientName = "blank-server"
-        getOrCreateClientState("blank-server").instructions = "   "
-
-        yield* mcp.add("blank-server", {
-          type: "local",
-          command: ["echo", "test"],
-        })
-
-        const instructions = yield* mcp.instructions()
-        expect(instructions.some((item) => item.name === "temporary-server")).toBe(false)
-        expect(instructions.some((item) => item.name === "blank-server")).toBe(false)
-      }),
-    ),
-  { config: { mcp: {} } },
-)
-
-it.instance(
-  "follows cursors when listing tools, prompts, and resources",
-  () =>
-    MCP.Service.use((mcp: MCPNS.Interface) =>
-      Effect.gen(function* () {
-        lastCreatedClientName = "paged-server"
-        const serverState = getOrCreateClientState("paged-server")
-        serverState.toolPages = {
-          initial: {
-            tools: [{ name: "tool-one", inputSchema: { type: "object", properties: {} } }],
-            nextCursor: "tools-2",
-          },
-          "tools-2": { tools: [{ name: "tool-two", inputSchema: { type: "object", properties: {} } }] },
+        if (capabilities.tools) {
+          protocol.setRequestHandler(ListToolsRequestSchema, (request) => {
+            if (state.listToolsError) throw new Error(state.listToolsError)
+            const page = state.toolPages?.[request.params?.cursor ?? "initial"]
+            return Promise.resolve({ tools: page?.items ?? state.tools, nextCursor: page?.nextCursor })
+          })
         }
-        serverState.promptPages = {
-          initial: { prompts: [{ name: "prompt-one" }], nextCursor: "prompts-2" },
-          "prompts-2": { prompts: [{ name: "prompt-two" }] },
+        if (capabilities.prompts) {
+          protocol.setRequestHandler(ListPromptsRequestSchema, (request) => {
+            const page = state.promptPages?.[request.params?.cursor ?? "initial"]
+            return Promise.resolve({ prompts: page?.items ?? state.prompts, nextCursor: page?.nextCursor })
+          })
+          protocol.setRequestHandler(GetPromptRequestSchema, async () => {
+            if (state.requestDelay) await Bun.sleep(state.requestDelay)
+            return { messages: [{ role: "user", content: { type: "text", text: "prompt result" } }] }
+          })
         }
-        serverState.resourcePages = {
-          initial: { resources: [{ name: "resource-one", uri: "test://one" }], nextCursor: "resources-2" },
-          "resources-2": { resources: [{ name: "resource-two", uri: "test://two" }] },
+        if (capabilities.resources) {
+          protocol.setRequestHandler(ListResourcesRequestSchema, (request) => {
+            const page = state.resourcePages?.[request.params?.cursor ?? "initial"]
+            return Promise.resolve({ resources: page?.items ?? state.resources, nextCursor: page?.nextCursor })
+          })
+          protocol.setRequestHandler(ListResourceTemplatesRequestSchema, (request) => {
+            const page = state.resourceTemplatePages?.[request.params?.cursor ?? "initial"]
+            return Promise.resolve({
+              resourceTemplates: page?.items ?? state.resourceTemplates,
+              nextCursor: page?.nextCursor,
+            })
+          })
+          protocol.setRequestHandler(ReadResourceRequestSchema, async (request) => {
+            if (state.requestDelay) await Bun.sleep(state.requestDelay)
+            return { contents: [{ uri: request.params.uri, text: "resource result" }] }
+          })
         }
-        serverState.resourceTemplatePages = {
-          initial: {
-            resourceTemplates: [{ name: "template-one", uriTemplate: "test://one/{id}" }],
-            nextCursor: "resource-templates-2",
-          },
-          "resource-templates-2": { resourceTemplates: [{ name: "template-two", uriTemplate: "test://two/{id}" }] },
-        }
-
-        yield* mcp.add("paged-server", {
-          type: "local",
-          command: ["echo", "test"],
-        })
-
-        expect(Object.keys(yield* mcp.tools())).toEqual(["paged-server_tool-one", "paged-server_tool-two"])
-        expect(Object.keys(yield* mcp.prompts())).toEqual(["paged-server:prompt-one", "paged-server:prompt-two"])
-        expect(Object.keys(yield* mcp.resources())).toEqual(["paged-server:test://one", "paged-server:test://two"])
-        expect(Object.keys(yield* mcp.resourceTemplates())).toEqual([
-          "paged-server:test://one/{id}",
-          "paged-server:test://two/{id}",
-        ])
-        expect(serverState.listToolsCalls).toBe(2)
-        expect(serverState.listPromptsCalls).toBe(2)
-        expect(serverState.listResourcesCalls).toBe(2)
-        expect(serverState.listResourceTemplatesCalls).toBe(2)
-      }),
-    ),
-  { config: { mcp: {} } },
-)
 
-it.instance(
-  "stops listing when a server repeats a cursor",
-  () =>
-    MCP.Service.use((mcp: MCPNS.Interface) =>
-      Effect.gen(function* () {
-        lastCreatedClientName = "looping-server"
-        const serverState = getOrCreateClientState("looping-server")
-        serverState.toolPages = {
-          initial: { tools: [], nextCursor: "repeat" },
-          repeat: { tools: [], nextCursor: "repeat" },
-        }
-
-        yield* mcp.add("looping-server", {
-          type: "local",
-          command: ["echo", "test"],
-        })
-
-        expect(serverState.listToolsCalls).toBe(2)
-        expect(yield* mcp.tools()).toEqual({})
-      }),
-    ),
-  { config: { mcp: {} } },
-)
-
-it.instance(
-  "follows empty cursors",
-  () =>
-    MCP.Service.use((mcp: MCPNS.Interface) =>
-      Effect.gen(function* () {
-        lastCreatedClientName = "empty-cursor-server"
-        const serverState = getOrCreateClientState("empty-cursor-server")
-        serverState.promptPages = {
-          initial: { prompts: [{ name: "prompt-one" }], nextCursor: "" },
-          "": { prompts: [{ name: "prompt-two" }] },
+        protocol.oninitialized = () => {
+          if (!input?.requestRoots) return
+          if (!protocol.getClientCapabilities()?.roots) return
+          void Bun.sleep(25)
+            .then(() => protocol.listRoots())
+            .then((result) => {
+              state.roots = result.roots
+            })
+            .catch(() => {})
         }
+        await protocol.connect(transport)
+        return { protocol, transport }
+      }
 
-        yield* mcp.add("empty-cursor-server", {
-          type: "local",
-          command: ["echo", "test"],
-        })
-
-        expect(Object.keys(yield* mcp.prompts())).toEqual([
-          "empty-cursor-server:prompt-one",
-          "empty-cursor-server:prompt-two",
-        ])
-        expect(serverState.listPromptsCalls).toBe(2)
-      }),
-    ),
-  { config: { mcp: {} } },
-)
-
-// ========================================================================
-// Test: tool change notifications refresh the cache
-// ========================================================================
-
-it.instance(
-  "tool change notifications refresh cached tool definitions",
-  () =>
-    MCP.Service.use((mcp: MCPNS.Interface) =>
-      Effect.gen(function* () {
-        lastCreatedClientName = "status-server"
-        const serverState = getOrCreateClientState("status-server")
-
-        yield* mcp.add("status-server", {
-          type: "local",
-          command: ["echo", "test"],
-        })
-
-        const before = yield* 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 = serverState.notificationHandlers.get(ToolListChangedNotificationSchema)
-        expect(handler).toBeDefined()
-        yield* Effect.promise(() => handler?.())
-
-        const after = yield* 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)
-      }),
-    ),
-  { config: { mcp: {} } },
-)
-
-// ========================================================================
-// Test: connect() / disconnect() lifecycle
-// ========================================================================
-
-it.instance(
-  "disconnect sets status to disabled and removes client",
-  () =>
-    MCP.Service.use((mcp: MCPNS.Interface) =>
-      Effect.gen(function* () {
-        lastCreatedClientName = "disc-server"
-        getOrCreateClientState("disc-server")
-
-        yield* mcp.add("disc-server", {
-          type: "local",
-          command: ["echo", "test"],
-        })
-
-        const statusBefore = yield* mcp.status()
-        expect(statusBefore["disc-server"]?.status).toBe("connected")
-
-        yield* mcp.disconnect("disc-server")
-
-        const statusAfter = yield* mcp.status()
-        expect(statusAfter["disc-server"]?.status).toBe("disabled")
-
-        const tools = yield* mcp.tools()
-        const serverTools = Object.keys(tools).filter((k) => k.startsWith("disc-server"))
-        expect(serverTools.length).toBe(0)
-      }),
-    ),
-  {
-    config: {
-      mcp: {
-        "disc-server": {
-          type: "local",
-          command: ["echo", "test"],
+      let current = await makeProtocol()
+      const http = Bun.serve({
+        port: 0,
+        fetch(request) {
+          state.requests.push(request.method)
+          request.signal.addEventListener("abort", () => state.aborted++)
+          return current.transport.handleRequest(request)
         },
-      },
-    },
-  },
-)
-
-it.instance(
-  "connect() after disconnect() re-establishes the server",
-  () =>
-    MCP.Service.use((mcp: MCPNS.Interface) =>
-      Effect.gen(function* () {
-        lastCreatedClientName = "reconn-server"
-        const serverState = getOrCreateClientState("reconn-server")
-        serverState.tools = [
-          { name: "my_tool", description: "a tool", inputSchema: { type: "object", properties: {} } },
-        ]
-
-        yield* mcp.add("reconn-server", {
-          type: "local",
-          command: ["echo", "test"],
-        })
-
-        yield* mcp.disconnect("reconn-server")
-        expect((yield* mcp.status())["reconn-server"]?.status).toBe("disabled")
-
-        yield* mcp.connect("reconn-server")
-        expect((yield* mcp.status())["reconn-server"]?.status).toBe("connected")
-
-        const tools = yield* mcp.tools()
-        expect(Object.keys(tools).some((k) => k.includes("my_tool"))).toBe(true)
-      }),
-    ),
-  {
-    config: {
-      mcp: {
-        "reconn-server": {
-          type: "local",
-          command: ["echo", "test"],
+      })
+
+      return {
+        state,
+        url: http.url.toString(),
+        sendToolListChanged: () => current.protocol.sendToolListChanged(),
+        restart: async () => {
+          current = await makeProtocol()
         },
-      },
-    },
-  },
-)
-
-// ========================================================================
-// Test: add() closes existing client before replacing
-// ========================================================================
-
-it.instance(
-  "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".
-  () =>
-    MCP.Service.use((mcp: MCPNS.Interface) =>
-      Effect.gen(function* () {
-        lastCreatedClientName = "replace-server"
-        const firstState = getOrCreateClientState("replace-server")
-
-        yield* 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
-        yield* mcp.add("replace-server", {
-          type: "local",
-          command: ["echo", "test"],
-        })
-
-        expect(firstState.closed).toBe(true)
-        expect(secondState.closed).toBe(false)
-      }),
-    ),
-  { config: { mcp: {} } },
-)
-
-// ========================================================================
-// Test: state init with mixed success/failure
-// ========================================================================
-
-it.instance(
-  "init connects available servers even when one fails",
-  () =>
-    MCP.Service.use((mcp: MCPNS.Interface) =>
-      Effect.gen(function* () {
-        // 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"
-        yield* mcp.add("good-server", {
-          type: "local",
-          command: ["echo", "good"],
-        })
-
-        // Add bad server - should fail but not affect good server
-        lastCreatedClientName = "bad-server"
-        yield* mcp.add("bad-server", {
-          type: "local",
-          command: ["echo", "bad"],
-        })
-
-        const status = yield* 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 = yield* mcp.tools()
-        expect(Object.keys(tools).some((k) => k.includes("good_tool"))).toBe(true)
-      }),
-    ),
-  {
-    config: {
-      mcp: {
-        "good-server": {
-          type: "local",
-          command: ["echo", "good"],
-        },
-        "bad-server": {
-          type: "local",
-          command: ["echo", "bad"],
+        close: async () => {
+          await current.protocol.close().catch(() => {})
+          http.stop(true)
         },
-      },
-    },
-  },
-)
-
-it.instance(
-  "returns failed and closes the client when SDK initialization throws",
-  () =>
-    MCP.Service.use((mcp: MCPNS.Interface) =>
-      Effect.gen(function* () {
-        lastCreatedClientName = "defective-server"
-        const serverState = getOrCreateClientState("defective-server")
-        serverState.capabilitiesShouldThrow = true
-
-        const result = yield* mcp.add("defective-server", {
-          type: "local",
-          command: ["echo", "test"],
-        })
-
-        expect(statusName(result.status, "defective-server")).toBe("failed")
-        expect((yield* mcp.status())["defective-server"]).toEqual({
-          status: "failed",
-          error: "capability discovery failed",
-        })
-        expect(serverState.closed).toBe(true)
-      }),
-    ),
-  { config: { mcp: {} } },
-)
-
-it.instance(
-  "falls back when MCP output schema refs fail SDK tool discovery",
-  () =>
-    MCP.Service.use((mcp: MCPNS.Interface) =>
-      Effect.gen(function* () {
-        lastCreatedClientName = "stitch-like-server"
-        const serverState = getOrCreateClientState("stitch-like-server")
-        serverState.listToolsShouldFail = true
-        serverState.listToolsError = "can't resolve reference #/$defs/ScreenInstance from id #"
-        serverState.tools = [
-          {
-            name: "render_screen",
-            description: "renders a screen",
-            inputSchema: { type: "object", properties: { prompt: { type: "string" } }, required: ["prompt"] },
-            outputSchema: { type: "object", properties: { screen: { $ref: "#/$defs/ScreenInstance" } } },
-          },
-        ]
-
-        const addResult = yield* mcp.add("stitch-like-server", {
-          type: "local",
-          command: ["echo", "test"],
-        })
-
-        expect(statusName(addResult.status, "stitch-like-server")).toBe("connected")
-
-        const tools = yield* mcp.tools()
-        expect(Object.keys(tools).some((key) => key.includes("render_screen"))).toBe(true)
-        expect(serverState.listToolsCalls).toBe(1)
-        expect(serverState.requestCalls).toBe(1)
-      }),
-    ),
-  { config: { mcp: {} } },
-)
-
-it.instance(
-  "does not fall back for non-schema MCP tool discovery errors",
-  () =>
-    MCP.Service.use((mcp: MCPNS.Interface) =>
-      Effect.gen(function* () {
-        lastCreatedClientName = "broken-server"
-        const serverState = getOrCreateClientState("broken-server")
-        serverState.listToolsShouldFail = true
-        serverState.listToolsError = "transport closed"
-
-        const addResult = yield* mcp.add("broken-server", {
-          type: "local",
-          command: ["echo", "test"],
-        })
-
-        expect(statusName(addResult.status, "broken-server")).toBe("failed")
-        expect(serverState.listToolsCalls).toBe(1)
-        expect(serverState.requestCalls).toBe(0)
-      }),
-    ),
-  { config: { mcp: {} } },
-)
-
-// ========================================================================
-// Test: disabled server via config
-// ========================================================================
-
-it.instance(
-  "disabled server is marked as disabled without attempting connection",
-  () =>
-    MCP.Service.use((mcp: MCPNS.Interface) =>
-      Effect.gen(function* () {
-        const countBefore = clientCreateCount
-
-        yield* 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 = yield* mcp.status()
-        expect(status["disabled-server"]?.status).toBe("disabled")
-      }),
-    ),
-  {
-    config: {
-      mcp: {
-        "disabled-server": {
-          type: "local",
-          command: ["echo", "test"],
-          enabled: false,
-        },
-      },
-    },
-  },
-)
-
-// ========================================================================
-// Test: prompts() and resources()
-// ========================================================================
-
-it.instance(
-  "prompts() returns prompts from connected servers",
-  () =>
-    MCP.Service.use((mcp: MCPNS.Interface) =>
-      Effect.gen(function* () {
-        lastCreatedClientName = "prompt-server"
-        const serverState = getOrCreateClientState("prompt-server")
-        serverState.prompts = [{ name: "my-prompt", description: "A test prompt" }]
-
-        yield* mcp.add("prompt-server", {
-          type: "local",
-          command: ["echo", "test"],
-        })
+      }
+    }),
+    (server) => Effect.promise(server.close),
+  )
+}
 
-        const prompts = yield* 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")
-      }),
-    ),
-  {
-    config: {
-      mcp: {
-        "prompt-server": {
-          type: "local",
-          command: ["echo", "test"],
+function hangingLifecycleServer() {
+  return Effect.acquireRelease(
+    Effect.promise(async () => {
+      const protocol = new Server({ name: "mcp-lifecycle-hanging", version: "1.0.0" }, { capabilities: { tools: {} } })
+      protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] }))
+      const transport = new WebStandardStreamableHTTPServerTransport({
+        sessionIdGenerator: () => crypto.randomUUID(),
+        enableJsonResponse: true,
+      })
+      await protocol.connect(transport)
+      const requests: string[] = []
+      let aborted = 0
+      const http = Bun.serve({
+        port: 0,
+        fetch(request) {
+          requests.push(request.method)
+          request.signal.addEventListener("abort", () => aborted++)
+          return new Promise<Response>(() => {})
         },
-      },
-    },
-  },
-)
-
-it.instance(
-  "resources() returns resources from connected servers",
-  () =>
-    MCP.Service.use((mcp: MCPNS.Interface) =>
-      Effect.gen(function* () {
-        lastCreatedClientName = "resource-server"
-        const serverState = getOrCreateClientState("resource-server")
-        serverState.resources = [
-          { name: "my-resource", uri: "file:///test.txt", description: "A test resource" },
-          { name: "my-resource", uri: "ui://component-state", description: "A second resource with same name" },
-        ]
-
-        yield* mcp.add("resource-server", {
-          type: "local",
-          command: ["echo", "test"],
-        })
-
-        const resources = yield* mcp.resources()
-        expect(Object.keys(resources)).toEqual([
-          "resource-server:file:///test.txt",
-          "resource-server:ui://component-state",
-        ])
-      }),
-    ),
-  {
-    config: {
-      mcp: {
-        "resource-server": {
-          type: "local",
-          command: ["echo", "test"],
+      })
+      return {
+        requests,
+        aborted: () => aborted,
+        url: http.url.toString(),
+        close: async () => {
+          await protocol.close().catch(() => {})
+          http.stop(true)
         },
-      },
-    },
-  },
-)
-
-it.instance(
-  "uses per-server timeouts for prompt and resource requests",
-  () =>
-    MCP.Service.use((mcp: MCPNS.Interface) =>
-      Effect.gen(function* () {
-        lastCreatedClientName = "timeout-server"
-        const serverState = getOrCreateClientState("timeout-server")
-
-        yield* mcp.add("timeout-server", {
-          type: "local",
-          command: ["echo", "test"],
-          timeout: 2500,
-        })
-        yield* mcp.getPrompt("timeout-server", "test")
-        yield* mcp.readResource("timeout-server", "test://resource")
-
-        expect(serverState.getPromptTimeout).toBe(2500)
-        expect(serverState.readResourceTimeout).toBe(2500)
-      }),
-    ),
-  { config: { mcp: {}, experimental: { mcp_timeout: 5000 } } },
-)
-
-it.instance(
-  "resource-only servers connect without listing tools",
-  () =>
-    MCP.Service.use((mcp: MCPNS.Interface) =>
-      Effect.gen(function* () {
-        lastCreatedClientName = "resource-only-server"
-        const serverState = getOrCreateClientState("resource-only-server")
-        serverState.capabilities = { resources: {} }
-        serverState.resources = [{ name: "docs", uri: "docs://readme" }]
-
-        const result = yield* mcp.add("resource-only-server", {
-          type: "local",
-          command: ["echo", "test"],
-        })
-
-        expect(statusName(result.status, "resource-only-server")).toBe("connected")
-        expect(serverState.listToolsCalls).toBe(0)
-        expect(Object.keys(yield* mcp.tools())).toHaveLength(0)
-        expect(Object.keys(yield* mcp.resources())).toEqual(["resource-only-server:docs://readme"])
-        expect(serverState.listResourcesCalls).toBe(1)
-        expect(serverState.listPromptsCalls).toBe(0)
-      }),
-    ),
-  { config: { mcp: {} } },
-)
-
-it.instance(
-  "prompt-only servers connect without listing tools",
-  () =>
-    MCP.Service.use((mcp: MCPNS.Interface) =>
-      Effect.gen(function* () {
-        lastCreatedClientName = "prompt-only-server"
-        const serverState = getOrCreateClientState("prompt-only-server")
-        serverState.capabilities = { prompts: {} }
-        serverState.prompts = [{ name: "review" }]
-
-        const result = yield* mcp.add("prompt-only-server", {
-          type: "local",
-          command: ["echo", "test"],
-        })
+      }
+    }),
+    (server) => Effect.promise(server.close),
+  )
+}
 
-        expect(statusName(result.status, "prompt-only-server")).toBe("connected")
-        expect(serverState.listToolsCalls).toBe(0)
-        expect(Object.keys(yield* mcp.tools())).toHaveLength(0)
-        expect(Object.keys(yield* mcp.prompts())).toEqual(["prompt-only-server:review"])
-        expect(serverState.listPromptsCalls).toBe(1)
-        expect(serverState.listResourcesCalls).toBe(0)
-      }),
-    ),
-  { config: { mcp: {} } },
-)
+function statusName(status: Record<string, MCPNS.Status> | MCPNS.Status, server: string) {
+  if ("status" in status) return status.status
+  return status[server]?.status
+}
 
-it.instance(
-  "tools-only servers skip optional prompt and resource discovery",
-  () =>
-    MCP.Service.use((mcp: MCPNS.Interface) =>
-      Effect.gen(function* () {
-        lastCreatedClientName = "tools-only-server"
-        const serverState = getOrCreateClientState("tools-only-server")
-        serverState.capabilities = { tools: {} }
+const remote = (url: string, timeout?: number) => ({ type: "remote" as const, url, oauth: false as const, timeout })
 
-        const result = yield* mcp.add("tools-only-server", {
-          type: "local",
-          command: ["echo", "test"],
-        })
+it.instance("advertises and lists the instance directory as its root", () =>
+  Effect.gen(function* () {
+    const server = yield* lifecycleServer({ requestRoots: true })
+    const mcp = yield* MCP.Service
+    const test = yield* TestInstance
+    yield* mcp.add("roots", remote(server.url))
 
-        expect(statusName(result.status, "tools-only-server")).toBe("connected")
-        expect(serverState.listToolsCalls).toBe(1)
-        expect(Object.keys(yield* mcp.tools())).toEqual(["tools-only-server_test_tool"])
-        expect(yield* mcp.prompts()).toEqual({})
-        expect(yield* mcp.resources()).toEqual({})
-        expect(serverState.listPromptsCalls).toBe(0)
-        expect(serverState.listResourcesCalls).toBe(0)
-      }),
-    ),
-  { config: { mcp: {} } },
+    const roots = yield* pollWithTimeout(
+      Effect.sync(() => server.state.roots),
+      "server did not receive roots",
+    )
+    expect(roots).toEqual([{ uri: pathToFileURL(test.directory).href }])
+  }),
 )
 
 it.instance(
-  "prompts() skips disconnected servers",
+  "local mcp cwd resolves relative paths against instance directory",
   () =>
-    MCP.Service.use((mcp: MCPNS.Interface) =>
-      Effect.gen(function* () {
-        lastCreatedClientName = "prompt-disc-server"
-        const serverState = getOrCreateClientState("prompt-disc-server")
-        serverState.prompts = [{ name: "hidden-prompt", description: "Should not appear" }]
-
-        yield* mcp.add("prompt-disc-server", {
-          type: "local",
-          command: ["echo", "test"],
-        })
-
-        yield* mcp.disconnect("prompt-disc-server")
-
-        const prompts = yield* mcp.prompts()
-        expect(Object.keys(prompts).length).toBe(0)
-      }),
-    ),
-  {
-    config: {
-      mcp: {
-        "prompt-disc-server": {
-          type: "local",
-          command: ["echo", "test"],
-        },
+    Effect.gen(function* () {
+      const mcp = yield* MCP.Service
+      const test = yield* TestInstance
+      yield* mcp.add("rel-cwd", {
+        type: "local",
+        command: [process.execPath, stdioFixture],
+        cwd: "plugins/sub",
+      })
+
+      expect((yield* mcp.tools())["rel-cwd_current_directory"]?.def.description).toBe(
+        path.resolve(test.directory, "plugins/sub"),
+      )
+    }),
+  { init: (directory) => Effect.promise(() => Bun.$`mkdir -p ${path.join(directory, "plugins/sub")}`.quiet()) },
+)
+
+it.instance("tools() reuses cached definitions until a protocol notification", () =>
+  Effect.gen(function* () {
+    const server = yield* lifecycleServer({ capabilities: { tools: { listChanged: true } } })
+    const mcp = yield* MCP.Service
+    yield* mcp.add("cache-server", remote(server.url))
+    server.state.tools = [{ name: "next_tool", inputSchema: { type: "object", properties: {} } }]
+
+    expect(Object.keys(yield* mcp.tools())).toEqual(["cache-server_test_tool"])
+    yield* Effect.promise(server.sendToolListChanged)
+    yield* pollWithTimeout(
+      Effect.gen(function* () {
+        const keys = Object.keys(yield* mcp.tools())
+        return keys.includes("cache-server_next_tool") ? keys : undefined
+      }),
+      "tool cache did not refresh",
+    )
+    expect(Object.keys(yield* mcp.tools())).toEqual(["cache-server_next_tool"])
+  }),
+)
+
+it.instance("instructions() returns non-empty connected server instructions with tool names", () =>
+  Effect.gen(function* () {
+    const guide = yield* lifecycleServer({ instructions: "Use lookup before mutate." })
+    const blank = yield* lifecycleServer({ instructions: "   " })
+    const mcp = yield* MCP.Service
+    yield* mcp.add("guide-server", remote(guide.url))
+    yield* mcp.add("blank-server", remote(blank.url))
+
+    expect(yield* mcp.instructions()).toEqual([
+      { name: "guide-server", instructions: "Use lookup before mutate.", tools: ["guide-server_test_tool"] },
+    ])
+    yield* mcp.disconnect("guide-server")
+    expect(yield* mcp.instructions()).toEqual([])
+  }),
+)
+
+it.instance("follows cursors when listing tools, prompts, resources, and templates", () =>
+  Effect.gen(function* () {
+    const server = yield* lifecycleServer()
+    server.state.toolPages = {
+      initial: { items: [{ name: "tool-one", inputSchema: { type: "object" } }], nextCursor: "tools-2" },
+      "tools-2": { items: [{ name: "tool-two", inputSchema: { type: "object" } }] },
+    }
+    server.state.promptPages = {
+      initial: { items: [{ name: "prompt-one" }], nextCursor: "prompts-2" },
+      "prompts-2": { items: [{ name: "prompt-two" }] },
+    }
+    server.state.resourcePages = {
+      initial: { items: [{ name: "resource-one", uri: "test://one" }], nextCursor: "resources-2" },
+      "resources-2": { items: [{ name: "resource-two", uri: "test://two" }] },
+    }
+    server.state.resourceTemplatePages = {
+      initial: { items: [{ name: "template-one", uriTemplate: "test://one/{id}" }], nextCursor: "templates-2" },
+      "templates-2": { items: [{ name: "template-two", uriTemplate: "test://two/{id}" }] },
+    }
+    const mcp = yield* MCP.Service
+    yield* mcp.add("paged-server", remote(server.url))
+
+    expect(Object.keys(yield* mcp.tools())).toEqual(["paged-server_tool-one", "paged-server_tool-two"])
+    expect(Object.keys(yield* mcp.prompts())).toEqual(["paged-server:prompt-one", "paged-server:prompt-two"])
+    expect(Object.keys(yield* mcp.resources())).toEqual(["paged-server:test://one", "paged-server:test://two"])
+    expect(Object.keys(yield* mcp.resourceTemplates())).toEqual([
+      "paged-server:test://one/{id}",
+      "paged-server:test://two/{id}",
+    ])
+  }),
+)
+
+it.instance("accepts empty cursors and rejects repeated cursors", () =>
+  Effect.gen(function* () {
+    const empty = yield* lifecycleServer({ capabilities: { prompts: {} } })
+    empty.state.promptPages = {
+      initial: { items: [{ name: "prompt-one" }], nextCursor: "" },
+      "": { items: [{ name: "prompt-two" }] },
+    }
+    const looping = yield* lifecycleServer({ capabilities: { tools: {} } })
+    looping.state.toolPages = {
+      initial: { items: [], nextCursor: "repeat" },
+      repeat: { items: [], nextCursor: "repeat" },
+    }
+    const mcp = yield* MCP.Service
+    yield* mcp.add("empty-cursor", remote(empty.url))
+    const result = yield* mcp.add("looping-cursor", remote(looping.url))
+
+    expect(Object.keys(yield* mcp.prompts())).toEqual(["empty-cursor:prompt-one", "empty-cursor:prompt-two"])
+    expect(statusName(result.status, "looping-cursor")).toBe("failed")
+  }),
+)
+
+it.instance("disconnect removes protocol data and reconnect establishes a new session", () =>
+  Effect.gen(function* () {
+    const server = yield* lifecycleServer()
+    const mcp = yield* MCP.Service
+    yield* mcp.add("reconnect-server", remote(server.url))
+    expect((yield* mcp.status())["reconnect-server"]?.status).toBe("connected")
+
+    yield* mcp.disconnect("reconnect-server")
+    expect((yield* mcp.status())["reconnect-server"]?.status).toBe("disabled")
+    expect(Object.keys(yield* mcp.tools())).toEqual([])
+    yield* pollWithTimeout(
+      Effect.sync(() => (server.state.aborted > 0 ? server.state.aborted : undefined)),
+      "disconnected HTTP session was not aborted",
+    )
+
+    yield* Effect.promise(server.restart)
+    yield* mcp.connect("reconnect-server")
+    expect((yield* mcp.status())["reconnect-server"]?.status).toBe("connected")
+    expect(Object.keys(yield* mcp.tools())).toEqual(["reconnect-server_test_tool"])
+  }),
+)
+
+it.instance("add() closes the old protocol session when replacing a server", () =>
+  Effect.gen(function* () {
+    const first = yield* lifecycleServer()
+    const second = yield* lifecycleServer()
+    const mcp = yield* MCP.Service
+    yield* mcp.add("replace-server", remote(first.url))
+    yield* mcp.add("replace-server", remote(second.url))
+
+    yield* pollWithTimeout(
+      Effect.sync(() => (first.state.aborted > 0 ? first.state.aborted : undefined)),
+      "replaced HTTP session was not aborted",
+    )
+    expect(second.state.aborted).toBe(0)
+    expect(Object.keys(yield* mcp.tools())).toEqual(["replace-server_test_tool"])
+  }),
+)
+
+it.instance("one failed server does not affect another connected server", () =>
+  Effect.gen(function* () {
+    const good = yield* lifecycleServer()
+    good.state.tools = [{ name: "good_tool", inputSchema: { type: "object" } }]
+    const bad = yield* lifecycleServer()
+    bad.state.listToolsError = "listTools failed"
+    const mcp = yield* MCP.Service
+    yield* mcp.add("good-server", remote(good.url))
+    yield* mcp.add("bad-server", remote(bad.url))
+
+    expect((yield* mcp.status())["good-server"]?.status).toBe("connected")
+    expect((yield* mcp.status())["bad-server"]?.status).toBe("failed")
+    expect(Object.keys(yield* mcp.tools())).toEqual(["good-server_good_tool"])
+  }),
+)
+
+it.instance("falls back when output schema refs fail SDK tool discovery", () =>
+  Effect.gen(function* () {
+    const server = yield* lifecycleServer({ capabilities: { tools: {} } })
+    server.state.tools = [
+      {
+        name: "render_screen",
+        inputSchema: { type: "object", properties: { prompt: { type: "string" } }, required: ["prompt"] },
+        outputSchema: { type: "object", properties: { screen: { $ref: "#/$defs/ScreenInstance" } } },
       },
-    },
-  },
-)
-
-// ========================================================================
-// Test: connect() on nonexistent server
-// ========================================================================
-
-it.instance(
-  "connect() on nonexistent server fails with NotFoundError",
-  () =>
-    MCP.Service.use((mcp: MCPNS.Interface) =>
-      Effect.gen(function* () {
-        const exit = yield* mcp.connect("nonexistent").pipe(Effect.exit)
-        expect(Exit.isFailure(exit)).toBe(true)
-        if (Exit.isFailure(exit)) {
-          expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "MCP.NotFoundError", name: "nonexistent" })
-        }
-        const status = yield* mcp.status()
-        expect(status["nonexistent"]).toBeUndefined()
-      }),
-    ),
-  { config: { mcp: {} } },
-)
-
-// ========================================================================
-// Test: disconnect() on nonexistent server
-// ========================================================================
-
-it.instance(
-  "disconnect() on nonexistent server fails with NotFoundError",
-  () =>
-    MCP.Service.use((mcp: MCPNS.Interface) =>
-      Effect.gen(function* () {
-        const exit = yield* mcp.disconnect("nonexistent").pipe(Effect.exit)
-        expect(Exit.isFailure(exit)).toBe(true)
-        if (Exit.isFailure(exit)) {
-          expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "MCP.NotFoundError", name: "nonexistent" })
+    ]
+    const mcp = yield* MCP.Service
+    const result = yield* mcp.add("schema-server", remote(server.url))
+
+    expect(statusName(result.status, "schema-server")).toBe("connected")
+    expect(Object.keys(yield* mcp.tools())).toEqual(["schema-server_render_screen"])
+  }),
+)
+
+it.instance("does not fall back for protocol tool discovery errors", () =>
+  Effect.gen(function* () {
+    const server = yield* lifecycleServer({ capabilities: { tools: {} } })
+    server.state.listToolsError = "transport closed"
+    const mcp = yield* MCP.Service
+    const result = yield* mcp.add("broken-server", remote(server.url))
+
+    expect(statusName(result.status, "broken-server")).toBe("failed")
+  }),
+)
+
+it.instance("disabled server is marked disabled without opening a protocol session", () =>
+  Effect.gen(function* () {
+    const server = yield* lifecycleServer()
+    const mcp = yield* MCP.Service
+    yield* mcp.add("disabled-server", { ...remote(server.url), enabled: false })
+
+    expect((yield* mcp.status())["disabled-server"]?.status).toBe("disabled")
+    expect(server.state.requests).toEqual([])
+  }),
+)
+
+it.instance("returns prompts and URI-keyed resources from connected servers", () =>
+  Effect.gen(function* () {
+    const server = yield* lifecycleServer()
+    server.state.prompts = [{ name: "my-prompt", description: "A test prompt" }]
+    server.state.resources = [
+      { name: "same-name", uri: "file:///test.txt" },
+      { name: "same-name", uri: "ui://component-state" },
+    ]
+    const mcp = yield* MCP.Service
+    yield* mcp.add("content-server", remote(server.url))
+
+    expect(Object.keys(yield* mcp.prompts())).toEqual(["content-server:my-prompt"])
+    expect(Object.keys(yield* mcp.resources())).toEqual([
+      "content-server:file:///test.txt",
+      "content-server:ui://component-state",
+    ])
+    yield* mcp.disconnect("content-server")
+    expect(yield* mcp.prompts()).toEqual({})
+  }),
+)
+
+it.instance("uses per-server timeouts for prompt and resource requests", () =>
+  Effect.gen(function* () {
+    const server = yield* lifecycleServer()
+    server.state.requestDelay = 200
+    const mcp = yield* MCP.Service
+    yield* mcp.add("timeout-server", remote(server.url, 50))
+
+    expect(yield* mcp.getPrompt("timeout-server", "test")).toBeUndefined()
+    expect(yield* mcp.readResource("timeout-server", "test://resource")).toBeUndefined()
+  }),
+)
+
+it.instance("connects resource-only, prompt-only, and tools-only servers", () =>
+  Effect.gen(function* () {
+    const resources = yield* lifecycleServer({ capabilities: { resources: {} } })
+    resources.state.resources = [{ name: "docs", uri: "docs://readme" }]
+    const prompts = yield* lifecycleServer({ capabilities: { prompts: {} } })
+    prompts.state.prompts = [{ name: "review" }]
+    const tools = yield* lifecycleServer({ capabilities: { tools: {} } })
+    const mcp = yield* MCP.Service
+    yield* mcp.add("resource-only", remote(resources.url))
+    yield* mcp.add("prompt-only", remote(prompts.url))
+    yield* mcp.add("tools-only", remote(tools.url))
+
+    expect(Object.keys(yield* mcp.tools())).toEqual(["tools-only_test_tool"])
+    expect(Object.keys(yield* mcp.prompts())).toEqual(["prompt-only:review"])
+    expect(Object.keys(yield* mcp.resources())).toEqual(["resource-only:docs://readme"])
+  }),
+)
+
+it.instance("connect and disconnect fail for unknown servers", () =>
+  Effect.gen(function* () {
+    const mcp = yield* MCP.Service
+    for (const operation of [mcp.connect("missing"), mcp.disconnect("missing")]) {
+      const exit = yield* operation.pipe(Effect.exit)
+      expect(Exit.isFailure(exit)).toBe(true)
+      if (Exit.isFailure(exit)) {
+        expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "MCP.NotFoundError", name: "missing" })
+      }
+    }
+    expect(yield* mcp.status()).toEqual({})
+    expect(yield* mcp.tools()).toEqual({})
+  }),
+)
+
+it.instance("unavailable remote server is marked failed without tools", () =>
+  Effect.gen(function* () {
+    const server = yield* Effect.acquireRelease(
+      Effect.sync(() => Bun.serve({ port: 0, fetch: () => new Response("unavailable", { status: 503 }) })),
+      (http) => Effect.promise(() => http.stop(true)),
+    )
+    const mcp = yield* MCP.Service
+    yield* mcp.add("unavailable", remote(server.url.toString(), 500))
+
+    expect((yield* mcp.status()).unavailable?.status).toBe("failed")
+    expect(yield* mcp.tools()).toEqual({})
+  }),
+)
+
+it.instance("tools() prefixes sanitized server and tool names", () =>
+  Effect.gen(function* () {
+    const server = yield* lifecycleServer({ capabilities: { tools: {} } })
+    server.state.tools = [
+      { name: "tool-a", inputSchema: { type: "object" } },
+      { name: "tool.b", inputSchema: { type: "object" } },
+    ]
+    const mcp = yield* MCP.Service
+    yield* mcp.add("my.special-server", remote(server.url))
+
+    expect(Object.keys(yield* mcp.tools())).toEqual(["my_special-server_tool-a", "my_special-server_tool_b"])
+  }),
+)
+
+it.instance("local stdio timeout terminates the real server process", () =>
+  Effect.gen(function* () {
+    const test = yield* TestInstance
+    const pidFile = path.join(test.directory, "mcp.pid")
+    const mcp = yield* MCP.Service
+    const result = yield* mcp.add("hanging-stdio", {
+      type: "local",
+      command: [process.execPath, stdioFixture, "--hang"],
+      environment: { MCP_LIFECYCLE_PID_FILE: pidFile },
+      timeout: 100,
+    })
+
+    expect(statusName(result.status, "hanging-stdio")).toBe("failed")
+    const pid = yield* pollWithTimeout(
+      Effect.promise(async () => {
+        const file = Bun.file(pidFile)
+        return (await file.exists()) ? Number(await file.text()) : undefined
+      }),
+      "stdio fixture did not publish its pid",
+    )
+    yield* pollWithTimeout(
+      Effect.sync(() => {
+        try {
+          process.kill(pid, 0)
+          return undefined
+        } catch {
+          return true
         }
       }),
-    ),
-  { config: { mcp: {} } },
+      "stdio fixture process was not terminated",
+    )
+  }),
 )
 
-// ========================================================================
-// Test: tools() with no MCP servers configured
-// ========================================================================
+it.instance("remote timeout aborts both real HTTP transport attempts", () =>
+  Effect.gen(function* () {
+    const server = yield* hangingLifecycleServer()
+    const mcp = yield* MCP.Service
+    const result = yield* mcp.add("hanging-remote", remote(server.url, 100))
 
-it.instance(
-  "tools() returns empty when no MCP servers are configured",
-  () =>
-    MCP.Service.use((mcp: MCPNS.Interface) =>
-      Effect.gen(function* () {
-        const tools = yield* mcp.tools()
-        expect(Object.keys(tools).length).toBe(0)
-      }),
-    ),
-  { config: { mcp: {} } },
+    expect(statusName(result.status, "hanging-remote")).toBe("failed")
+    yield* pollWithTimeout(
+      Effect.sync(() => (server.aborted() >= 2 ? server.aborted() : undefined)),
+      "remote transport requests were not aborted",
+    )
+    expect(server.requests).toEqual(["POST", "GET"])
+  }),
 )
 
-// ========================================================================
-// Test: connect failure during create()
-// ========================================================================
-
-it.instance(
-  "server that fails to connect is marked as failed",
-  () =>
-    MCP.Service.use((mcp: MCPNS.Interface) =>
-      Effect.gen(function* () {
-        lastCreatedClientName = "fail-connect"
-        getOrCreateClientState("fail-connect")
-        connectShouldFail = true
-        connectError = "Connection refused"
-
-        yield* mcp.add("fail-connect", {
-          type: "local",
-          command: ["echo", "test"],
-        })
-
-        const status = yield* 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 = yield* mcp.tools()
-        expect(Object.keys(tools).length).toBe(0)
-      }),
-    ),
-  {
-    config: {
-      mcp: {
-        "fail-connect": {
-          type: "local",
-          command: ["echo", "test"],
-        },
-      },
-    },
-  },
-)
-
-// ========================================================================
-// Bug #5: McpOAuthCallback.cancelPending uses wrong key
-// ========================================================================
-
-it.live("McpOAuthCallback.cancelPending is keyed by mcpName but pendingAuths uses oauthState", () =>
+it.live("McpOAuthCallback.cancelPending rejects the pending callback", () =>
   Effect.acquireUseRelease(
     Effect.sync(() => McpOAuthCallback.waitForCallback("abc123hexstate", "my-mcp-server")),
     (callback) =>
       Effect.gen(function* () {
         McpOAuthCallback.cancelPending("my-mcp-server")
-
         const exit = yield* Effect.tryPromise({
           try: () => callback,
           catch: (error) => (error instanceof Error ? error : new Error(String(error))),
-        }).pipe(
-          Effect.timeoutOrElse({
-            duration: "1 second",
-            orElse: () => Effect.fail(new Error("timed out waiting for OAuth cancellation")),
-          }),
-          Effect.exit,
-        )
-
+        }).pipe(Effect.exit)
         expect(Exit.isFailure(exit)).toBe(true)
       }),
     () => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore),
   ),
 )
-
-// ========================================================================
-// Test: multiple tools from same server get correct name prefixes
-// ========================================================================
-
-it.instance(
-  "tools() prefixes tool names with sanitized server name",
-  () =>
-    MCP.Service.use((mcp: MCPNS.Interface) =>
-      Effect.gen(function* () {
-        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: {} } },
-        ]
-
-        yield* mcp.add("my.special-server", {
-          type: "local",
-          command: ["echo", "test"],
-        })
-
-        const tools = yield* 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)
-      }),
-    ),
-  {
-    config: {
-      mcp: {
-        "my.special-server": {
-          type: "local",
-          command: ["echo", "test"],
-        },
-      },
-    },
-  },
-)
-
-// ========================================================================
-// Test: transport leak — local stdio timeout (#19168)
-// ========================================================================
-
-it.instance(
-  "local stdio transport is closed when connect times out (no process leak)",
-  () =>
-    MCP.Service.use((mcp: MCPNS.Interface) =>
-      Effect.gen(function* () {
-        lastCreatedClientName = "hanging-server"
-        getOrCreateClientState("hanging-server")
-        connectShouldHang = true
-
-        const addResult = yield* mcp.add("hanging-server", {
-          type: "local",
-          command: ["node", "fake.js"],
-          timeout: 100,
-        })
-
-        const serverStatus = (addResult.status as any)["hanging-server"] ?? addResult.status
-        expect(serverStatus.status).toBe("failed")
-        expect(serverStatus.error).toContain("timed out")
-        // Transport must be closed to avoid orphaned child process
-        expect(transportCloseCount).toBeGreaterThanOrEqual(1)
-      }),
-    ),
-  { config: { mcp: {} } },
-)
-
-// ========================================================================
-// Test: transport leak — remote timeout (#19168)
-// ========================================================================
-
-it.instance(
-  "remote transport is closed when connect times out",
-  () =>
-    MCP.Service.use((mcp: MCPNS.Interface) =>
-      Effect.gen(function* () {
-        lastCreatedClientName = "hanging-remote"
-        getOrCreateClientState("hanging-remote")
-        connectShouldHang = true
-
-        const addResult = yield* mcp.add("hanging-remote", {
-          type: "remote",
-          url: "http://localhost:9999/mcp",
-          timeout: 100,
-          oauth: false,
-        })
-
-        const serverStatus = (addResult.status as any)["hanging-remote"] ?? addResult.status
-        expect(serverStatus.status).toBe("failed")
-        // Transport must be closed to avoid leaked HTTP connections
-        expect(transportCloseCount).toBeGreaterThanOrEqual(1)
-      }),
-    ),
-  { config: { mcp: {} } },
-)
-
-// ========================================================================
-// Test: transport leak — failed remote transports not closed (#19168)
-// ========================================================================
-
-it.instance(
-  "failed remote transport is closed before trying next transport",
-  () =>
-    MCP.Service.use((mcp: MCPNS.Interface) =>
-      Effect.gen(function* () {
-        lastCreatedClientName = "fail-remote"
-        getOrCreateClientState("fail-remote")
-        connectShouldFail = true
-        connectError = "Connection refused"
-
-        const addResult = yield* mcp.add("fail-remote", {
-          type: "remote",
-          url: "http://localhost:9999/mcp",
-          timeout: 5000,
-          oauth: false,
-        })
-
-        const serverStatus = (addResult.status as any)["fail-remote"] ?? addResult.status
-        expect(serverStatus.status).toBe("failed")
-        // Both StreamableHTTP and SSE transports should be closed
-        expect(transportCloseCount).toBeGreaterThanOrEqual(2)
-      }),
-    ),
-  { config: { mcp: {} } },
-)

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

@@ -1,199 +1,155 @@
-import { expect, mock, beforeEach } from "bun:test"
+import { expect } from "bun:test"
+import { Server } from "@modelcontextprotocol/sdk/server/index.js"
+import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
+import { ListResourcesRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
 import { LayerNode } from "@opencode-ai/core/effect/layer-node"
-import { Effect, Layer } from "effect"
+import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
+import { FSUtil } from "@opencode-ai/core/fs-util"
+import { Effect } from "effect"
+import { Config } from "../../src/config/config"
+import { EventV2Bridge } from "../../src/event-v2-bridge"
+import { McpAuth } from "../../src/mcp/auth"
+import { MCP } from "../../src/mcp/index"
+import { McpOAuthCallback } from "../../src/mcp/oauth-callback"
+import { McpOAuthPendingProvider, McpOAuthProvider } from "../../src/mcp/oauth-provider"
 import { testEffect } from "../lib/effect"
 
-// Mock UnauthorizedError to match the SDK's class
-class MockUnauthorizedError extends Error {
-  constructor(message?: string) {
-    super(message ?? "Unauthorized")
-    this.name = "UnauthorizedError"
-  }
+const mcpTest = testEffect(
+  LayerNode.compile(
+    LayerNode.group([MCP.node, McpAuth.node, EventV2Bridge.node, Config.node, CrossSpawnSpawner.node, FSUtil.node]),
+  ),
+)
+
+interface OAuthMcpOptions {
+  capabilities?: "tools" | "resources"
 }
 
-// Track what options were passed to each transport constructor
-const transportCalls: Array<{
-  type: "streamable" | "sse"
-  url: string
-  options: { authProvider?: unknown }
-}> = []
-
-// Controls whether the mock transport simulates a 401 that triggers the SDK
-// auth flow (which calls provider.state()) or a simple UnauthorizedError.
-let simulateAuthFlow = true
-let connectSucceedsImmediately = false
-let serverCapabilities: { tools?: object; resources?: object } = { tools: {} }
-let listToolsCalls = 0
-let finishAuthFails = false
-let finishAuthStoresCredentials = false
-
-// Mock the transport constructors to simulate OAuth auto-auth on 401
-void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
-  StreamableHTTPClientTransport: class MockStreamableHTTP {
-    authProvider:
-      | {
-          state?: () => Promise<string>
-          redirectToAuthorization?: (url: URL) => Promise<void>
-          saveCodeVerifier?: (v: string) => Promise<void>
-          tokens?: () => Promise<{ access_token: string } | undefined>
-          clientInformation?: () => Promise<{ client_id: string } | undefined>
-          saveClientInformation?: (info: { client_id: string; client_secret?: string }) => Promise<void>
-          saveTokens?: (tokens: { access_token: string; token_type: string }) => Promise<void>
-        }
-      | undefined
-    constructor(url: URL, options?: { authProvider?: unknown }) {
-      this.authProvider = options?.authProvider as typeof this.authProvider
-      transportCalls.push({
-        type: "streamable",
-        url: url.toString(),
-        options: options ?? {},
+function serveOAuthMcp(options: OAuthMcpOptions = {}) {
+  return Effect.acquireRelease(
+    Effect.promise(async () => {
+      const capabilities = options.capabilities ?? "tools"
+      const protocol = new Server(
+        { name: "oauth-auto-connect", version: "1.0.0" },
+        { capabilities: capabilities === "tools" ? { tools: {} } : { resources: {} } },
+      )
+      const transport = new WebStandardStreamableHTTPServerTransport({
+        sessionIdGenerator: () => crypto.randomUUID(),
+        enableJsonResponse: true,
       })
-    }
-    async start() {
-      if (connectSucceedsImmediately) return
-
-      // Simulate what the real SDK transport does on 401:
-      // It calls auth() which eventually calls provider.state(), then
-      // provider.redirectToAuthorization(), then throws UnauthorizedError.
-      if (simulateAuthFlow && this.authProvider) {
-        if (await this.authProvider.tokens?.()) throw new MockUnauthorizedError()
-        if (await this.authProvider.clientInformation?.()) throw new MockUnauthorizedError()
-        // The SDK calls provider.state() to get the OAuth state parameter
-        if (this.authProvider.state) {
-          await this.authProvider.state()
-        }
-        // The SDK calls saveCodeVerifier before redirecting
-        if (this.authProvider.saveCodeVerifier) {
-          await this.authProvider.saveCodeVerifier("test-verifier")
-        }
-        // The SDK calls redirectToAuthorization to redirect the user
-        if (this.authProvider.redirectToAuthorization) {
-          await this.authProvider.redirectToAuthorization(new URL("https://auth.example.com/authorize?state=test"))
-        }
-        throw new MockUnauthorizedError()
+      let listToolsCalls = 0
+      let requiresAuth = true
+
+      if (capabilities === "tools") {
+        protocol.setRequestHandler(ListToolsRequestSchema, () => {
+          listToolsCalls++
+          return Promise.resolve({ tools: [{ name: "test_tool", inputSchema: { type: "object" } }] })
+        })
       }
-      throw new MockUnauthorizedError()
-    }
-    async finishAuth(_code: string) {
-      if (finishAuthFails) throw new Error("Token exchange failed")
-      if (finishAuthStoresCredentials) {
-        await this.authProvider?.saveClientInformation?.({ client_id: "replacement-client" })
-        await this.authProvider?.saveTokens?.({ access_token: "replacement-token", token_type: "Bearer" })
+      if (capabilities === "resources") {
+        protocol.setRequestHandler(ListResourcesRequestSchema, () =>
+          Promise.resolve({ resources: [{ name: "docs", uri: "docs://readme" }] }),
+        )
       }
-    }
-    async close() {}
-  },
-}))
-
-void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({
-  SSEClientTransport: class MockSSE {
-    constructor(url: URL, options?: { authProvider?: unknown }) {
-      transportCalls.push({
-        type: "sse",
-        url: url.toString(),
-        options: options ?? {},
-      })
-    }
-    async start() {
-      throw new Error("Mock SSE transport cannot connect")
-    }
-  },
-}))
-
-// Mock the MCP SDK Client
-void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
-  Client: class MockClient {
-    setRequestHandler() {}
-
-    async connect(transport: { start: () => Promise<void> }) {
-      await transport.start()
-    }
-
-    setNotificationHandler() {}
-
-    getServerCapabilities() {
-      return serverCapabilities
-    }
-
-    getInstructions() {}
-
-    async listTools() {
-      listToolsCalls++
-      return { tools: [{ name: "test_tool", inputSchema: { type: "object", properties: {} } }] }
-    }
-
-    async listResources() {
-      return { resources: [{ name: "docs", uri: "docs://readme" }] }
-    }
-
-    async close() {}
-  },
-}))
-
-// Mock UnauthorizedError in the auth module so instanceof checks work
-void mock.module("@modelcontextprotocol/sdk/client/auth.js", () => ({
-  UnauthorizedError: MockUnauthorizedError,
-}))
-
-beforeEach(() => {
-  transportCalls.length = 0
-  simulateAuthFlow = true
-  connectSucceedsImmediately = false
-  serverCapabilities = { tools: {} }
-  listToolsCalls = 0
-  finishAuthFails = false
-  finishAuthStoresCredentials = false
-})
 
-// Import modules after mocking
-const { MCP } = await import("../../src/mcp/index")
-const { EventV2Bridge } = await import("../../src/event-v2-bridge")
-const { Config } = await import("../../src/config/config")
-const { McpAuth } = await import("../../src/mcp/auth")
-const { McpOAuthProvider } = await import("../../src/mcp/oauth-provider")
-const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback")
-const { FSUtil } = await import("@opencode-ai/core/fs-util")
-const { CrossSpawnSpawner } = await import("@opencode-ai/core/cross-spawn-spawner")
+      await protocol.connect(transport)
+      const http = Bun.serve({
+        port: 0,
+        async fetch(request) {
+          const url = new URL(request.url)
+          const origin = url.origin
+          const mcpUrl = `${origin}/mcp`
+
+          if (url.pathname === "/.well-known/oauth-protected-resource/mcp") {
+            return Response.json({
+              resource: mcpUrl,
+              authorization_servers: [origin],
+              scopes_supported: ["mcp"],
+            })
+          }
+          if (url.pathname === "/.well-known/oauth-protected-resource") {
+            return Response.json({
+              resource: mcpUrl,
+              authorization_servers: [origin],
+              scopes_supported: ["mcp"],
+            })
+          }
+          if (url.pathname === "/.well-known/oauth-authorization-server") {
+            return Response.json({
+              issuer: origin,
+              authorization_endpoint: `${origin}/authorize`,
+              token_endpoint: `${origin}/token`,
+              registration_endpoint: `${origin}/register`,
+              response_types_supported: ["code"],
+              grant_types_supported: ["authorization_code", "refresh_token"],
+              token_endpoint_auth_methods_supported: ["none"],
+              code_challenge_methods_supported: ["S256"],
+              scopes_supported: ["mcp"],
+            })
+          }
+          if (url.pathname === "/register") {
+            const metadata = (await request.json()) as Record<string, unknown>
+            return Response.json({ ...metadata, client_id: "replacement-client" }, { status: 201 })
+          }
+          if (url.pathname === "/token") {
+            const body = new URLSearchParams(await request.text())
+            if (body.get("code") !== "valid-code") {
+              return Response.json(
+                { error: "invalid_grant", error_description: "Token exchange failed" },
+                { status: 400 },
+              )
+            }
+            return Response.json({ access_token: "replacement-token", token_type: "Bearer" })
+          }
+          if (url.pathname !== "/mcp") return new Response("Not found", { status: 404 })
+
+          if (request.method === "GET") return new Response(null, { status: 405 })
+          if (requiresAuth && request.headers.get("authorization") !== "Bearer replacement-token") {
+            return new Response("Unauthorized", {
+              status: 401,
+              headers: {
+                "WWW-Authenticate": `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource", scope="mcp"`,
+              },
+            })
+          }
+          return transport.handleRequest(request)
+        },
+      })
 
-const mcpTest = testEffect(
-  LayerNode.compile(
-    LayerNode.group([MCP.node, McpAuth.node, EventV2Bridge.node, Config.node, CrossSpawnSpawner.node, FSUtil.node]),
-  ),
-)
+      return {
+        url: new URL("/mcp", http.url).toString(),
+        allowAnonymous: () => {
+          requiresAuth = false
+        },
+        listToolsCalls: () => listToolsCalls,
+        close: async () => {
+          await http.stop(true)
+          await protocol.close()
+        },
+      }
+    }),
+    (server) => Effect.promise(server.close),
+  )
+}
 
-const config = (name: string) => ({
-  mcp: {
-    [name]: {
-      type: "remote" as const,
-      url: "https://example.com/mcp",
-    },
-  },
+const remote = (url: string, enabled = true) => ({
+  type: "remote" as const,
+  url,
+  enabled,
 })
 
-mcpTest.instance(
-  "first connect to OAuth server shows needs_auth instead of failed",
-  () =>
-    MCP.Service.use((mcp) =>
-      Effect.gen(function* () {
-        const result = yield* mcp.add("test-oauth", {
-          type: "remote",
-          url: "https://example.com/mcp",
-        })
+const stopOAuthCallback = Effect.addFinalizer(() => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore))
 
-        const serverStatus = result.status as Record<string, { status: string; error?: string }>
-
-        // The server should be detected as needing auth, NOT as failed.
-        // Before the fix, provider.state() would throw a plain Error
-        // ("No OAuth state saved for MCP server: test-oauth") which was
-        // not caught as UnauthorizedError, causing status to be "failed".
-        expect(serverStatus["test-oauth"]).toBeDefined()
-        expect(serverStatus["test-oauth"].status).toBe("needs_auth")
-      }),
-    ),
-  { config: config("test-oauth") },
+mcpTest.instance("first connect to OAuth server shows needs_auth instead of failed", () =>
+  Effect.gen(function* () {
+    const server = yield* serveOAuthMcp()
+    const mcp = yield* MCP.Service
+    const result = yield* mcp.add("test-oauth", remote(server.url))
+
+    expect((result.status as Record<string, { status: string }>)["test-oauth"]).toEqual({ status: "needs_auth" })
+  }),
 )
 
-mcpTest.instance("state() generates a new state when none is saved", () =>
+mcpTest.instance("state() generates and persists a new state when none is saved", () =>
   Effect.gen(function* () {
     const auth = yield* McpAuth.Service
     const provider = new McpOAuthProvider(
@@ -204,17 +160,11 @@ mcpTest.instance("state() generates a new state when none is saved", () =>
       auth,
     )
 
-    const entryBefore = yield* McpAuth.use.get("test-state-gen")
-    expect(entryBefore?.oauthState).toBeUndefined()
+    expect((yield* auth.get("test-state-gen"))?.oauthState).toBeUndefined()
 
-    // state() should generate and return a new state, not throw
     const state = yield* Effect.promise(() => provider.state())
-    expect(typeof state).toBe("string")
-    expect(state.length).toBe(64) // 32 bytes as hex
-
-    // The generated state should be persisted
-    const entryAfter = yield* McpAuth.use.get("test-state-gen")
-    expect(entryAfter?.oauthState).toBe(state)
+    expect(state).toHaveLength(64)
+    expect((yield* auth.get("test-state-gen"))?.oauthState).toBe(state)
   }),
 )
 
@@ -229,139 +179,122 @@ mcpTest.instance("state() returns existing state when one is saved", () =>
       auth,
     )
 
-    // Pre-save a state
-    const existingState = "pre-saved-state-value"
-    yield* McpAuth.use.updateOAuthState("test-state-existing", existingState)
+    yield* auth.updateOAuthState("test-state-existing", "pre-saved-state-value")
+    expect(yield* Effect.promise(() => provider.state())).toBe("pre-saved-state-value")
+  }),
+)
 
-    // state() should return the existing state
-    const state = yield* Effect.promise(() => provider.state())
-    expect(state).toBe(existingState)
+mcpTest.instance("pending provider does not expose or overwrite existing credentials before commit", () =>
+  Effect.gen(function* () {
+    const auth = yield* McpAuth.Service
+    const name = "test-pending-credentials"
+    const url = "https://example.com/mcp"
+    const provider = new McpOAuthPendingProvider(name, url, {}, { onRedirect: async () => {} }, auth)
+
+    yield* auth.updateClientInfo(name, { clientId: "old-client" }, url)
+    yield* auth.updateTokens(name, { accessToken: "old-token" }, url)
+
+    expect(yield* Effect.promise(() => provider.clientInformation())).toBeUndefined()
+    expect(yield* Effect.promise(() => provider.tokens())).toBeUndefined()
+    expect((yield* auth.get(name))?.tokens?.accessToken).toBe("old-token")
+    expect((yield* auth.get(name))?.clientInfo?.clientId).toBe("old-client")
   }),
 )
 
-mcpTest.instance(
-  "failed reauthentication preserves existing credentials",
-  () =>
-    Effect.gen(function* () {
-      yield* Effect.addFinalizer(() => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore))
-      const mcp = yield* MCP.Service
-      const auth = yield* McpAuth.Service
-      const name = "test-reauth-failure"
-      const url = "https://example.com/mcp"
-      const clientInfo = { clientId: "dynamic-client", clientSecret: "dynamic-secret" }
-
-      yield* auth.updateClientInfo(name, clientInfo, url)
-      yield* auth.updateTokens(name, { accessToken: "working-token" }, url)
-      expect((yield* mcp.startAuth(name)).authorizationUrl).toContain("https://auth.example.com/authorize")
-      finishAuthFails = true
-
-      expect(yield* mcp.finishAuth(name, "invalid-code")).toEqual({
-        status: "failed",
-        error: "OAuth completion failed: Token exchange failed",
-      })
-      const entry = yield* auth.get(name)
-      expect(entry?.tokens?.accessToken).toBe("working-token")
-      expect(entry?.clientInfo).toEqual(clientInfo)
-    }),
-  { config: config("test-reauth-failure") },
+mcpTest.instance("failed reauthentication preserves existing credentials", () =>
+  Effect.gen(function* () {
+    yield* stopOAuthCallback
+    const server = yield* serveOAuthMcp()
+    const mcp = yield* MCP.Service
+    const auth = yield* McpAuth.Service
+    const name = "test-reauth-failure"
+
+    yield* auth.updateClientInfo(name, { clientId: "dynamic-client", clientSecret: "dynamic-secret" }, server.url)
+    yield* auth.updateTokens(name, { accessToken: "working-token" }, server.url)
+    yield* mcp.add(name, remote(server.url))
+    expect((yield* mcp.startAuth(name)).authorizationUrl).toContain("/authorize")
+
+    expect(yield* mcp.finishAuth(name, "invalid-code")).toEqual({
+      status: "failed",
+      error: "OAuth completion failed: Token exchange failed",
+    })
+    expect((yield* auth.get(name))?.tokens?.accessToken).toBe("working-token")
+    expect((yield* auth.get(name))?.clientInfo).toMatchObject({
+      clientId: "dynamic-client",
+      clientSecret: "dynamic-secret",
+    })
+  }),
 )
 
-mcpTest.instance(
-  "successful reauthentication commits replacement credentials",
-  () =>
-    Effect.gen(function* () {
-      yield* Effect.addFinalizer(() => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore))
-      const mcp = yield* MCP.Service
-      const auth = yield* McpAuth.Service
-      const name = "test-reauth-success"
-      const url = "https://example.com/mcp"
-
-      yield* auth.updateClientInfo(name, { clientId: "old-client" }, url)
-      yield* auth.updateTokens(name, { accessToken: "old-token" }, url)
-      expect((yield* mcp.startAuth(name)).authorizationUrl).toContain("https://auth.example.com/authorize")
-      expect((yield* auth.get(name))?.tokens?.accessToken).toBe("old-token")
-      finishAuthStoresCredentials = true
-      connectSucceedsImmediately = true
-
-      expect((yield* mcp.finishAuth(name, "valid-code")).status).toBe("connected")
-      const entry = yield* auth.get(name)
-      expect(entry?.tokens?.accessToken).toBe("replacement-token")
-      expect(entry?.clientInfo?.clientId).toBe("replacement-client")
-      expect(entry?.serverUrl).toBe(url)
-    }),
-  { config: config("test-reauth-success") },
+mcpTest.instance("successful reauthentication commits replacement credentials", () =>
+  Effect.gen(function* () {
+    yield* stopOAuthCallback
+    const server = yield* serveOAuthMcp()
+    const mcp = yield* MCP.Service
+    const auth = yield* McpAuth.Service
+    const name = "test-reauth-success"
+
+    yield* auth.updateClientInfo(name, { clientId: "old-client" }, server.url)
+    yield* auth.updateTokens(name, { accessToken: "old-token" }, server.url)
+    yield* mcp.add(name, remote(server.url))
+    expect((yield* mcp.startAuth(name)).authorizationUrl).toContain("/authorize")
+    expect((yield* auth.get(name))?.tokens?.accessToken).toBe("old-token")
+
+    expect((yield* mcp.finishAuth(name, "valid-code")).status).toBe("connected")
+    const entry = yield* auth.get(name)
+    expect(entry?.tokens?.accessToken).toBe("replacement-token")
+    expect(entry?.clientInfo?.clientId).toBe("replacement-client")
+    expect(entry?.serverUrl).toBe(server.url)
+  }),
 )
 
-mcpTest.instance(
-  "auth status only reports credentials stored for the configured server URL",
-  () =>
-    Effect.gen(function* () {
-      const mcp = yield* MCP.Service
-      expect(transportCalls).toHaveLength(0)
-      yield* McpAuth.use.updateTokens("test-status-url", { accessToken: "old-token" }, "https://old.example.com/mcp")
+mcpTest.instance("auth status only reports credentials stored for the configured server URL", () =>
+  Effect.gen(function* () {
+    const mcp = yield* MCP.Service
+    yield* mcp.add("test-status-url", remote("https://example.com/mcp", false))
+    yield* McpAuth.use.updateTokens("test-status-url", { accessToken: "old-token" }, "https://old.example.com/mcp")
 
-      expect(yield* mcp.getAuthStatus("test-status-url")).toBe("not_authenticated")
+    expect(yield* mcp.getAuthStatus("test-status-url")).toBe("not_authenticated")
 
-      yield* McpAuth.use.updateTokens("test-status-url", { accessToken: "current-token" }, "https://example.com/mcp")
-      expect(yield* mcp.getAuthStatus("test-status-url")).toBe("authenticated")
+    yield* McpAuth.use.updateTokens("test-status-url", { accessToken: "current-token" }, "https://example.com/mcp")
+    expect(yield* mcp.getAuthStatus("test-status-url")).toBe("authenticated")
 
-      yield* McpAuth.use.updateTokens(
-        "test-status-url",
-        { accessToken: "expired-token", expiresAt: 1 },
-        "https://example.com/mcp",
-      )
-      expect(yield* mcp.getAuthStatus("test-status-url")).toBe("expired")
-      expect(transportCalls).toHaveLength(0)
-    }),
-  { config: config("test-status-url") },
+    yield* McpAuth.use.updateTokens(
+      "test-status-url",
+      { accessToken: "expired-token", expiresAt: 1 },
+      "https://example.com/mcp",
+    )
+    expect(yield* mcp.getAuthStatus("test-status-url")).toBe("expired")
+  }),
 )
 
-mcpTest.instance(
-  "authenticate() stores a connected client when auth completes without redirect",
-  () =>
-    MCP.Service.use((mcp) =>
-      Effect.gen(function* () {
-        const added = yield* mcp.add("test-oauth-connect", {
-          type: "remote",
-          url: "https://example.com/mcp",
-        })
-        const before = added.status as Record<string, { status: string; error?: string }>
-        expect(before["test-oauth-connect"]?.status).toBe("needs_auth")
-
-        simulateAuthFlow = false
-        connectSucceedsImmediately = true
-
-        const result = yield* mcp.authenticate("test-oauth-connect")
-        expect(result.status).toBe("connected")
-
-        const after = yield* mcp.status()
-        expect(after["test-oauth-connect"]?.status).toBe("connected")
-      }),
-    ),
-  { config: config("test-oauth-connect") },
+mcpTest.instance("authenticate() stores a connected client when auth completes without redirect", () =>
+  Effect.gen(function* () {
+    yield* stopOAuthCallback
+    const server = yield* serveOAuthMcp()
+    const mcp = yield* MCP.Service
+    const name = "test-oauth-connect"
+    const added = yield* mcp.add(name, remote(server.url))
+    expect((added.status as Record<string, { status: string }>)[name]?.status).toBe("needs_auth")
+
+    server.allowAnonymous()
+    expect((yield* mcp.authenticate(name)).status).toBe("connected")
+    expect((yield* mcp.status())[name]?.status).toBe("connected")
+  }),
 )
 
-mcpTest.instance(
-  "authenticate() connects a resource-only server without listing tools",
-  () =>
-    MCP.Service.use((mcp) =>
-      Effect.gen(function* () {
-        const added = yield* mcp.add("test-oauth-resources", {
-          type: "remote",
-          url: "https://example.com/mcp",
-        })
-        const before = added.status as Record<string, { status: string }>
-        expect(before["test-oauth-resources"]?.status).toBe("needs_auth")
-
-        simulateAuthFlow = false
-        connectSucceedsImmediately = true
-        serverCapabilities = { resources: {} }
-
-        const result = yield* mcp.authenticate("test-oauth-resources")
-        expect(result.status).toBe("connected")
-        expect(listToolsCalls).toBe(0)
-        expect(Object.keys(yield* mcp.resources())).toEqual(["test-oauth-resources:docs://readme"])
-      }),
-    ),
-  { config: config("test-oauth-resources") },
+mcpTest.instance("authenticate() connects a resource-only server without listing tools", () =>
+  Effect.gen(function* () {
+    yield* stopOAuthCallback
+    const server = yield* serveOAuthMcp({ capabilities: "resources" })
+    const mcp = yield* MCP.Service
+    const name = "test-oauth-resources"
+    const added = yield* mcp.add(name, remote(server.url))
+    expect((added.status as Record<string, { status: string }>)[name]?.status).toBe("needs_auth")
+
+    server.allowAnonymous()
+    expect((yield* mcp.authenticate(name)).status).toBe("connected")
+    expect(server.listToolsCalls()).toBe(0)
+    expect(Object.keys(yield* mcp.resources())).toEqual([`${name}:docs://readme`])
+  }),
 )

+ 192 - 210
packages/opencode/test/mcp/oauth-browser.test.ts

@@ -1,152 +1,138 @@
-import { expect, mock, beforeEach } from "bun:test"
-import { EventEmitter } from "events"
+import { expect } from "bun:test"
+import { Server } from "@modelcontextprotocol/sdk/server/index.js"
+import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
+import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
 import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { Deferred, Effect, Layer, Option } from "effect"
+import { Config } from "../../src/config/config"
+import { EventV2Bridge } from "../../src/event-v2-bridge"
+import { McpAuth } from "../../src/mcp/auth"
+import { McpBrowser } from "../../src/mcp/browser"
+import { MCP } from "../../src/mcp/index"
+import { McpOAuthCallback } from "../../src/mcp/oauth-callback"
 import { awaitWithTimeout, testEffect } from "../lib/effect"
-import type { MCP as MCPNS } from "../../src/mcp/index"
-
-// Track open() calls and control failure behavior
-let openShouldFail = false
-let openCalledWith: string | undefined
-let openDeferred: Deferred.Deferred<string> | undefined
-
-void mock.module("open", () => ({
-  default: async (url: string) => {
-    openCalledWith = url
-    if (openDeferred) Effect.runSync(Deferred.succeed(openDeferred, url).pipe(Effect.ignore))
-
-    // Return a mock subprocess that emits an error if openShouldFail is true
-    const subprocess = new EventEmitter()
-    if (openShouldFail) {
-      // Emit error asynchronously like a real subprocess would
-      setTimeout(() => {
-        subprocess.emit("error", new Error("spawn xdg-open ENOENT"))
-      }, 10)
-    }
-    return subprocess
-  },
-}))
-
-// Mock UnauthorizedError
-class MockUnauthorizedError extends Error {
-  constructor() {
-    super("Unauthorized")
-    this.name = "UnauthorizedError"
-  }
-}
-
-// Track what options were passed to each transport constructor
-const transportCalls: Array<{
-  type: "streamable" | "sse"
-  url: string
-  options: { authProvider?: unknown; requestInit?: RequestInit }
-}> = []
-
-// Mock the transport constructors
-void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
-  StreamableHTTPClientTransport: class MockStreamableHTTP {
-    url: string
-    authProvider: { redirectToAuthorization?: (url: URL) => Promise<void> } | undefined
-    constructor(
-      url: URL,
-      options?: { authProvider?: { redirectToAuthorization?: (url: URL) => Promise<void> }; requestInit?: RequestInit },
-    ) {
-      this.url = url.toString()
-      this.authProvider = options?.authProvider
-      transportCalls.push({
-        type: "streamable",
-        url: url.toString(),
-        options: options ?? {},
-      })
-    }
-    async start() {
-      // Simulate OAuth redirect by calling the authProvider's redirectToAuthorization
-      if (this.authProvider?.redirectToAuthorization) {
-        await this.authProvider.redirectToAuthorization(new URL("https://auth.example.com/authorize?client_id=test"))
-      }
-      throw new MockUnauthorizedError()
-    }
-    async finishAuth(_code: string) {
-      // Mock successful auth completion
-    }
-  },
-}))
-
-void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({
-  SSEClientTransport: class MockSSE {
-    constructor(url: URL) {
-      transportCalls.push({
-        type: "sse",
-        url: url.toString(),
-        options: {},
-      })
-    }
-    async start() {
-      throw new Error("Mock SSE transport cannot connect")
-    }
-  },
-}))
 
-// Mock the MCP SDK Client to trigger OAuth flow
-void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
-  Client: class MockClient {
-    setRequestHandler() {}
+const browsers = new Map<string, { opened: Deferred.Deferred<string>; fail: boolean }>()
+
+const browserLayer = Layer.succeed(
+  McpBrowser.Service,
+  McpBrowser.Service.of({
+    open: (url) =>
+      Effect.gen(function* () {
+        const browser = browsers.get(new URL(url).origin)
+        if (!browser) return yield* Effect.fail(new Error(`Unexpected browser URL: ${url}`))
+        Deferred.doneUnsafe(browser.opened, Effect.succeed(url))
+        if (browser.fail) return yield* Effect.fail(new Error("spawn xdg-open ENOENT"))
+        yield* Effect.tryPromise({
+          try: () => fetch(url).then((response) => response.body?.cancel()),
+          catch: (error) => (error instanceof Error ? error : new Error(String(error))),
+        })
+      }),
+  }),
+)
 
-    async connect(transport: { start: () => Promise<void> }) {
-      await transport.start()
-    }
+const mcpTest = testEffect(
+  LayerNode.compile(LayerNode.group([MCP.node, McpAuth.node, EventV2Bridge.node, Config.node]), [
+    [McpBrowser.node, browserLayer],
+  ]),
+)
 
-    getServerCapabilities() {
-      return { tools: {} }
+const serveOAuthMcp = Effect.acquireRelease(
+  Effect.promise(async () => {
+    const requests: Array<{ pathname: string; headers: Headers }> = []
+    const protocol = new Server({ name: "oauth-browser", version: "1.0.0" }, { capabilities: { tools: {} } })
+    protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] }))
+    const transport = new WebStandardStreamableHTTPServerTransport({
+      sessionIdGenerator: () => crypto.randomUUID(),
+      enableJsonResponse: true,
+    })
+    await protocol.connect(transport)
+
+    const http = Bun.serve({
+      hostname: "127.0.0.1",
+      port: 0,
+      async fetch(request) {
+        const url = new URL(request.url)
+        requests.push({ pathname: url.pathname, headers: new Headers(request.headers) })
+
+        if (url.pathname === "/mcp") {
+          if (request.headers.get("authorization") === "Bearer test-access-token") {
+            return transport.handleRequest(request)
+          }
+          return new Response("Unauthorized", {
+            status: 401,
+            headers: {
+              "WWW-Authenticate": `Bearer resource_metadata="${url.origin}/.well-known/oauth-protected-resource/mcp", scope="mcp"`,
+            },
+          })
+        }
+
+        if (url.pathname === "/.well-known/oauth-protected-resource/mcp") {
+          return Response.json({
+            resource: `${url.origin}/mcp`,
+            authorization_servers: [url.origin],
+            scopes_supported: ["mcp"],
+          })
+        }
+
+        if (url.pathname === "/.well-known/oauth-authorization-server") {
+          return Response.json({
+            issuer: url.origin,
+            authorization_endpoint: `${url.origin}/authorize`,
+            token_endpoint: `${url.origin}/token`,
+            registration_endpoint: `${url.origin}/register`,
+            scopes_supported: ["mcp"],
+            response_types_supported: ["code"],
+            grant_types_supported: ["authorization_code"],
+            token_endpoint_auth_methods_supported: ["none"],
+            code_challenge_methods_supported: ["S256"],
+          })
+        }
+
+        if (url.pathname === "/register") {
+          const metadata = await request.json()
+          if (!metadata || typeof metadata !== "object") return new Response("Invalid metadata", { status: 400 })
+          return Response.json({ ...metadata, client_id: "test-client" }, { status: 201 })
+        }
+
+        if (url.pathname === "/authorize") {
+          const redirect = new URL(url.searchParams.get("redirect_uri") ?? "")
+          redirect.searchParams.set("code", "test-code")
+          const state = url.searchParams.get("state")
+          if (state) redirect.searchParams.set("state", state)
+          return Response.redirect(redirect, 302)
+        }
+
+        if (url.pathname === "/token") {
+          return Response.json({ access_token: "test-access-token", token_type: "Bearer", scope: "mcp" })
+        }
+
+        return new Response("Not found", { status: 404 })
+      },
+    })
+
+    return {
+      requests,
+      url: new URL("/mcp", http.url).toString(),
+      close: async () => {
+        await http.stop(true)
+        await protocol.close()
+      },
     }
-  },
-}))
-
-// Mock UnauthorizedError in the auth module
-void mock.module("@modelcontextprotocol/sdk/client/auth.js", () => ({
-  UnauthorizedError: MockUnauthorizedError,
-}))
-
-beforeEach(() => {
-  openShouldFail = false
-  openCalledWith = undefined
-  openDeferred = undefined
-  transportCalls.length = 0
-})
-
-// Import modules after mocking
-const { MCP } = await import("../../src/mcp/index")
-const { EventV2Bridge } = await import("../../src/event-v2-bridge")
-const { Config } = await import("../../src/config/config")
-const { McpAuth } = await import("../../src/mcp/auth")
-const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback")
-const { FSUtil } = await import("@opencode-ai/core/fs-util")
-const { CrossSpawnSpawner } = await import("@opencode-ai/core/cross-spawn-spawner")
-const mcpTest = testEffect(
-  LayerNode.compile(
-    LayerNode.group([MCP.node, McpAuth.node, EventV2Bridge.node, Config.node, CrossSpawnSpawner.node, FSUtil.node]),
-  ),
+  }),
+  (server) => Effect.promise(server.close),
 )
-const service = MCP.Service as unknown as Effect.Effect<MCPNS.Interface, never, never>
-
-const config = (name: string, headers?: Record<string, string>) => ({
-  mcp: {
-    [name]: {
-      type: "remote" as const,
-      url: "https://example.com/mcp",
-      headers,
-    },
-  },
-})
 
 const withCallbackStop = Effect.addFinalizer(() => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore))
 
-const trackBrowserOpen = Effect.gen(function* () {
-  const opened = yield* Deferred.make<string>()
-  openDeferred = opened
-  yield* Effect.addFinalizer(() => Effect.sync(() => (openDeferred = undefined)))
-  return opened
-})
+const trackBrowserOpen = (url: string, fail = false) =>
+  Effect.gen(function* () {
+    const origin = new URL(url).origin
+    const opened = yield* Deferred.make<string>()
+    browsers.set(origin, { opened, fail })
+    yield* Effect.addFinalizer(() => Effect.sync(() => browsers.delete(origin)))
+    return opened
+  })
 
 const trackBrowserOpenFailed = Effect.gen(function* () {
   const events = yield* EventV2Bridge.Service
@@ -160,84 +146,80 @@ const trackBrowserOpenFailed = Effect.gen(function* () {
   return event
 })
 
-const authenticateScoped = (name: string, onAuthorization?: (authorizationUrl: string) => void) =>
+const addServer = Effect.fnUntraced(function* (name: string, url: string, headers?: Record<string, string>) {
+  const mcp = yield* MCP.Service
+  const result = yield* mcp.add(name, { type: "remote", url, headers })
+  expect(result.status).toMatchObject({ [name]: { status: "needs_auth" } })
+  return mcp
+})
+
+mcpTest.instance("BrowserOpenFailed event is published when browser launch fails", () =>
   Effect.gen(function* () {
-    const mcp = yield* service
-    yield* mcp.authenticate(name, onAuthorization).pipe(
-      Effect.ignore,
-      Effect.catchCause(() => Effect.void),
-      Effect.forkScoped,
+    yield* withCallbackStop
+    const server = yield* serveOAuthMcp
+    yield* trackBrowserOpen(server.url, true)
+
+    const event = yield* trackBrowserOpenFailed
+    const mcp = yield* addServer("test-oauth-server", server.url)
+    yield* mcp.authenticate("test-oauth-server").pipe(Effect.ignore, Effect.forkScoped)
+
+    const failure = yield* awaitWithTimeout(
+      Deferred.await(event),
+      "Timed out waiting for BrowserOpenFailed event",
+      "5 seconds",
     )
-  })
 
-mcpTest.instance(
-  "BrowserOpenFailed event is published when open() throws",
-  () =>
-    Effect.gen(function* () {
-      yield* withCallbackStop
-      openShouldFail = true
-
-      const event = yield* trackBrowserOpenFailed
-      yield* authenticateScoped("test-oauth-server")
-
-      const failure = yield* awaitWithTimeout(
-        Deferred.await(event),
-        "Timed out waiting for BrowserOpenFailed event",
-        "5 seconds",
-      )
-
-      expect(failure.mcpName).toBe("test-oauth-server")
-      expect(failure.url).toContain("https://")
-    }),
-  { config: config("test-oauth-server") },
+    expect(failure.mcpName).toBe("test-oauth-server")
+    expect(failure.url).toStartWith(new URL("/authorize", server.url).toString())
+  }),
 )
 
-mcpTest.instance(
-  "BrowserOpenFailed event is NOT published when open() succeeds",
-  () =>
-    Effect.gen(function* () {
-      yield* withCallbackStop
-      openShouldFail = false
-
-      const opened = yield* trackBrowserOpen
-      const event = yield* trackBrowserOpenFailed
-      yield* authenticateScoped("test-oauth-server-2")
-
-      yield* awaitWithTimeout(Deferred.await(opened), "Timed out waiting for open()", "5 seconds")
-      const failure = yield* Deferred.await(event).pipe(Effect.timeoutOption("700 millis"))
+mcpTest.instance("BrowserOpenFailed event is not published when browser launch succeeds", () =>
+  Effect.gen(function* () {
+    yield* withCallbackStop
+    const server = yield* serveOAuthMcp
+
+    const opened = yield* trackBrowserOpen(server.url)
+    const event = yield* trackBrowserOpenFailed
+    const mcp = yield* addServer("test-oauth-server-2", server.url)
+    const status = yield* awaitWithTimeout(
+      mcp.authenticate("test-oauth-server-2"),
+      "Timed out completing OAuth authentication",
+      "5 seconds",
+    )
+    const url = yield* Deferred.await(opened)
+    const failure = yield* Deferred.await(event).pipe(Effect.timeoutOption("700 millis"))
 
-      expect(failure).toEqual(Option.none())
-      expect(openCalledWith).toBeDefined()
-    }),
-  { config: config("test-oauth-server-2") },
+    expect(status).toEqual({ status: "connected" })
+    expect(failure).toEqual(Option.none())
+    expect(new URL(url).origin).toBe(new URL(server.url).origin)
+  }),
 )
 
-mcpTest.instance(
-  "open() is called with the authorization URL",
-  () =>
-    Effect.gen(function* () {
-      yield* withCallbackStop
-      openShouldFail = false
-      openCalledWith = undefined
-
-      const opened = yield* trackBrowserOpen
-      const event = yield* trackBrowserOpenFailed
-      const authorization = yield* Deferred.make<string>()
-      yield* authenticateScoped("test-oauth-server-3", (url) => Deferred.doneUnsafe(authorization, Effect.succeed(url)))
-
-      const url = yield* awaitWithTimeout(Deferred.await(opened), "Timed out waiting for open()", "5 seconds")
-      const authorizationUrl = yield* awaitWithTimeout(
-        Deferred.await(authorization),
-        "Timed out waiting for authorization URL",
-        "5 seconds",
-      )
-      const failure = yield* Deferred.await(event).pipe(Effect.timeoutOption("700 millis"))
-
-      expect(failure).toEqual(Option.none())
-      expect(authorizationUrl).toBe(url)
-      expect(typeof url).toBe("string")
-      expect(url).toContain("https://")
-      expect(transportCalls.at(-1)?.options.requestInit?.headers).toEqual({ "X-Custom-Header": "custom-value" })
-    }),
-  { config: config("test-oauth-server-3", { "X-Custom-Header": "custom-value" }) },
+mcpTest.instance("browser launch receives the discovered authorization URL", () =>
+  Effect.gen(function* () {
+    yield* withCallbackStop
+    const server = yield* serveOAuthMcp
+
+    const opened = yield* trackBrowserOpen(server.url)
+    const authorization = yield* Deferred.make<string>()
+    const mcp = yield* addServer("test-oauth-server-3", server.url, { "X-Custom-Header": "custom-value" })
+    const status = yield* awaitWithTimeout(
+      mcp.authenticate("test-oauth-server-3", (url) => Deferred.doneUnsafe(authorization, Effect.succeed(url))),
+      "Timed out completing OAuth authentication",
+      "5 seconds",
+    )
+    const url = yield* Deferred.await(opened)
+    const authorizationUrl = yield* Deferred.await(authorization)
+
+    expect(status).toEqual({ status: "connected" })
+    expect(authorizationUrl).toBe(url)
+    expect(new URL(url).pathname).toBe("/authorize")
+    expect(new URL(url).searchParams.get("client_id")).toBe("test-client")
+    expect(
+      server.requests.some(
+        (request) => request.pathname === "/mcp" && request.headers.get("x-custom-header") === "custom-value",
+      ),
+    ).toBe(true)
+  }),
 )