test(mcp): isolate anonymous OAuth integration
This commit is contained in:
parent
5ad089fc01
commit
0b7a0b1a80
3 changed files with 110 additions and 83 deletions
|
|
@ -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
packages/opencode/test/fixture/mcp-oauth-anonymous.ts
Normal file
81
packages/opencode/test/fixture/mcp-oauth-anonymous.ts
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue