Skip to content

Commit 27c5180

Browse files
Ripwordsclaude
andcommitted
test(mcp): integration tests for Phase 2 read tools
7 tests covering repro_list_tickets, repro_list_ticket_comments, repro_list_project_members, repro_get_ticket_cookies, repro_get_screenshot, repro_get_replay_transcript, and repro_get_replay_raw against a live dev server with a full OAuth dance. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0f219f4 commit 27c5180

1 file changed

Lines changed: 239 additions & 0 deletions

File tree

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
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, reports, reportComments } 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+
interface SetupResult {
28+
client: Client
29+
userId: string
30+
projectId: string
31+
ticketId: string
32+
}
33+
34+
async function setupOAuth(): Promise<SetupResult> {
35+
await truncateDomain()
36+
const userId = await createUser("phase2-mcp@example.com")
37+
const cookie = await signIn("phase2-mcp@example.com")
38+
39+
const projectId = crypto.randomUUID()
40+
await db.insert(projects).values({ id: projectId, name: "Phase 2 Test", createdBy: userId })
41+
await db.insert(projectMembers).values({ projectId, userId, role: "developer" })
42+
43+
const ticketId = crypto.randomUUID()
44+
await db.insert(reports).values({
45+
id: ticketId,
46+
projectId,
47+
title: "Login button does nothing",
48+
description: "Clicking the **Sign in** button no longer triggers anything",
49+
status: "open",
50+
priority: "high",
51+
tags: ["auth", "frontend"],
52+
source: "web",
53+
context: {
54+
source: "web",
55+
pageUrl: "https://example.com/login",
56+
userAgent: "Mozilla/5.0",
57+
viewport: { w: 1440, h: 900 },
58+
timestamp: Date.now(),
59+
cookies: [{ name: "host_session", value: "•••", domain: "example.com" }],
60+
},
61+
})
62+
await db.insert(reportComments).values({
63+
reportId: ticketId,
64+
userId,
65+
body: "Reproduced on Chrome 132",
66+
source: "dashboard",
67+
})
68+
69+
const discovery = await fetch(`${BASE}/.well-known/oauth-authorization-server/api/auth`).then(
70+
(r) => r.json(),
71+
)
72+
const reg = await fetch(discovery.registration_endpoint, {
73+
method: "POST",
74+
headers: { "Content-Type": "application/json" },
75+
body: JSON.stringify({
76+
client_name: "Phase2 Test Client",
77+
redirect_uris: [`${BASE}/oauth-test-callback`],
78+
token_endpoint_auth_method: "none",
79+
grant_types: ["authorization_code", "refresh_token"],
80+
response_types: ["code"],
81+
}),
82+
}).then((r) => r.json())
83+
84+
const { verifier, challenge } = pkce()
85+
const authorizeUrl = new URL(discovery.authorization_endpoint)
86+
authorizeUrl.searchParams.set("response_type", "code")
87+
authorizeUrl.searchParams.set("client_id", reg.client_id)
88+
authorizeUrl.searchParams.set("redirect_uri", `${BASE}/oauth-test-callback`)
89+
authorizeUrl.searchParams.set("scope", "mcp:full")
90+
authorizeUrl.searchParams.set("code_challenge", challenge)
91+
authorizeUrl.searchParams.set("code_challenge_method", "S256")
92+
authorizeUrl.searchParams.set("state", "test-state")
93+
const authorizeRes = await fetch(authorizeUrl, { headers: { cookie }, redirect: "manual" })
94+
let location = authorizeRes.headers.get("location") ?? ""
95+
if (location.includes("/oauth/consent")) {
96+
const consentLocationUrl = new URL(location, BASE)
97+
const oauthQuery = consentLocationUrl.search.replace(/^\?/, "")
98+
const decision = await apiFetch<{ redirectUri: string }>("/api/oauth/consent", {
99+
method: "POST",
100+
headers: { cookie },
101+
body: { oauthQuery, allow: true },
102+
})
103+
expect(decision.status).toBe(200)
104+
location = decision.body.redirectUri
105+
}
106+
const code = new URL(location, BASE).searchParams.get("code")
107+
if (!code) throw new Error("missing code param in redirect")
108+
const tokenRes = await fetch(discovery.token_endpoint, {
109+
method: "POST",
110+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
111+
body: new URLSearchParams({
112+
grant_type: "authorization_code",
113+
code,
114+
redirect_uri: `${BASE}/oauth-test-callback`,
115+
client_id: reg.client_id,
116+
code_verifier: verifier,
117+
resource: `${BASE}/api/mcp`,
118+
}),
119+
}).then((r) => r.json())
120+
121+
const client = new Client({ name: "phase2-test-client", version: "0.0.0" })
122+
const transport = new StreamableHTTPClientTransport(new URL(`${BASE}/api/mcp`), {
123+
requestInit: { headers: { authorization: `Bearer ${tokenRes.access_token}` } },
124+
})
125+
await client.connect(transport)
126+
return { client, userId, projectId, ticketId }
127+
}
128+
129+
function parseToolText<T>(result: { content?: Array<unknown> }): T {
130+
const text = (result.content?.[0] as { text?: string } | undefined)?.text ?? "null"
131+
return JSON.parse(text) as T
132+
}
133+
134+
describe("MCP Phase 2 read tools", () => {
135+
it("repro_list_tickets returns the seeded ticket", async () => {
136+
const { client, projectId, ticketId } = await setupOAuth()
137+
try {
138+
const result = await client.callTool({
139+
name: "repro_list_tickets",
140+
arguments: { projectId },
141+
})
142+
const parsed = parseToolText<{
143+
items: Array<{ id: string; title: string; tags: string[] }>
144+
nextCursor: string | null
145+
}>(result)
146+
expect(parsed.items.find((t) => t.id === ticketId)?.tags).toContain("auth")
147+
expect(parsed.nextCursor).toBeNull()
148+
} finally {
149+
await client.close()
150+
}
151+
}, 30_000)
152+
153+
it("repro_list_ticket_comments returns the seeded comment", async () => {
154+
const { client, ticketId } = await setupOAuth()
155+
try {
156+
const result = await client.callTool({
157+
name: "repro_list_ticket_comments",
158+
arguments: { ticketId },
159+
})
160+
const parsed = parseToolText<{ items: Array<{ body: string; source: string }> }>(result)
161+
expect(parsed.items[0]?.body).toBe("Reproduced on Chrome 132")
162+
expect(parsed.items[0]?.source).toBe("dashboard")
163+
} finally {
164+
await client.close()
165+
}
166+
}, 30_000)
167+
168+
it("repro_list_project_members returns the seeded developer", async () => {
169+
const { client, projectId, userId } = await setupOAuth()
170+
try {
171+
const result = await client.callTool({
172+
name: "repro_list_project_members",
173+
arguments: { projectId },
174+
})
175+
const parsed = parseToolText<Array<{ userId: string; projectRole: string }>>(result)
176+
expect(parsed.find((m) => m.userId === userId)?.projectRole).toBe("developer")
177+
} finally {
178+
await client.close()
179+
}
180+
}, 30_000)
181+
182+
it("repro_get_ticket_cookies returns captured cookies", async () => {
183+
const { client, ticketId } = await setupOAuth()
184+
try {
185+
const result = await client.callTool({
186+
name: "repro_get_ticket_cookies",
187+
arguments: { ticketId },
188+
})
189+
const parsed = parseToolText<{ cookies: Array<{ name: string; value: string }> }>(result)
190+
expect(parsed.cookies[0]?.name).toBe("host_session")
191+
expect(parsed.cookies[0]?.value).toBe("•••")
192+
} finally {
193+
await client.close()
194+
}
195+
}, 30_000)
196+
197+
it("repro_get_screenshot returns NOT_FOUND when no screenshot is attached", async () => {
198+
const { client, ticketId } = await setupOAuth()
199+
try {
200+
const result = await client.callTool({
201+
name: "repro_get_screenshot",
202+
arguments: { ticketId },
203+
})
204+
expect((result as { isError?: boolean }).isError).toBe(true)
205+
const txt = (result as { content?: Array<{ text?: string }> }).content?.[0]?.text ?? ""
206+
expect(txt).toMatch(/NOT_FOUND/)
207+
} finally {
208+
await client.close()
209+
}
210+
}, 30_000)
211+
212+
it("repro_get_replay_transcript returns NOT_FOUND when no replay is attached", async () => {
213+
const { client, ticketId } = await setupOAuth()
214+
try {
215+
const result = await client.callTool({
216+
name: "repro_get_replay_transcript",
217+
arguments: { ticketId },
218+
})
219+
expect((result as { isError?: boolean }).isError).toBe(true)
220+
const txt = (result as { content?: Array<{ text?: string }> }).content?.[0]?.text ?? ""
221+
expect(txt).toMatch(/NOT_FOUND/)
222+
} finally {
223+
await client.close()
224+
}
225+
}, 30_000)
226+
227+
it("repro_get_replay_raw returns NOT_FOUND when no replay is attached", async () => {
228+
const { client, ticketId } = await setupOAuth()
229+
try {
230+
const result = await client.callTool({
231+
name: "repro_get_replay_raw",
232+
arguments: { ticketId },
233+
})
234+
expect((result as { isError?: boolean }).isError).toBe(true)
235+
} finally {
236+
await client.close()
237+
}
238+
}, 30_000)
239+
})

0 commit comments

Comments
 (0)