oauth-auto-connect.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. import { expect } from "bun:test"
  2. import { Server } from "@modelcontextprotocol/sdk/server/index.js"
  3. import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
  4. import { ListResourcesRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
  5. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  6. import { CrossSpawnSpawner } from "@kirincode-ai/core/cross-spawn-spawner"
  7. import { FSUtil } from "@kirincode-ai/core/fs-util"
  8. import { Effect } from "effect"
  9. import { Config } from "../../src/config/config"
  10. import { EventV2Bridge } from "../../src/event-v2-bridge"
  11. import { McpAuth } from "../../src/mcp/auth"
  12. import { MCP } from "../../src/mcp/index"
  13. import { McpOAuthCallback } from "../../src/mcp/oauth-callback"
  14. import { McpOAuthPendingProvider, McpOAuthProvider } from "../../src/mcp/oauth-provider"
  15. import { testEffect } from "../lib/effect"
  16. const mcpTest = testEffect(
  17. LayerNode.compile(
  18. LayerNode.group([MCP.node, McpAuth.node, EventV2Bridge.node, Config.node, CrossSpawnSpawner.node, FSUtil.node]),
  19. ),
  20. )
  21. interface OAuthMcpOptions {
  22. capabilities?: "tools" | "resources"
  23. }
  24. function serveOAuthMcp(options: OAuthMcpOptions = {}) {
  25. return Effect.acquireRelease(
  26. Effect.promise(async () => {
  27. const capabilities = options.capabilities ?? "tools"
  28. const protocol = new Server(
  29. { name: "oauth-auto-connect", version: "1.0.0" },
  30. { capabilities: capabilities === "tools" ? { tools: {} } : { resources: {} } },
  31. )
  32. const transport = new WebStandardStreamableHTTPServerTransport({
  33. sessionIdGenerator: () => crypto.randomUUID(),
  34. enableJsonResponse: true,
  35. })
  36. let listToolsCalls = 0
  37. let requiresAuth = true
  38. if (capabilities === "tools") {
  39. protocol.setRequestHandler(ListToolsRequestSchema, () => {
  40. listToolsCalls++
  41. return Promise.resolve({ tools: [{ name: "test_tool", inputSchema: { type: "object" } }] })
  42. })
  43. }
  44. if (capabilities === "resources") {
  45. protocol.setRequestHandler(ListResourcesRequestSchema, () =>
  46. Promise.resolve({ resources: [{ name: "docs", uri: "docs://readme" }] }),
  47. )
  48. }
  49. await protocol.connect(transport)
  50. const http = Bun.serve({
  51. port: 0,
  52. async fetch(request) {
  53. const url = new URL(request.url)
  54. const origin = url.origin
  55. const mcpUrl = `${origin}/mcp`
  56. if (url.pathname === "/.well-known/oauth-protected-resource/mcp") {
  57. return Response.json({
  58. resource: mcpUrl,
  59. authorization_servers: [origin],
  60. scopes_supported: ["mcp"],
  61. })
  62. }
  63. if (url.pathname === "/.well-known/oauth-protected-resource") {
  64. return Response.json({
  65. resource: mcpUrl,
  66. authorization_servers: [origin],
  67. scopes_supported: ["mcp"],
  68. })
  69. }
  70. if (url.pathname === "/.well-known/oauth-authorization-server") {
  71. return Response.json({
  72. issuer: origin,
  73. authorization_endpoint: `${origin}/authorize`,
  74. token_endpoint: `${origin}/token`,
  75. registration_endpoint: `${origin}/register`,
  76. response_types_supported: ["code"],
  77. grant_types_supported: ["authorization_code", "refresh_token"],
  78. token_endpoint_auth_methods_supported: ["none"],
  79. code_challenge_methods_supported: ["S256"],
  80. scopes_supported: ["mcp"],
  81. })
  82. }
  83. if (url.pathname === "/register") {
  84. const metadata = (await request.json()) as Record<string, unknown>
  85. return Response.json({ ...metadata, client_id: "replacement-client" }, { status: 201 })
  86. }
  87. if (url.pathname === "/token") {
  88. const body = new URLSearchParams(await request.text())
  89. if (body.get("code") !== "valid-code") {
  90. return Response.json(
  91. { error: "invalid_grant", error_description: "Token exchange failed" },
  92. { status: 400 },
  93. )
  94. }
  95. return Response.json({ access_token: "replacement-token", token_type: "Bearer" })
  96. }
  97. if (url.pathname !== "/mcp") return new Response("Not found", { status: 404 })
  98. if (request.method === "GET") return new Response(null, { status: 405 })
  99. if (requiresAuth && request.headers.get("authorization") !== "Bearer replacement-token") {
  100. return new Response("Unauthorized", {
  101. status: 401,
  102. headers: {
  103. "WWW-Authenticate": `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource", scope="mcp"`,
  104. },
  105. })
  106. }
  107. return transport.handleRequest(request)
  108. },
  109. })
  110. return {
  111. url: new URL("/mcp", http.url).toString(),
  112. allowAnonymous: () => {
  113. requiresAuth = false
  114. },
  115. listToolsCalls: () => listToolsCalls,
  116. close: async () => {
  117. await http.stop(true)
  118. await protocol.close()
  119. },
  120. }
  121. }),
  122. (server) => Effect.promise(server.close),
  123. )
  124. }
  125. const remote = (url: string, enabled = true) => ({
  126. type: "remote" as const,
  127. url,
  128. enabled,
  129. })
  130. const stopOAuthCallback = Effect.addFinalizer(() => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore))
  131. mcpTest.instance("first connect to OAuth server shows needs_auth instead of failed", () =>
  132. Effect.gen(function* () {
  133. const server = yield* serveOAuthMcp()
  134. const mcp = yield* MCP.Service
  135. const result = yield* mcp.add("test-oauth", remote(server.url))
  136. expect((result.status as Record<string, { status: string }>)["test-oauth"]).toEqual({ status: "needs_auth" })
  137. }),
  138. )
  139. mcpTest.instance("state() generates and persists a new state when none is saved", () =>
  140. Effect.gen(function* () {
  141. const auth = yield* McpAuth.Service
  142. const provider = new McpOAuthProvider(
  143. "test-state-gen",
  144. "https://example.com/mcp",
  145. {},
  146. { onRedirect: async () => {} },
  147. auth,
  148. )
  149. expect((yield* auth.get("test-state-gen"))?.oauthState).toBeUndefined()
  150. const state = yield* Effect.promise(() => provider.state())
  151. expect(state).toHaveLength(64)
  152. expect((yield* auth.get("test-state-gen"))?.oauthState).toBe(state)
  153. }),
  154. )
  155. mcpTest.instance("state() returns existing state when one is saved", () =>
  156. Effect.gen(function* () {
  157. const auth = yield* McpAuth.Service
  158. const provider = new McpOAuthProvider(
  159. "test-state-existing",
  160. "https://example.com/mcp",
  161. {},
  162. { onRedirect: async () => {} },
  163. auth,
  164. )
  165. yield* auth.updateOAuthState("test-state-existing", "pre-saved-state-value")
  166. expect(yield* Effect.promise(() => provider.state())).toBe("pre-saved-state-value")
  167. }),
  168. )
  169. mcpTest.instance("pending provider does not expose or overwrite existing credentials before commit", () =>
  170. Effect.gen(function* () {
  171. const auth = yield* McpAuth.Service
  172. const name = "test-pending-credentials"
  173. const url = "https://example.com/mcp"
  174. const provider = new McpOAuthPendingProvider(name, url, {}, { onRedirect: async () => {} }, auth)
  175. yield* auth.updateClientInfo(name, { clientId: "old-client" }, url)
  176. yield* auth.updateTokens(name, { accessToken: "old-token" }, url)
  177. expect(yield* Effect.promise(() => provider.clientInformation())).toBeUndefined()
  178. expect(yield* Effect.promise(() => provider.tokens())).toBeUndefined()
  179. expect((yield* auth.get(name))?.tokens?.accessToken).toBe("old-token")
  180. expect((yield* auth.get(name))?.clientInfo?.clientId).toBe("old-client")
  181. }),
  182. )
  183. mcpTest.instance("failed reauthentication preserves existing credentials", () =>
  184. Effect.gen(function* () {
  185. yield* stopOAuthCallback
  186. const server = yield* serveOAuthMcp()
  187. const mcp = yield* MCP.Service
  188. const auth = yield* McpAuth.Service
  189. const name = "test-reauth-failure"
  190. yield* auth.updateClientInfo(name, { clientId: "dynamic-client", clientSecret: "dynamic-secret" }, server.url)
  191. yield* auth.updateTokens(name, { accessToken: "working-token" }, server.url)
  192. yield* mcp.add(name, remote(server.url))
  193. expect((yield* mcp.startAuth(name)).authorizationUrl).toContain("/authorize")
  194. expect(yield* mcp.finishAuth(name, "invalid-code")).toEqual({
  195. status: "failed",
  196. error: "OAuth completion failed: Token exchange failed",
  197. })
  198. expect((yield* auth.get(name))?.tokens?.accessToken).toBe("working-token")
  199. expect((yield* auth.get(name))?.clientInfo).toMatchObject({
  200. clientId: "dynamic-client",
  201. clientSecret: "dynamic-secret",
  202. })
  203. }),
  204. )
  205. mcpTest.instance("successful reauthentication commits replacement credentials", () =>
  206. Effect.gen(function* () {
  207. yield* stopOAuthCallback
  208. const server = yield* serveOAuthMcp()
  209. const mcp = yield* MCP.Service
  210. const auth = yield* McpAuth.Service
  211. const name = "test-reauth-success"
  212. yield* auth.updateClientInfo(name, { clientId: "old-client" }, server.url)
  213. yield* auth.updateTokens(name, { accessToken: "old-token" }, server.url)
  214. yield* mcp.add(name, remote(server.url))
  215. expect((yield* mcp.startAuth(name)).authorizationUrl).toContain("/authorize")
  216. expect((yield* auth.get(name))?.tokens?.accessToken).toBe("old-token")
  217. expect((yield* mcp.finishAuth(name, "valid-code")).status).toBe("connected")
  218. const entry = yield* auth.get(name)
  219. expect(entry?.tokens?.accessToken).toBe("replacement-token")
  220. expect(entry?.clientInfo?.clientId).toBe("replacement-client")
  221. expect(entry?.serverUrl).toBe(server.url)
  222. }),
  223. )
  224. mcpTest.instance("auth status only reports credentials stored for the configured server URL", () =>
  225. Effect.gen(function* () {
  226. const mcp = yield* MCP.Service
  227. yield* mcp.add("test-status-url", remote("https://example.com/mcp", false))
  228. yield* McpAuth.use.updateTokens("test-status-url", { accessToken: "old-token" }, "https://old.example.com/mcp")
  229. expect(yield* mcp.getAuthStatus("test-status-url")).toBe("not_authenticated")
  230. yield* McpAuth.use.updateTokens("test-status-url", { accessToken: "current-token" }, "https://example.com/mcp")
  231. expect(yield* mcp.getAuthStatus("test-status-url")).toBe("authenticated")
  232. yield* McpAuth.use.updateTokens(
  233. "test-status-url",
  234. { accessToken: "expired-token", expiresAt: 1 },
  235. "https://example.com/mcp",
  236. )
  237. expect(yield* mcp.getAuthStatus("test-status-url")).toBe("expired")
  238. }),
  239. )
  240. mcpTest.instance("authenticate() stores a connected client when auth completes without redirect", () =>
  241. Effect.gen(function* () {
  242. yield* stopOAuthCallback
  243. const server = yield* serveOAuthMcp()
  244. const mcp = yield* MCP.Service
  245. const name = "test-oauth-connect"
  246. const added = yield* mcp.add(name, remote(server.url))
  247. expect((added.status as Record<string, { status: string }>)[name]?.status).toBe("needs_auth")
  248. server.allowAnonymous()
  249. expect((yield* mcp.authenticate(name)).status).toBe("connected")
  250. expect((yield* mcp.status())[name]?.status).toBe("connected")
  251. }),
  252. )
  253. mcpTest.instance("authenticate() connects a resource-only server without listing tools", () =>
  254. Effect.gen(function* () {
  255. yield* stopOAuthCallback
  256. const server = yield* serveOAuthMcp({ capabilities: "resources" })
  257. const mcp = yield* MCP.Service
  258. const name = "test-oauth-resources"
  259. const added = yield* mcp.add(name, remote(server.url))
  260. expect((added.status as Record<string, { status: string }>)[name]?.status).toBe("needs_auth")
  261. server.allowAnonymous()
  262. expect((yield* mcp.authenticate(name)).status).toBe("connected")
  263. expect(server.listToolsCalls()).toBe(0)
  264. expect(Object.keys(yield* mcp.resources())).toEqual([`${name}:docs://readme`])
  265. }),
  266. )