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

This commit is contained in:
Aiden Cline
2026-07-05 12:49:31 -05:00
committed by GitHub
parent be73f465df
commit d3459eb740
7 changed files with 1035 additions and 1840 deletions
+37
View File
@@ -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
View File
@@ -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 "."
@@ -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())
+70 -95
View File
@@ -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")
}
},
}))
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 ?? {},
})
}
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))
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()
},
}
}),
(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))
const result = yield* mcp.add("test-server-no-headers", {
type: "remote",
url: server.url,
})
expect(transportCalls.length).toBeGreaterThanOrEqual(1)
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)
}
}),
)
File diff suppressed because it is too large Load Diff
@@ -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"
}
}
// 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 ?? {},
})
}
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()
}
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" })
}
}
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")
const mcpTest = testEffect(
LayerNode.compile(
LayerNode.group([MCP.node, McpAuth.node, EventV2Bridge.node, Config.node, CrossSpawnSpawner.node, FSUtil.node]),
),
)
const config = (name: string) => ({
mcp: {
[name]: {
type: "remote" as const,
url: "https://example.com/mcp",
},
},
interface OAuthMcpOptions {
capabilities?: "tools" | "resources"
}
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,
})
let listToolsCalls = 0
let requiresAuth = true
if (capabilities === "tools") {
protocol.setRequestHandler(ListToolsRequestSchema, () => {
listToolsCalls++
return Promise.resolve({ tools: [{ name: "test_tool", inputSchema: { type: "object" } }] })
})
}
if (capabilities === "resources") {
protocol.setRequestHandler(ListResourcesRequestSchema, () =>
Promise.resolve({ resources: [{ name: "docs", uri: "docs://readme" }] }),
)
}
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)
},
})
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 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 }>
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))
// 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") },
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)
// state() should return the existing state
const state = yield* Effect.promise(() => provider.state())
expect(state).toBe(existingState)
yield* auth.updateOAuthState("test-state-existing", "pre-saved-state-value")
expect(yield* Effect.promise(() => provider.state())).toBe("pre-saved-state-value")
}),
)
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" }
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, clientInfo, url)
yield* auth.updateTokens(name, { accessToken: "working-token" }, url)
expect((yield* mcp.startAuth(name)).authorizationUrl).toContain("https://auth.example.com/authorize")
finishAuthFails = true
yield* auth.updateClientInfo(name, { clientId: "old-client" }, url)
yield* auth.updateTokens(name, { accessToken: "old-token" }, url)
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") },
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(
"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"
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: "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
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, "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") },
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(
"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("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"
expect(yield* mcp.getAuthStatus("test-status-url")).toBe("not_authenticated")
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")
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") },
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(
"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")
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")
simulateAuthFlow = false
connectSucceedsImmediately = true
expect(yield* mcp.getAuthStatus("test-status-url")).toBe("not_authenticated")
const result = yield* mcp.authenticate("test-oauth-connect")
expect(result.status).toBe("connected")
yield* McpAuth.use.updateTokens("test-status-url", { accessToken: "current-token" }, "https://example.com/mcp")
expect(yield* mcp.getAuthStatus("test-status-url")).toBe("authenticated")
const after = yield* mcp.status()
expect(after["test-oauth-connect"]?.status).toBe("connected")
}),
),
{ config: config("test-oauth-connect") },
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() 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")
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")
simulateAuthFlow = false
connectSucceedsImmediately = true
serverCapabilities = { resources: {} }
server.allowAnonymous()
expect((yield* mcp.authenticate(name)).status).toBe("connected")
expect((yield* mcp.status())[name]?.status).toBe("connected")
}),
)
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`])
}),
)
+190 -208
View File
@@ -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
const browsers = new Map<string, { opened: Deferred.Deferred<string>; fail: boolean }>()
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() {}
async connect(transport: { start: () => Promise<void> }) {
await transport.start()
}
getServerCapabilities() {
return { tools: {} }
}
},
}))
// 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]),
),
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))),
})
}),
}),
)
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 mcpTest = testEffect(
LayerNode.compile(LayerNode.group([MCP.node, McpAuth.node, EventV2Bridge.node, Config.node]), [
[McpBrowser.node, browserLayer],
]),
)
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()
},
}
}),
(server) => Effect.promise(server.close),
)
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
mcpTest.instance("BrowserOpenFailed event is not published when browser launch succeeds", () =>
Effect.gen(function* () {
yield* withCallbackStop
const server = yield* serveOAuthMcp
const opened = yield* trackBrowserOpen
const event = yield* trackBrowserOpenFailed
yield* authenticateScoped("test-oauth-server-2")
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"))
yield* awaitWithTimeout(Deferred.await(opened), "Timed out waiting for open()", "5 seconds")
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
mcpTest.instance("browser launch receives the discovered authorization URL", () =>
Effect.gen(function* () {
yield* withCallbackStop
const server = yield* serveOAuthMcp
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 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)
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" }) },
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)
}),
)