Bladeren bron

test(mcp): isolate anonymous OAuth integration

Aiden Cline 1 maand geleden
bovenliggende
commit
0b7a0b1a80

+ 1 - 3
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
           },
         },

+ 81 - 0
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<Response> {
+    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)
+}

+ 28 - 80
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)