oauth-browser.test.ts 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  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 { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
  5. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  6. import { Deferred, Effect, Layer, Option } from "effect"
  7. import { Config } from "../../src/config/config"
  8. import { EventV2Bridge } from "../../src/event-v2-bridge"
  9. import { McpAuth } from "../../src/mcp/auth"
  10. import { McpBrowser } from "../../src/mcp/browser"
  11. import { MCP } from "../../src/mcp/index"
  12. import { McpOAuthCallback } from "../../src/mcp/oauth-callback"
  13. import { awaitWithTimeout, testEffect } from "../lib/effect"
  14. const browsers = new Map<string, { opened: Deferred.Deferred<string>; fail: boolean }>()
  15. const browserLayer = Layer.succeed(
  16. McpBrowser.Service,
  17. McpBrowser.Service.of({
  18. open: (url) =>
  19. Effect.gen(function* () {
  20. const browser = browsers.get(new URL(url).origin)
  21. if (!browser) return yield* Effect.fail(new Error(`Unexpected browser URL: ${url}`))
  22. Deferred.doneUnsafe(browser.opened, Effect.succeed(url))
  23. if (browser.fail) return yield* Effect.fail(new Error("spawn xdg-open ENOENT"))
  24. yield* Effect.tryPromise({
  25. try: () => fetch(url).then((response) => response.body?.cancel()),
  26. catch: (error) => (error instanceof Error ? error : new Error(String(error))),
  27. })
  28. }),
  29. }),
  30. )
  31. const mcpTest = testEffect(
  32. LayerNode.compile(LayerNode.group([MCP.node, McpAuth.node, EventV2Bridge.node, Config.node]), [
  33. [McpBrowser.node, browserLayer],
  34. ]),
  35. )
  36. const serveOAuthMcp = Effect.acquireRelease(
  37. Effect.promise(async () => {
  38. const requests: Array<{ pathname: string; headers: Headers }> = []
  39. const protocol = new Server({ name: "oauth-browser", version: "1.0.0" }, { capabilities: { tools: {} } })
  40. protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] }))
  41. const transport = new WebStandardStreamableHTTPServerTransport({
  42. sessionIdGenerator: () => crypto.randomUUID(),
  43. enableJsonResponse: true,
  44. })
  45. await protocol.connect(transport)
  46. const http = Bun.serve({
  47. hostname: "127.0.0.1",
  48. port: 0,
  49. async fetch(request) {
  50. const url = new URL(request.url)
  51. requests.push({ pathname: url.pathname, headers: new Headers(request.headers) })
  52. if (url.pathname === "/mcp") {
  53. if (request.headers.get("authorization") === "Bearer test-access-token") {
  54. return transport.handleRequest(request)
  55. }
  56. return new Response("Unauthorized", {
  57. status: 401,
  58. headers: {
  59. "WWW-Authenticate": `Bearer resource_metadata="${url.origin}/.well-known/oauth-protected-resource/mcp", scope="mcp"`,
  60. },
  61. })
  62. }
  63. if (url.pathname === "/.well-known/oauth-protected-resource/mcp") {
  64. return Response.json({
  65. resource: `${url.origin}/mcp`,
  66. authorization_servers: [url.origin],
  67. scopes_supported: ["mcp"],
  68. })
  69. }
  70. if (url.pathname === "/.well-known/oauth-authorization-server") {
  71. return Response.json({
  72. issuer: url.origin,
  73. authorization_endpoint: `${url.origin}/authorize`,
  74. token_endpoint: `${url.origin}/token`,
  75. registration_endpoint: `${url.origin}/register`,
  76. scopes_supported: ["mcp"],
  77. response_types_supported: ["code"],
  78. grant_types_supported: ["authorization_code"],
  79. token_endpoint_auth_methods_supported: ["none"],
  80. code_challenge_methods_supported: ["S256"],
  81. })
  82. }
  83. if (url.pathname === "/register") {
  84. const metadata = await request.json()
  85. if (!metadata || typeof metadata !== "object") return new Response("Invalid metadata", { status: 400 })
  86. return Response.json({ ...metadata, client_id: "test-client" }, { status: 201 })
  87. }
  88. if (url.pathname === "/authorize") {
  89. const redirect = new URL(url.searchParams.get("redirect_uri") ?? "")
  90. redirect.searchParams.set("code", "test-code")
  91. const state = url.searchParams.get("state")
  92. if (state) redirect.searchParams.set("state", state)
  93. return Response.redirect(redirect, 302)
  94. }
  95. if (url.pathname === "/token") {
  96. return Response.json({ access_token: "test-access-token", token_type: "Bearer", scope: "mcp" })
  97. }
  98. return new Response("Not found", { status: 404 })
  99. },
  100. })
  101. return {
  102. requests,
  103. url: new URL("/mcp", http.url).toString(),
  104. close: async () => {
  105. await http.stop(true)
  106. await protocol.close()
  107. },
  108. }
  109. }),
  110. (server) => Effect.promise(server.close),
  111. )
  112. const withCallbackStop = Effect.addFinalizer(() => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore))
  113. const trackBrowserOpen = (url: string, fail = false) =>
  114. Effect.gen(function* () {
  115. const origin = new URL(url).origin
  116. const opened = yield* Deferred.make<string>()
  117. browsers.set(origin, { opened, fail })
  118. yield* Effect.addFinalizer(() => Effect.sync(() => browsers.delete(origin)))
  119. return opened
  120. })
  121. const trackBrowserOpenFailed = Effect.gen(function* () {
  122. const events = yield* EventV2Bridge.Service
  123. const event = yield* Deferred.make<{ mcpName: string; url: string }>()
  124. const unsubscribe = yield* events.listen((evt) => {
  125. if (evt.type === MCP.BrowserOpenFailed.type)
  126. Deferred.doneUnsafe(event, Effect.succeed(evt.data as { mcpName: string; url: string }))
  127. return Effect.void
  128. })
  129. yield* Effect.addFinalizer(() => unsubscribe)
  130. return event
  131. })
  132. const addServer = Effect.fnUntraced(function* (name: string, url: string, headers?: Record<string, string>) {
  133. const mcp = yield* MCP.Service
  134. const result = yield* mcp.add(name, { type: "remote", url, headers })
  135. expect(result.status).toMatchObject({ [name]: { status: "needs_auth" } })
  136. return mcp
  137. })
  138. mcpTest.instance("BrowserOpenFailed event is published when browser launch fails", () =>
  139. Effect.gen(function* () {
  140. yield* withCallbackStop
  141. const server = yield* serveOAuthMcp
  142. yield* trackBrowserOpen(server.url, true)
  143. const event = yield* trackBrowserOpenFailed
  144. const mcp = yield* addServer("test-oauth-server", server.url)
  145. yield* mcp.authenticate("test-oauth-server").pipe(Effect.ignore, Effect.forkScoped)
  146. const failure = yield* awaitWithTimeout(
  147. Deferred.await(event),
  148. "Timed out waiting for BrowserOpenFailed event",
  149. "5 seconds",
  150. )
  151. expect(failure.mcpName).toBe("test-oauth-server")
  152. expect(failure.url).toStartWith(new URL("/authorize", server.url).toString())
  153. }),
  154. )
  155. mcpTest.instance("BrowserOpenFailed event is not published when browser launch succeeds", () =>
  156. Effect.gen(function* () {
  157. yield* withCallbackStop
  158. const server = yield* serveOAuthMcp
  159. const opened = yield* trackBrowserOpen(server.url)
  160. const event = yield* trackBrowserOpenFailed
  161. const mcp = yield* addServer("test-oauth-server-2", server.url)
  162. const status = yield* awaitWithTimeout(
  163. mcp.authenticate("test-oauth-server-2"),
  164. "Timed out completing OAuth authentication",
  165. "5 seconds",
  166. )
  167. const url = yield* Deferred.await(opened)
  168. const failure = yield* Deferred.await(event).pipe(Effect.timeoutOption("700 millis"))
  169. expect(status).toEqual({ status: "connected" })
  170. expect(failure).toEqual(Option.none())
  171. expect(new URL(url).origin).toBe(new URL(server.url).origin)
  172. }),
  173. )
  174. mcpTest.instance("browser launch receives the discovered authorization URL", () =>
  175. Effect.gen(function* () {
  176. yield* withCallbackStop
  177. const server = yield* serveOAuthMcp
  178. const opened = yield* trackBrowserOpen(server.url)
  179. const authorization = yield* Deferred.make<string>()
  180. const mcp = yield* addServer("test-oauth-server-3", server.url, { "X-Custom-Header": "custom-value" })
  181. const status = yield* awaitWithTimeout(
  182. mcp.authenticate("test-oauth-server-3", (url) => Deferred.doneUnsafe(authorization, Effect.succeed(url))),
  183. "Timed out completing OAuth authentication",
  184. "5 seconds",
  185. )
  186. const url = yield* Deferred.await(opened)
  187. const authorizationUrl = yield* Deferred.await(authorization)
  188. expect(status).toEqual({ status: "connected" })
  189. expect(authorizationUrl).toBe(url)
  190. expect(new URL(url).pathname).toBe("/authorize")
  191. expect(new URL(url).searchParams.get("client_id")).toBe("test-client")
  192. expect(
  193. server.requests.some(
  194. (request) => request.pathname === "/mcp" && request.headers.get("x-custom-header") === "custom-value",
  195. ),
  196. ).toBe(true)
  197. }),
  198. )