From 5ad089fc01d1c01832587c3fbcb064093a3f4141 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 24 Jun 2026 16:24:59 -0500 Subject: [PATCH 1/2] fix(mcp): verify explicit OAuth authentication --- packages/opencode/src/mcp/index.ts | 12 +++ .../opencode/test/mcp/oauth-anonymous.test.ts | 82 +++++++++++++++++++ .../test/mcp/oauth-auto-connect.test.ts | 13 ++- 3 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/test/mcp/oauth-anonymous.test.ts diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index db673244b3..22c6e90605 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -859,6 +859,7 @@ export const layer = Layer.effect( }) const authenticate = Effect.fn("MCP.authenticate")(function* (mcpName: string) { + const previousTokens = (yield* auth.get(mcpName))?.tokens const result = yield* startAuth(mcpName) if (!result.authorizationUrl) { const client = "client" in result ? result.client : undefined @@ -876,6 +877,17 @@ export const layer = Layer.effect( return { status: "failed", error: "Failed to get tools" } satisfies Status } + const currentTokens = (yield* auth.get(mcpName))?.tokens + if (!currentTokens || JSON.stringify(currentTokens) === JSON.stringify(previousTokens)) { + yield* Effect.tryPromise(() => client.close()).pipe(Effect.ignore) + yield* auth.clearOAuthState(mcpName) + return { + status: "failed", + error: + "The server did not issue a standard OAuth challenge. Anonymous MCP access remains available, but authentication was not completed. Verify the server's OAuth configuration or use credentials supported by the server.", + } satisfies Status + } + const s = yield* InstanceState.get(state) yield* auth.clearOAuthState(mcpName) return yield* storeClient(s, mcpName, client, listed, client.getInstructions()?.trim(), mcpConfig.timeout) diff --git a/packages/opencode/test/mcp/oauth-anonymous.test.ts b/packages/opencode/test/mcp/oauth-anonymous.test.ts new file mode 100644 index 0000000000..70a5fd96eb --- /dev/null +++ b/packages/opencode/test/mcp/oauth-anonymous.test.ts @@ -0,0 +1,82 @@ +import { afterAll, expect } from "bun:test" +import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/sdk/types.js" +import { Effect } from "effect" +import { MCP } from "../../src/mcp/index" +import { testEffect } from "../lib/effect" + +const server = Bun.serve({ + port: 0, + async fetch(request) { + if (request.method !== "POST") return new Response(null, { status: 405 }) + + const message = (await request.json()) as { id?: number; method: string } + if (message.method === "initialize") { + return Response.json({ + jsonrpc: "2.0", + id: message.id, + result: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: { name: "anonymous-oauth-test", version: "1" }, + }, + }) + } + if (message.method === "notifications/initialized") return new Response(null, { status: 202 }) + if (message.method === "tools/list") { + return Response.json({ + jsonrpc: "2.0", + id: message.id, + result: { + tools: [{ name: "protected", inputSchema: { type: "object", properties: {} } }], + }, + }) + } + if (message.method === "tools/call") { + return new Response("Authentication required", { + status: 401, + headers: { "WWW-Authenticate": `Bearer resource_metadata="${server.url}.well-known/oauth-protected-resource"` }, + }) + } + return Response.json({ jsonrpc: "2.0", id: message.id, error: { code: -32601, message: "Method not found" } }) + }, +}) + +afterAll(() => server.stop(true)) + +const it = testEffect(MCP.defaultLayer) + +it.instance( + "explicit auth fails when anonymous initialize and catalog emit no OAuth challenge", + () => + MCP.Service.use((mcp) => + Effect.gen(function* () { + const added = yield* mcp.add("anonymous-oauth", { type: "remote", url: server.url.toString() }) + expect(added.status).toEqual({ "anonymous-oauth": { status: "connected" } }) + expect(Object.keys(yield* mcp.tools())).toEqual(["anonymous-oauth_protected"]) + + const protectedResponse = yield* Effect.promise(() => + fetch(server.url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "protected", arguments: {} }, + }), + }), + ) + expect(protectedResponse.status).toBe(401) + + const result = yield* mcp.authenticate("anonymous-oauth") + expect(result).toEqual({ + status: "failed", + error: + "The server did not issue a standard OAuth challenge. Anonymous MCP access remains available, but authentication was not completed. Verify the server's OAuth configuration or use credentials supported by the server.", + }) + expect(yield* mcp.hasStoredTokens("anonymous-oauth")).toBe(false) + expect(yield* mcp.status()).toEqual({ "anonymous-oauth": { status: "connected" } }) + }), + ), + { config: { mcp: { "anonymous-oauth": { type: "remote", url: server.url.toString() } } } }, +) diff --git a/packages/opencode/test/mcp/oauth-auto-connect.test.ts b/packages/opencode/test/mcp/oauth-auto-connect.test.ts index 480792b1cb..9467d71c25 100644 --- a/packages/opencode/test/mcp/oauth-auto-connect.test.ts +++ b/packages/opencode/test/mcp/oauth-auto-connect.test.ts @@ -21,6 +21,7 @@ const transportCalls: Array<{ // auth flow (which calls provider.state()) or a simple UnauthorizedError. let simulateAuthFlow = true let connectSucceedsImmediately = false +let saveTokensOnConnect = false let serverCapabilities: { tools?: object; resources?: object } = { tools: {} } let listToolsCalls = 0 @@ -32,6 +33,7 @@ void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ state?: () => Promise redirectToAuthorization?: (url: URL) => Promise saveCodeVerifier?: (v: string) => Promise + saveTokens?: (tokens: { access_token: string; token_type: string }) => Promise } | undefined constructor(url: URL, options?: { authProvider?: unknown }) { @@ -43,7 +45,11 @@ void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ }) } async start() { - if (connectSucceedsImmediately) return + if (connectSucceedsImmediately) { + if (saveTokensOnConnect) + await this.authProvider?.saveTokens?.({ access_token: "new-token", token_type: "bearer" }) + return + } // Simulate what the real SDK transport does on 401: // It calls auth() which eventually calls provider.state(), then @@ -123,6 +129,7 @@ beforeEach(() => { transportCalls.length = 0 simulateAuthFlow = true connectSucceedsImmediately = false + saveTokensOnConnect = false serverCapabilities = { tools: {} } listToolsCalls = 0 }) @@ -228,7 +235,7 @@ mcpTest.instance("state() returns existing state when one is saved", () => ) mcpTest.instance( - "authenticate() stores a connected client when auth completes without redirect", + "authenticate() stores a connected client when stored credentials connect without redirect", () => MCP.Service.use((mcp) => Effect.gen(function* () { @@ -241,6 +248,7 @@ mcpTest.instance( simulateAuthFlow = false connectSucceedsImmediately = true + saveTokensOnConnect = true const result = yield* mcp.authenticate("test-oauth-connect") expect(result.status).toBe("connected") @@ -266,6 +274,7 @@ mcpTest.instance( simulateAuthFlow = false connectSucceedsImmediately = true + saveTokensOnConnect = true serverCapabilities = { resources: {} } const result = yield* mcp.authenticate("test-oauth-resources") From 0b7a0b1a801c7338f490ebf18742b63f181fb77c Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 24 Jun 2026 17:05:51 -0500 Subject: [PATCH 2/2] test(mcp): isolate anonymous OAuth integration --- packages/opencode/src/mcp/index.ts | 4 +- .../test/fixture/mcp-oauth-anonymous.ts | 81 +++++++++++++ .../opencode/test/mcp/oauth-anonymous.test.ts | 108 +++++------------- 3 files changed, 110 insertions(+), 83 deletions(-) create mode 100644 packages/opencode/test/fixture/mcp-oauth-anonymous.ts diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 22c6e90605..ddd8bb23ab 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -808,9 +808,6 @@ export const layer = Layer.effect( oauthConfig?.redirectUri ?? (oauthConfig?.callbackPort ? `http://127.0.0.1:${oauthConfig.callbackPort}${OAUTH_CALLBACK_PATH}` : undefined) - // Start the callback server with custom redirectUri if configured - yield* Effect.promise(() => McpOAuthCallback.ensureRunning(effectiveRedirectUri)) - const oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32))) .map((b) => b.toString(16).padStart(2, "0")) .join("") @@ -827,6 +824,7 @@ export const layer = Layer.effect( }, { onRedirect: async (url) => { + await McpOAuthCallback.ensureRunning(effectiveRedirectUri) capturedUrl = url }, }, diff --git a/packages/opencode/test/fixture/mcp-oauth-anonymous.ts b/packages/opencode/test/fixture/mcp-oauth-anonymous.ts new file mode 100644 index 0000000000..531fa73af6 --- /dev/null +++ b/packages/opencode/test/fixture/mcp-oauth-anonymous.ts @@ -0,0 +1,81 @@ +import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/sdk/types.js" +import { Effect } from "effect" +import { MCP } from "../../src/mcp/index" +import { withTmpdirInstance } from "./fixture" + +const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request): Promise { + if (request.method !== "POST") return new Response(null, { status: 405 }) + + const message = (await request.json()) as { id?: number; method: string } + if (message.method === "initialize") { + return Response.json({ + jsonrpc: "2.0", + id: message.id, + result: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: { name: "anonymous-oauth-test", version: "1" }, + }, + }) + } + if (message.method === "notifications/initialized") return new Response(null, { status: 202 }) + if (message.method === "tools/list") { + return Response.json({ + jsonrpc: "2.0", + id: message.id, + result: { + tools: [{ name: "protected", inputSchema: { type: "object", properties: {} } }], + }, + }) + } + if (message.method === "tools/call") { + return new Response("Authentication required", { + status: 401, + headers: { + "WWW-Authenticate": `Bearer resource_metadata="${new URL("/.well-known/oauth-protected-resource", request.url)}"`, + }, + }) + } + return Response.json({ jsonrpc: "2.0", id: message.id, error: { code: -32601, message: "Method not found" } }) + }, +}) + +try { + const result = await Effect.gen(function* () { + const mcp = yield* MCP.Service + const added = yield* mcp.add("anonymous-oauth", { type: "remote", url: server.url.toString() }) + const initialTools = Object.keys(yield* mcp.tools()) + const client = (yield* mcp.clients())["anonymous-oauth"] + const protectedToolFailed = yield* Effect.promise(() => + client + .callTool({ name: "protected", arguments: {} }) + .then(() => false) + .catch(() => true), + ) + const auth = yield* mcp.authenticate("anonymous-oauth") + + return { + initialStatus: "status" in added.status ? added.status.status : added.status["anonymous-oauth"]?.status, + initialTools, + protectedToolFailed, + authStatus: auth.status, + authError: auth.status === "failed" ? auth.error : undefined, + hasStoredTokens: yield* mcp.hasStoredTokens("anonymous-oauth"), + finalStatus: (yield* mcp.status())["anonymous-oauth"]?.status, + finalTools: Object.keys(yield* mcp.tools()), + } + }).pipe( + withTmpdirInstance({ + config: { mcp: { "anonymous-oauth": { type: "remote", url: server.url.toString() } } }, + }), + Effect.provide(MCP.defaultLayer), + Effect.scoped, + Effect.runPromise, + ) + process.stdout.write(`MCP_OAUTH_RESULT=${JSON.stringify(result)}`) +} finally { + server.stop(true) +} diff --git a/packages/opencode/test/mcp/oauth-anonymous.test.ts b/packages/opencode/test/mcp/oauth-anonymous.test.ts index 70a5fd96eb..02cc2488ae 100644 --- a/packages/opencode/test/mcp/oauth-anonymous.test.ts +++ b/packages/opencode/test/mcp/oauth-anonymous.test.ts @@ -1,82 +1,30 @@ -import { afterAll, expect } from "bun:test" -import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/sdk/types.js" -import { Effect } from "effect" -import { MCP } from "../../src/mcp/index" -import { testEffect } from "../lib/effect" +import path from "node:path" +import { expect, test } from "bun:test" -const server = Bun.serve({ - port: 0, - async fetch(request) { - if (request.method !== "POST") return new Response(null, { status: 405 }) +test("explicit auth fails when anonymous initialize and catalog emit no OAuth challenge", async () => { + const child = Bun.spawn([process.execPath, path.join(import.meta.dir, "../fixture/mcp-oauth-anonymous.ts")], { + cwd: path.join(import.meta.dir, "../.."), + stdout: "pipe", + stderr: "pipe", + }) + const [code, stdout, stderr] = await Promise.all([ + child.exited, + Bun.readableStreamToText(child.stdout), + Bun.readableStreamToText(child.stderr), + ]) - const message = (await request.json()) as { id?: number; method: string } - if (message.method === "initialize") { - return Response.json({ - jsonrpc: "2.0", - id: message.id, - result: { - protocolVersion: LATEST_PROTOCOL_VERSION, - capabilities: { tools: {} }, - serverInfo: { name: "anonymous-oauth-test", version: "1" }, - }, - }) - } - if (message.method === "notifications/initialized") return new Response(null, { status: 202 }) - if (message.method === "tools/list") { - return Response.json({ - jsonrpc: "2.0", - id: message.id, - result: { - tools: [{ name: "protected", inputSchema: { type: "object", properties: {} } }], - }, - }) - } - if (message.method === "tools/call") { - return new Response("Authentication required", { - status: 401, - headers: { "WWW-Authenticate": `Bearer resource_metadata="${server.url}.well-known/oauth-protected-resource"` }, - }) - } - return Response.json({ jsonrpc: "2.0", id: message.id, error: { code: -32601, message: "Method not found" } }) - }, -}) - -afterAll(() => server.stop(true)) - -const it = testEffect(MCP.defaultLayer) - -it.instance( - "explicit auth fails when anonymous initialize and catalog emit no OAuth challenge", - () => - MCP.Service.use((mcp) => - Effect.gen(function* () { - const added = yield* mcp.add("anonymous-oauth", { type: "remote", url: server.url.toString() }) - expect(added.status).toEqual({ "anonymous-oauth": { status: "connected" } }) - expect(Object.keys(yield* mcp.tools())).toEqual(["anonymous-oauth_protected"]) - - const protectedResponse = yield* Effect.promise(() => - fetch(server.url, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: 1, - method: "tools/call", - params: { name: "protected", arguments: {} }, - }), - }), - ) - expect(protectedResponse.status).toBe(401) - - const result = yield* mcp.authenticate("anonymous-oauth") - expect(result).toEqual({ - status: "failed", - error: - "The server did not issue a standard OAuth challenge. Anonymous MCP access remains available, but authentication was not completed. Verify the server's OAuth configuration or use credentials supported by the server.", - }) - expect(yield* mcp.hasStoredTokens("anonymous-oauth")).toBe(false) - expect(yield* mcp.status()).toEqual({ "anonymous-oauth": { status: "connected" } }) - }), - ), - { config: { mcp: { "anonymous-oauth": { type: "remote", url: server.url.toString() } } } }, -) + expect(code, stderr).toBe(0) + const marker = "MCP_OAUTH_RESULT=" + expect(stdout).toContain(marker) + expect(JSON.parse(stdout.slice(stdout.lastIndexOf(marker) + marker.length))).toEqual({ + initialStatus: "connected", + initialTools: ["anonymous-oauth_protected"], + protectedToolFailed: true, + authStatus: "failed", + authError: + "The server did not issue a standard OAuth challenge. Anonymous MCP access remains available, but authentication was not completed. Verify the server's OAuth configuration or use credentials supported by the server.", + hasStoredTokens: false, + finalStatus: "connected", + finalTools: ["anonymous-oauth_protected"], + }) +}, 30_000)