| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300 |
- 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 "@kirincode-ai/core/effect/layer-node"
- import { CrossSpawnSpawner } from "@kirincode-ai/core/cross-spawn-spawner"
- import { FSUtil } from "@kirincode-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`])
- }),
- )
|