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 { 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" const mcpTest = testEffect( LayerNode.compile( LayerNode.group([MCP.node, McpAuth.node, EventV2Bridge.node, Config.node, CrossSpawnSpawner.node, FSUtil.node]), ), ) 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 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, }) const stopOAuthCallback = Effect.addFinalizer(() => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore)) 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)["test-oauth"]).toEqual({ status: "needs_auth" }) }), ) 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( "test-state-gen", "https://example.com/mcp", {}, { onRedirect: async () => {} }, auth, ) expect((yield* auth.get("test-state-gen"))?.oauthState).toBeUndefined() const state = yield* Effect.promise(() => provider.state()) expect(state).toHaveLength(64) expect((yield* auth.get("test-state-gen"))?.oauthState).toBe(state) }), ) mcpTest.instance("state() returns existing state when one is saved", () => Effect.gen(function* () { const auth = yield* McpAuth.Service const provider = new McpOAuthProvider( "test-state-existing", "https://example.com/mcp", {}, { onRedirect: async () => {} }, auth, ) yield* auth.updateOAuthState("test-state-existing", "pre-saved-state-value") expect(yield* Effect.promise(() => provider.state())).toBe("pre-saved-state-value") }), ) 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* 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* 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 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") 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") }), ) 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)[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", () => 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)[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`]) }), )