Skip to content

Commit e21c4cc

Browse files
committed
finish all tests
1 parent 118907d commit e21c4cc

File tree

8 files changed

+1178
-30
lines changed

8 files changed

+1178
-30
lines changed
Lines changed: 292 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,298 @@
1+
import { invariant } from '@epic-web/invariant'
2+
import {
3+
type JSONRPCMessage,
4+
JSONRPCMessageSchema,
5+
} from '@modelcontextprotocol/sdk/types.js'
16
import { test, expect, inject } from 'vitest'
2-
import { z } from 'zod'
37

48
const mcpServerPort = inject('mcpServerPort')
9+
const EPIC_ME_AUTH_SERVER_URL = 'http://localhost:7788'
510
const mcpServerUrl = `http://localhost:${mcpServerPort}`
611

7-
test(`TODO: update this test title to describe the important thing we're working on in this exercise step`, async () => {
8-
// TODO: implement this test
12+
test(`tools can be called with a valid token`, async () => {
13+
const tokenResult = await getAuthToken()
14+
const response = await initialize(tokenResult.access_token)
15+
const sessionId = response.headers.get('mcp-session-id')
16+
invariant(
17+
sessionId,
18+
'🚨 initialization response should have an MCP session ID header',
19+
)
20+
const toolResponse = await fetch(`${mcpServerUrl}/mcp`, {
21+
method: 'POST',
22+
headers: {
23+
'mcp-session-id': sessionId,
24+
accept: 'application/json, text/event-stream',
25+
'Content-Type': 'application/json',
26+
Authorization: `Bearer ${tokenResult.access_token}`,
27+
},
28+
body: JSON.stringify({
29+
jsonrpc: '2.0',
30+
id: crypto.randomUUID(),
31+
method: 'tools/call',
32+
params: {
33+
name: 'list_entries',
34+
arguments: {},
35+
},
36+
}),
37+
})
38+
const toolResponseData = await handleStreamableResponse(toolResponse)
39+
expect(
40+
toolResponseData,
41+
'🚨 the list_entries tool should be available with a valid token',
42+
).toEqual([
43+
{
44+
id: expect.any(String),
45+
jsonrpc: '2.0',
46+
result: expect.objectContaining({
47+
content: expect.arrayContaining([
48+
{ type: 'text', text: expect.stringMatching(/Found \d+ entries\./) },
49+
]),
50+
}),
51+
},
52+
])
953
})
54+
55+
async function getAuthToken() {
56+
const redirectUri = `https://example.com/test-mcp-client`
57+
const clientRegistrationResponse = await fetch(
58+
`${EPIC_ME_AUTH_SERVER_URL}/oauth/register`,
59+
{
60+
method: 'POST',
61+
headers: {
62+
'content-type': 'application/json',
63+
accept: 'application/json, text/event-stream',
64+
},
65+
body: JSON.stringify({
66+
client_name: 'Test MCP Client',
67+
redirect_uris: [redirectUri],
68+
}),
69+
},
70+
)
71+
72+
expect(
73+
clientRegistrationResponse.ok,
74+
'🚨 Client registration should succeed',
75+
).toBe(true)
76+
const clientRegistration =
77+
(await clientRegistrationResponse.json()) as ClientRegistration
78+
expect(
79+
clientRegistration.client_id,
80+
'🚨 Client ID should be returned from registration',
81+
).toBeTruthy()
82+
83+
const { codeVerifier, codeChallenge, codeChallengeMethod } =
84+
generateCodeChallenge()
85+
const state = crypto.randomUUID()
86+
87+
const testAuthUrl = new URL(`${EPIC_ME_AUTH_SERVER_URL}/test-auth`)
88+
// Use the registered client ID instead of the one from the auth URL
89+
testAuthUrl.searchParams.set('client_id', clientRegistration.client_id)
90+
testAuthUrl.searchParams.set('redirect_uri', redirectUri)
91+
testAuthUrl.searchParams.set('response_type', 'code')
92+
testAuthUrl.searchParams.set('code_challenge', codeChallenge)
93+
testAuthUrl.searchParams.set('code_challenge_method', codeChallengeMethod)
94+
testAuthUrl.searchParams.set('scope', '')
95+
testAuthUrl.searchParams.set('state', state)
96+
97+
const authCodeResponse = await fetch(testAuthUrl.toString())
98+
expect(authCodeResponse.ok, '🚨 Auth code request should succeed').toBe(true)
99+
100+
const authResult = (await authCodeResponse.json()) as AuthResult
101+
expect(
102+
authResult.redirectTo,
103+
'🚨 Redirect URL should be returned',
104+
).toBeTruthy()
105+
106+
const redirectUrl = new URL(authResult.redirectTo)
107+
const authCode = redirectUrl.searchParams.get('code')
108+
const returnedState = redirectUrl.searchParams.get('state')
109+
110+
expect(
111+
authCode,
112+
'🚨 Auth code should be present in redirect URL',
113+
).toBeTruthy()
114+
expect(returnedState, '🚨 State should be returned').toBe(state)
115+
116+
const tokenParams = new URLSearchParams({
117+
grant_type: 'authorization_code',
118+
code: authCode!,
119+
redirect_uri: redirectUri,
120+
client_id: clientRegistration.client_id, // Use registered client ID
121+
code_verifier: codeVerifier,
122+
})
123+
124+
// Add client_secret if provided during registration
125+
if (clientRegistration.client_secret) {
126+
tokenParams.set('client_secret', clientRegistration.client_secret)
127+
}
128+
129+
const tokenResponse = await fetch(`${EPIC_ME_AUTH_SERVER_URL}/oauth/token`, {
130+
method: 'POST',
131+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
132+
body: tokenParams,
133+
})
134+
135+
if (!tokenResponse.ok) {
136+
const errorText = await tokenResponse.text()
137+
console.error('Token exchange failed:', tokenResponse.status, errorText)
138+
}
139+
140+
expect(tokenResponse.ok, '🚨 Token exchange should succeed').toBe(true)
141+
const tokenResult = (await tokenResponse.json()) as TokenResult
142+
expect(
143+
tokenResult.access_token,
144+
'🚨 Access token should be returned',
145+
).toBeTruthy()
146+
expect(
147+
tokenResult.token_type?.toLowerCase(),
148+
'🚨 Token type should be Bearer',
149+
).toBe('bearer')
150+
151+
return tokenResult
152+
}
153+
154+
async function initialize(accessToken: string) {
155+
const authTestResponse = await fetch(`${mcpServerUrl}/mcp`, {
156+
method: 'POST',
157+
headers: {
158+
'Content-Type': 'application/json',
159+
Accept: 'application/json, text/event-stream',
160+
Authorization: `Bearer ${accessToken}`,
161+
},
162+
body: JSON.stringify({
163+
jsonrpc: '2.0',
164+
id: crypto.randomUUID(),
165+
method: 'initialize',
166+
params: {
167+
protocolVersion: '2024-11-05',
168+
capabilities: {},
169+
clientInfo: {
170+
name: 'Test Client',
171+
version: '1.0.0',
172+
},
173+
},
174+
}),
175+
})
176+
177+
expect(
178+
authTestResponse.status,
179+
'🚨 Should not get 401 Unauthorized with valid token',
180+
).not.toBe(401)
181+
return authTestResponse
182+
}
183+
184+
async function handleStreamableResponse(response: Response) {
185+
if (response.headers.get('content-type')?.includes('text/event-stream')) {
186+
const stream = response.body
187+
if (!stream) {
188+
throw new Error('No response body available for streaming')
189+
}
190+
191+
const messages: Array<JSONRPCMessage> = []
192+
193+
try {
194+
// Create a pipeline: binary stream -> text decoder
195+
const reader = stream.pipeThrough(new TextDecoderStream()).getReader()
196+
197+
let buffer = ''
198+
let messageReceived = false
199+
200+
while (true) {
201+
const { value: chunk, done } = await reader.read()
202+
if (done) {
203+
break
204+
}
205+
206+
buffer += chunk
207+
208+
// Process complete SSE messages
209+
const lines = buffer.split('\n')
210+
buffer = lines.pop() || '' // Keep incomplete line in buffer
211+
212+
let eventData = ''
213+
let inData = false
214+
215+
for (const line of lines) {
216+
if (line.trim() === '') {
217+
// Empty line indicates end of event
218+
if (eventData && inData) {
219+
try {
220+
const message = JSONRPCMessageSchema.parse(
221+
JSON.parse(eventData),
222+
)
223+
messages.push(message)
224+
messageReceived = true
225+
226+
// Close the connection after receiving the first message
227+
// to prevent hanging if the server doesn't close the stream
228+
void reader.cancel().catch(() => {})
229+
break
230+
} catch (error) {
231+
console.error('Failed to parse SSE message:', error)
232+
// Continue processing other messages even if one fails
233+
}
234+
}
235+
eventData = ''
236+
inData = false
237+
} else if (line.startsWith('data: ')) {
238+
eventData += line.slice(6) // Remove 'data: ' prefix
239+
inData = true
240+
}
241+
// Ignore other SSE fields like 'event:', 'id:', etc.
242+
}
243+
244+
// Break out of the main loop if we've received a message and cancelled
245+
if (messageReceived) {
246+
break
247+
}
248+
}
249+
} catch (error) {
250+
console.error('SSE stream error:', error)
251+
throw new Error(`SSE stream disconnected: ${error}`)
252+
}
253+
254+
return messages
255+
} else {
256+
return response.json()
257+
}
258+
}
259+
260+
// TypeScript interfaces for API responses
261+
interface AuthServerConfig {
262+
authorization_endpoint: string
263+
token_endpoint: string
264+
[key: string]: unknown
265+
}
266+
267+
interface ClientRegistration {
268+
client_id: string
269+
client_secret?: string
270+
[key: string]: unknown
271+
}
272+
273+
interface AuthResult {
274+
redirectTo: string
275+
[key: string]: unknown
276+
}
277+
278+
interface TokenResult {
279+
access_token: string
280+
token_type: string
281+
[key: string]: unknown
282+
}
283+
284+
// Helper function to generate PKCE challenge
285+
function generateCodeChallenge() {
286+
const codeVerifier = btoa(
287+
String.fromCharCode(...crypto.getRandomValues(new Uint8Array(32))),
288+
)
289+
.replace(/\+/g, '-')
290+
.replace(/\//g, '_')
291+
.replace(/=/g, '')
292+
293+
return {
294+
codeVerifier,
295+
codeChallenge: codeVerifier, // For simplicity, using plain method
296+
codeChallengeMethod: 'plain',
297+
}
298+
}

0 commit comments

Comments
 (0)