Skip to content

Commit 94222c5

Browse files
Ripwordsclaude
andcommitted
test(mcp): integration — full OAuth + tools/call roundtrip
End-to-end acceptance test for Phase 1 MCP OAuth server: 1. RFC 8414 discovery endpoint 2. Dynamic client registration 3. Authorization with PKCE → consent page 4. Token exchange with resource indicator (RFC 8707) for JWT issuance 5. tools/list via Streamable HTTP 6. tools/call repro_list_projects → asserts seeded project present Requires dev server running with MCP_ENABLED=true and BETTER_AUTH_URL overriding the .env ngrok URL to http://localhost:3000. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 9270e52 commit 94222c5

1 file changed

Lines changed: 151 additions & 0 deletions

File tree

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
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

Comments
 (0)