chore: merge dev into v2 (#35591)

Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: runvip <164729189+runvip@users.noreply.github.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Julian Coy <julian@ex-machina.co>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
This commit is contained in:
Aiden Cline
2026-07-06 16:05:29 -05:00
committed by GitHub
co-authored by Frank Aarav Sareen Brendan Allan opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Jack Brendan Allan Shoubhit Dash opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> James Long Dustin Deus starptech Luke Parker 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 Dax usrnk1 Jay runvip opencode Julian Coy Vladimir Glafirov Adam Kit Langton Simon Klee Jay David Hill
parent f87998f37f
commit 9e0d3976e1
332 changed files with 24650 additions and 4497 deletions
+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: {} }
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") },
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", () =>
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)
}),
)