Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit 2e3ec13

Browse files
authored
Merge pull request #1 from Zoo-Code-Org/feat/mcp-oauth-streamable-http
feat: support OAuth 2.1 for streamable-http MCP servers
2 parents 9260779 + 7840d03 commit 2e3ec13

32 files changed

Lines changed: 4087 additions & 25 deletions
Lines changed: 372 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,372 @@
1+
import * as assert from "assert"
2+
import * as fs from "fs/promises"
3+
import * as path from "path"
4+
import * as os from "os"
5+
import * as http from "http"
6+
import * as vscode from "vscode"
7+
8+
import { waitFor, sleep } from "./utils"
9+
import { setDefaultSuiteTimeout } from "./test-utils"
10+
11+
/**
12+
* Minimal MCP-protocol-aware request handler.
13+
*
14+
* The SDK's StreamableHTTPClientTransport uses:
15+
* - GET /mcp → SSE stream (we return 405 to indicate not supported)
16+
* - POST /mcp → JSON-RPC messages (initialize, tools/list, etc.)
17+
*/
18+
function handleMcpRequest(req: http.IncomingMessage, res: http.ServerResponse, endpointsHit: Set<string>): void {
19+
if (req.method === "GET") {
20+
// Signal that we don't support the SSE push channel.
21+
// The SDK treats 405 as "SSE not supported, POST-only mode".
22+
endpointsHit.add("mcp-authed-get")
23+
res.writeHead(405)
24+
res.end()
25+
return
26+
}
27+
28+
// POST — read body, parse JSON-RPC, dispatch
29+
let body = ""
30+
req.on("data", (chunk) => (body += chunk))
31+
req.on("end", () => {
32+
endpointsHit.add("mcp-authed")
33+
34+
let message: { id?: number; method?: string }
35+
try {
36+
message = JSON.parse(body)
37+
} catch {
38+
res.writeHead(400)
39+
res.end()
40+
return
41+
}
42+
43+
// Notifications (no id) → 202 Accepted
44+
if (message.id === undefined) {
45+
res.writeHead(202)
46+
res.end()
47+
return
48+
}
49+
50+
let result: unknown
51+
switch (message.method) {
52+
case "initialize":
53+
result = {
54+
protocolVersion: "2024-11-05",
55+
capabilities: {},
56+
serverInfo: { name: "test-oauth-server", version: "1.0.0" },
57+
}
58+
break
59+
case "tools/list":
60+
result = { tools: [] }
61+
break
62+
case "resources/list":
63+
result = { resources: [] }
64+
break
65+
case "resources/templates/list":
66+
result = { resourceTemplates: [] }
67+
break
68+
default:
69+
result = {}
70+
}
71+
72+
res.writeHead(200, { "Content-Type": "application/json" })
73+
res.end(JSON.stringify({ jsonrpc: "2.0", id: message.id, result }))
74+
})
75+
}
76+
77+
suite("Roo Code MCP OAuth", function () {
78+
setDefaultSuiteTimeout(this)
79+
80+
let tempDir: string
81+
let testFiles: { mcpConfig: string }
82+
let mockServer: http.Server
83+
let mockServerPort: number
84+
85+
// Track which OAuth / MCP endpoints were hit
86+
const endpointsHit: Set<string> = new Set()
87+
88+
suiteSetup(async () => {
89+
// Enable test mode so the OAuth callback server resolves immediately
90+
// without needing a real browser redirect.
91+
process.env.MCP_OAUTH_TEST_MODE = "true"
92+
93+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "roo-test-mcp-oauth-"))
94+
95+
mockServer = http.createServer((req, res) => {
96+
const url = req.url || ""
97+
console.log(`[MOCK SERVER] ${req.method} ${url}`)
98+
99+
// ── MCP endpoint ─────────────────────────────────────────────
100+
if (url === "/mcp" || url.startsWith("/mcp?") || url.startsWith("/mcp/")) {
101+
const authHeader = req.headers.authorization
102+
if (!authHeader || !authHeader.startsWith("Bearer ")) {
103+
endpointsHit.add("mcp-401")
104+
res.writeHead(401, {
105+
"WWW-Authenticate": `Bearer resource_metadata="http://localhost:${mockServerPort}/.well-known/oauth-protected-resource"`,
106+
})
107+
res.end()
108+
} else {
109+
// Authenticated — handle as MCP protocol
110+
handleMcpRequest(req, res, endpointsHit)
111+
}
112+
return
113+
}
114+
115+
// ── OAuth discovery / registration / token endpoints ─────────
116+
117+
if (url === "/.well-known/oauth-protected-resource") {
118+
endpointsHit.add("resource-metadata")
119+
res.writeHead(200, { "Content-Type": "application/json" })
120+
res.end(
121+
JSON.stringify({
122+
resource: `http://localhost:${mockServerPort}/mcp`,
123+
authorization_servers: [`http://localhost:${mockServerPort}/auth`],
124+
}),
125+
)
126+
return
127+
}
128+
129+
// SDK constructs: new URL("/.well-known/oauth-authorization-server", "http://host/auth")
130+
// which resolves to http://host/.well-known/oauth-authorization-server (origin-relative)
131+
// Our custom fetchOAuthAuthServerMetadata constructs the RFC 8414 URL with issuer path:
132+
// /.well-known/oauth-authorization-server/auth (with issuer path)
133+
// Handle BOTH forms so our provider gets _authServerMeta.
134+
if (
135+
url === "/.well-known/oauth-authorization-server" ||
136+
url === "/.well-known/oauth-authorization-server/auth"
137+
) {
138+
endpointsHit.add("auth-metadata")
139+
res.writeHead(200, { "Content-Type": "application/json" })
140+
res.end(
141+
JSON.stringify({
142+
issuer: `http://localhost:${mockServerPort}/auth`,
143+
authorization_endpoint: `http://localhost:${mockServerPort}/auth/authorize`,
144+
token_endpoint: `http://localhost:${mockServerPort}/auth/token`,
145+
registration_endpoint: `http://localhost:${mockServerPort}/auth/register`,
146+
code_challenge_methods_supported: ["S256"],
147+
response_types_supported: ["code"],
148+
}),
149+
)
150+
return
151+
}
152+
153+
if (url === "/auth/register" && req.method === "POST") {
154+
endpointsHit.add("register")
155+
res.writeHead(201, { "Content-Type": "application/json" })
156+
res.end(
157+
JSON.stringify({
158+
client_id: "test-client-id",
159+
redirect_uris: ["http://localhost:3000/callback"],
160+
}),
161+
)
162+
return
163+
}
164+
165+
if (url === "/auth/token" && req.method === "POST") {
166+
endpointsHit.add("token")
167+
res.writeHead(200, { "Content-Type": "application/json" })
168+
res.end(
169+
JSON.stringify({
170+
access_token: "test-access-token",
171+
token_type: "Bearer",
172+
expires_in: 3600,
173+
}),
174+
)
175+
return
176+
}
177+
178+
// Capture authorize hits (only reachable if a real browser is present)
179+
if (url.startsWith("/auth/authorize")) {
180+
endpointsHit.add("authorize")
181+
res.writeHead(200, { "Content-Type": "text/plain" })
182+
res.end("Authorization endpoint reached")
183+
return
184+
}
185+
186+
res.writeHead(404)
187+
res.end()
188+
})
189+
190+
// Find an available port
191+
mockServerPort = await new Promise<number>((resolve, reject) => {
192+
mockServer.listen(0, "127.0.0.1", () => {
193+
const addr = mockServer.address()
194+
if (!addr || typeof addr === "string") return reject(new Error("Failed to get address"))
195+
resolve(addr.port)
196+
})
197+
mockServer.on("error", reject)
198+
})
199+
200+
const workspaceDir = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || tempDir
201+
const rooDir = path.join(workspaceDir, ".roo")
202+
await fs.mkdir(rooDir, { recursive: true })
203+
204+
const mcpConfig = {
205+
mcpServers: {
206+
"test-oauth-server": {
207+
type: "streamable-http",
208+
url: `http://localhost:${mockServerPort}/mcp`,
209+
},
210+
},
211+
}
212+
213+
testFiles = { mcpConfig: path.join(rooDir, "mcp.json") }
214+
await fs.writeFile(testFiles.mcpConfig, JSON.stringify(mcpConfig, null, 2))
215+
216+
console.log("[TEST] Mock server port:", mockServerPort)
217+
console.log("[TEST] MCP config:", testFiles.mcpConfig)
218+
})
219+
220+
suiteTeardown(async () => {
221+
delete process.env.MCP_OAUTH_TEST_MODE
222+
223+
try {
224+
await globalThis.api.cancelCurrentTask()
225+
} catch {
226+
// Task might not be running
227+
}
228+
229+
if (mockServer) {
230+
await new Promise<void>((resolve) => mockServer.close(() => resolve()))
231+
}
232+
233+
for (const filePath of Object.values(testFiles)) {
234+
try {
235+
await fs.unlink(filePath)
236+
} catch {
237+
// ignore
238+
}
239+
}
240+
241+
// Only remove .roo/mcp.json if it's inside the ephemeral tempDir — never
242+
// touch a real workspace's config.
243+
const workspaceDir = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || tempDir
244+
if (workspaceDir === tempDir || workspaceDir.startsWith(tempDir + path.sep)) {
245+
try {
246+
await fs.unlink(path.join(workspaceDir, ".roo", "mcp.json"))
247+
} catch {
248+
// ignore
249+
}
250+
}
251+
252+
await fs.rm(tempDir, { recursive: true, force: true })
253+
})
254+
255+
setup(async () => {
256+
try {
257+
await globalThis.api.cancelCurrentTask()
258+
} catch {
259+
// ignore
260+
}
261+
endpointsHit.clear()
262+
await sleep(100)
263+
})
264+
265+
teardown(async () => {
266+
try {
267+
await globalThis.api.cancelCurrentTask()
268+
} catch {
269+
// ignore
270+
}
271+
await sleep(100)
272+
})
273+
274+
test("Should complete the full OAuth flow when connecting to an OAuth-protected MCP server", async function () {
275+
// Re-write the config to trigger the file watcher and force a reconnect.
276+
const workspaceDir = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || tempDir
277+
const mcpConfigPath = path.join(workspaceDir, ".roo", "mcp.json")
278+
279+
await fs.writeFile(
280+
mcpConfigPath,
281+
JSON.stringify(
282+
{
283+
mcpServers: {
284+
"test-oauth-server": {
285+
type: "streamable-http",
286+
url: `http://localhost:${mockServerPort}/mcp`,
287+
},
288+
},
289+
},
290+
null,
291+
2,
292+
),
293+
)
294+
295+
// Step 1: Initial connection attempt gets 401 → triggers OAuth discovery
296+
await waitFor(() => endpointsHit.has("mcp-401"), { timeout: 30_000 })
297+
console.log("[TEST] Got initial 401, OAuth flow started")
298+
299+
// Step 2: SDK discovers OAuth metadata
300+
await waitFor(() => endpointsHit.has("resource-metadata"), { timeout: 15_000 })
301+
console.log("[TEST] Resource metadata fetched")
302+
303+
await waitFor(() => endpointsHit.has("auth-metadata"), { timeout: 15_000 })
304+
console.log("[TEST] Auth server metadata fetched")
305+
306+
// Step 3: Dynamic client registration
307+
await waitFor(() => endpointsHit.has("register"), { timeout: 15_000 })
308+
console.log("[TEST] Client registered")
309+
310+
// Step 4: In MCP_OAUTH_TEST_MODE the callback server resolves immediately with
311+
// a test auth code (no real browser needed). The SDK exchanges it for a token.
312+
await waitFor(() => endpointsHit.has("token"), { timeout: 15_000 })
313+
console.log("[TEST] Access token obtained")
314+
315+
// Step 5: The background _completeOAuthFlow task retries client.connect() with
316+
// the bearer token. Verify the MCP server receives an authenticated request.
317+
await waitFor(() => endpointsHit.has("mcp-authed"), { timeout: 15_000 })
318+
console.log("[TEST] MCP server connected with valid Bearer token")
319+
320+
// Assert the complete OAuth flow ran
321+
assert.ok(endpointsHit.has("mcp-401"), "MCP server should return 401 to trigger OAuth")
322+
assert.ok(endpointsHit.has("resource-metadata"), "Resource metadata discovery should run")
323+
assert.ok(endpointsHit.has("auth-metadata"), "Auth server metadata discovery should run")
324+
assert.ok(endpointsHit.has("register"), "Dynamic client registration should run")
325+
assert.ok(endpointsHit.has("token"), "Token exchange should succeed")
326+
assert.ok(endpointsHit.has("mcp-authed"), "Retry connection should succeed with Bearer token")
327+
328+
console.log("[TEST] MCP OAuth flow completed successfully. Endpoints hit:", [...endpointsHit])
329+
})
330+
331+
test("Should reuse stored token on reconnect without re-running the full OAuth flow", async function () {
332+
// Ensure a token is in SecretStorage before testing reuse — this makes the
333+
// test self-contained regardless of execution order.
334+
await waitFor(() => endpointsHit.has("token"), { timeout: 30_000 })
335+
336+
// Clear hit tracking so we can assert the token endpoint is NOT re-hit.
337+
endpointsHit.clear()
338+
339+
const workspaceDir = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || tempDir
340+
const mcpConfigPath = path.join(workspaceDir, ".roo", "mcp.json")
341+
342+
// Slightly modify the config to force a reconnect
343+
await fs.writeFile(
344+
mcpConfigPath,
345+
JSON.stringify(
346+
{
347+
mcpServers: {
348+
"test-oauth-server": {
349+
type: "streamable-http",
350+
url: `http://localhost:${mockServerPort}/mcp`,
351+
// A different but valid timeout value triggers config-change detection
352+
timeout: 30,
353+
},
354+
},
355+
},
356+
null,
357+
2,
358+
),
359+
)
360+
361+
// Wait for the MCP server to receive an authenticated request
362+
await waitFor(() => endpointsHit.has("mcp-authed"), { timeout: 30_000 })
363+
console.log("[TEST] Token reuse: MCP server got authenticated request")
364+
365+
// The full OAuth flow should NOT have re-run (token was cached in SecretStorage)
366+
assert.ok(endpointsHit.has("mcp-authed"), "Reconnect should use cached token")
367+
assert.ok(!endpointsHit.has("mcp-401"), "Should not get 401 when token is cached")
368+
assert.ok(!endpointsHit.has("register"), "Should not re-register client when token is cached")
369+
370+
console.log("[TEST] Token reuse test passed. Endpoints hit:", [...endpointsHit])
371+
})
372+
})

src/esbuild.mjs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,10 @@ async function main() {
143143
copyLocales(srcDir, distDir)
144144
setupLocaleWatcher(srcDir, distDir)
145145
} else {
146-
await Promise.all([extensionCtx.rebuild(), workerCtx.rebuild()])
146+
// Run sequentially on rebuild to avoid Windows EBUSY races when both
147+
// onEnd hooks copy the same asset directories concurrently.
148+
await extensionCtx.rebuild()
149+
await workerCtx.rebuild()
147150
await Promise.all([extensionCtx.dispose(), workerCtx.dispose()])
148151
}
149152
}

0 commit comments

Comments
 (0)