301 lines
12 KiB
TypeScript
301 lines
12 KiB
TypeScript
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<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,
|
|
})
|
|
|
|
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<string, { status: string }>)["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<string, { status: string }>)[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<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`])
|
|
}),
|
|
)
|