|
| 1 | +import { afterAll, beforeAll, describe, expect, it } from "bun:test" |
| 2 | +import { createHash, randomBytes } from "node:crypto" |
| 3 | +import { Client } from "@modelcontextprotocol/sdk/client/index.js" |
| 4 | +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" |
| 5 | +import { db } from "../../server/db" |
| 6 | +import { projects, projectMembers } from "../../server/db/schema" |
| 7 | +import { apiFetch, createUser, signIn, truncateDomain } from "../helpers" |
| 8 | + |
| 9 | +const BASE = process.env.TEST_BASE_URL ?? "http://localhost:3000" |
| 10 | + |
| 11 | +beforeAll(() => { |
| 12 | + if (process.env.MCP_ENABLED !== "true") { |
| 13 | + throw new Error("Run integration tests with MCP_ENABLED=true (the dev server too).") |
| 14 | + } |
| 15 | +}) |
| 16 | + |
| 17 | +afterAll(async () => { |
| 18 | + await truncateDomain() |
| 19 | +}) |
| 20 | + |
| 21 | +function pkce(): { verifier: string; challenge: string } { |
| 22 | + const verifier = randomBytes(32).toString("base64url") |
| 23 | + const challenge = createHash("sha256").update(verifier).digest("base64url") |
| 24 | + return { verifier, challenge } |
| 25 | +} |
| 26 | + |
| 27 | +describe("MCP OAuth + Streamable HTTP", () => { |
| 28 | + it("end-to-end: discovery → register → authorize → token → tools/call", async () => { |
| 29 | + await truncateDomain() |
| 30 | + const userId = await createUser("mcp-user@example.com") |
| 31 | + const cookie = await signIn("mcp-user@example.com") |
| 32 | + const projectId = crypto.randomUUID() |
| 33 | + await db.insert(projects).values({ |
| 34 | + id: projectId, |
| 35 | + name: "MCP Test", |
| 36 | + createdBy: userId, |
| 37 | + }) |
| 38 | + await db.insert(projectMembers).values({ projectId, userId, role: "developer" }) |
| 39 | + |
| 40 | + // 1. Discovery |
| 41 | + const discoveryUrl = `${BASE}/.well-known/oauth-authorization-server/api/auth` |
| 42 | + const discovery = (await fetch(discoveryUrl).then((r) => r.json())) as { |
| 43 | + issuer: string |
| 44 | + authorization_endpoint: string |
| 45 | + token_endpoint: string |
| 46 | + registration_endpoint: string |
| 47 | + } |
| 48 | + expect(discovery.issuer).toBeDefined() |
| 49 | + expect(discovery.authorization_endpoint).toBeDefined() |
| 50 | + expect(discovery.token_endpoint).toBeDefined() |
| 51 | + expect(discovery.registration_endpoint).toBeDefined() |
| 52 | + |
| 53 | + // 2. Dynamic client registration |
| 54 | + const reg = (await fetch(discovery.registration_endpoint, { |
| 55 | + method: "POST", |
| 56 | + headers: { "Content-Type": "application/json" }, |
| 57 | + body: JSON.stringify({ |
| 58 | + client_name: "Test MCP Client", |
| 59 | + redirect_uris: [`${BASE}/oauth-test-callback`], |
| 60 | + token_endpoint_auth_method: "none", |
| 61 | + grant_types: ["authorization_code", "refresh_token"], |
| 62 | + response_types: ["code"], |
| 63 | + }), |
| 64 | + }).then((r) => r.json())) as { client_id: string } |
| 65 | + expect(reg.client_id).toBeDefined() |
| 66 | + |
| 67 | + // 3. Authorize: simulate the user clicking "Allow". |
| 68 | + // better-auth's oauthProvider consent endpoint expects: |
| 69 | + // { accept, oauth_query } (forwarded by /api/oauth/consent) |
| 70 | + const { verifier, challenge } = pkce() |
| 71 | + const authorizeUrl = new URL(discovery.authorization_endpoint) |
| 72 | + authorizeUrl.searchParams.set("response_type", "code") |
| 73 | + authorizeUrl.searchParams.set("client_id", reg.client_id) |
| 74 | + authorizeUrl.searchParams.set("redirect_uri", `${BASE}/oauth-test-callback`) |
| 75 | + authorizeUrl.searchParams.set("scope", "mcp:full") |
| 76 | + authorizeUrl.searchParams.set("code_challenge", challenge) |
| 77 | + authorizeUrl.searchParams.set("code_challenge_method", "S256") |
| 78 | + authorizeUrl.searchParams.set("state", "test-state") |
| 79 | + const authorizeRes = await fetch(authorizeUrl.toString(), { |
| 80 | + headers: { cookie }, |
| 81 | + redirect: "manual", |
| 82 | + }) |
| 83 | + let location = authorizeRes.headers.get("location") ?? "" |
| 84 | + |
| 85 | + // The authorize endpoint redirects to the consent page when the user |
| 86 | + // hasn't granted consent yet. |
| 87 | + if (location.includes("/oauth/consent")) { |
| 88 | + // Extract the signed oauth_query from the consent redirect URL and |
| 89 | + // POST the Allow decision to /api/oauth/consent. |
| 90 | + const consentLocationUrl = new URL(location, BASE) |
| 91 | + // The query string from the consent page URL is the signed oauth_query |
| 92 | + // that better-auth needs to restore the flow state. |
| 93 | + const oauthQuery = consentLocationUrl.search.replace(/^\?/, "") |
| 94 | + const decision = await apiFetch<{ redirectUri: string }>("/api/oauth/consent", { |
| 95 | + method: "POST", |
| 96 | + headers: { cookie }, |
| 97 | + body: JSON.stringify({ oauthQuery, allow: true }), |
| 98 | + }) |
| 99 | + expect(decision.status).toBe(200) |
| 100 | + location = decision.body.redirectUri |
| 101 | + } |
| 102 | + |
| 103 | + const code = new URL(location, BASE).searchParams.get("code") |
| 104 | + expect(code).toBeTruthy() |
| 105 | + if (!code) throw new Error("missing code param in redirect") |
| 106 | + |
| 107 | + // 4. Token exchange — include `resource` so the oauth provider issues a |
| 108 | + // JWT access token (RFC 8707: resource indicators). Without `resource`, |
| 109 | + // better-auth's oauthProvider has no audience to set and falls back to |
| 110 | + // an opaque token which the mcpHandler cannot verify via JWKS. |
| 111 | + const tokenRes = (await fetch(discovery.token_endpoint, { |
| 112 | + method: "POST", |
| 113 | + headers: { "Content-Type": "application/x-www-form-urlencoded" }, |
| 114 | + body: new URLSearchParams({ |
| 115 | + grant_type: "authorization_code", |
| 116 | + code, |
| 117 | + redirect_uri: `${BASE}/oauth-test-callback`, |
| 118 | + client_id: reg.client_id, |
| 119 | + code_verifier: verifier, |
| 120 | + resource: `${BASE}/api/mcp`, |
| 121 | + }), |
| 122 | + }).then((r) => r.json())) as { access_token?: string; token_type?: string; error?: string } |
| 123 | + expect(tokenRes.access_token).toBeDefined() |
| 124 | + expect(tokenRes.token_type).toMatch(/bearer/i) |
| 125 | + |
| 126 | + // 5. MCP — tools/list and tools/call via the official SDK client |
| 127 | + const client = new Client({ name: "test-client", version: "0.0.0" }) |
| 128 | + const transport = new StreamableHTTPClientTransport(new URL(`${BASE}/api/mcp`), { |
| 129 | + requestInit: { |
| 130 | + headers: { authorization: `Bearer ${tokenRes.access_token}` }, |
| 131 | + }, |
| 132 | + }) |
| 133 | + await client.connect(transport) |
| 134 | + |
| 135 | + const tools = await client.listTools() |
| 136 | + expect(tools.tools.map((t) => t.name).toSorted()).toEqual([ |
| 137 | + "repro_get_ticket", |
| 138 | + "repro_list_projects", |
| 139 | + ]) |
| 140 | + |
| 141 | + const listResult = await client.callTool({ |
| 142 | + name: "repro_list_projects", |
| 143 | + arguments: {}, |
| 144 | + }) |
| 145 | + const text = (listResult.content?.[0] as { text?: string } | undefined)?.text ?? "[]" |
| 146 | + const projectsResult = JSON.parse(text) as Array<{ id: string; name: string }> |
| 147 | + expect(projectsResult.find((p) => p.id === projectId)).toBeDefined() |
| 148 | + |
| 149 | + await client.close() |
| 150 | + }, 30_000) |
| 151 | +}) |
0 commit comments