This commit is contained in:
edelauna 2026-05-27 11:32:23 +08:00 committed by GitHub
commit 737c5dfa1f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 4087 additions and 25 deletions

View file

@ -0,0 +1,372 @@
import * as assert from "assert"
import * as fs from "fs/promises"
import * as path from "path"
import * as os from "os"
import * as http from "http"
import * as vscode from "vscode"
import { waitFor, sleep } from "./utils"
import { setDefaultSuiteTimeout } from "./test-utils"
/**
* Minimal MCP-protocol-aware request handler.
*
* The SDK's StreamableHTTPClientTransport uses:
* - GET /mcp SSE stream (we return 405 to indicate not supported)
* - POST /mcp JSON-RPC messages (initialize, tools/list, etc.)
*/
function handleMcpRequest(req: http.IncomingMessage, res: http.ServerResponse, endpointsHit: Set<string>): void {
if (req.method === "GET") {
// Signal that we don't support the SSE push channel.
// The SDK treats 405 as "SSE not supported, POST-only mode".
endpointsHit.add("mcp-authed-get")
res.writeHead(405)
res.end()
return
}
// POST — read body, parse JSON-RPC, dispatch
let body = ""
req.on("data", (chunk) => (body += chunk))
req.on("end", () => {
endpointsHit.add("mcp-authed")
let message: { id?: number; method?: string }
try {
message = JSON.parse(body)
} catch {
res.writeHead(400)
res.end()
return
}
// Notifications (no id) → 202 Accepted
if (message.id === undefined) {
res.writeHead(202)
res.end()
return
}
let result: unknown
switch (message.method) {
case "initialize":
result = {
protocolVersion: "2024-11-05",
capabilities: {},
serverInfo: { name: "test-oauth-server", version: "1.0.0" },
}
break
case "tools/list":
result = { tools: [] }
break
case "resources/list":
result = { resources: [] }
break
case "resources/templates/list":
result = { resourceTemplates: [] }
break
default:
result = {}
}
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ jsonrpc: "2.0", id: message.id, result }))
})
}
suite("Roo Code MCP OAuth", function () {
setDefaultSuiteTimeout(this)
let tempDir: string
let testFiles: { mcpConfig: string }
let mockServer: http.Server
let mockServerPort: number
// Track which OAuth / MCP endpoints were hit
const endpointsHit: Set<string> = new Set()
suiteSetup(async () => {
// Enable test mode so the OAuth callback server resolves immediately
// without needing a real browser redirect.
process.env.MCP_OAUTH_TEST_MODE = "true"
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "roo-test-mcp-oauth-"))
mockServer = http.createServer((req, res) => {
const url = req.url || ""
console.log(`[MOCK SERVER] ${req.method} ${url}`)
// ── MCP endpoint ─────────────────────────────────────────────
if (url === "/mcp" || url.startsWith("/mcp?") || url.startsWith("/mcp/")) {
const authHeader = req.headers.authorization
if (!authHeader || !authHeader.startsWith("Bearer ")) {
endpointsHit.add("mcp-401")
res.writeHead(401, {
"WWW-Authenticate": `Bearer resource_metadata="http://localhost:${mockServerPort}/.well-known/oauth-protected-resource"`,
})
res.end()
} else {
// Authenticated — handle as MCP protocol
handleMcpRequest(req, res, endpointsHit)
}
return
}
// ── OAuth discovery / registration / token endpoints ─────────
if (url === "/.well-known/oauth-protected-resource") {
endpointsHit.add("resource-metadata")
res.writeHead(200, { "Content-Type": "application/json" })
res.end(
JSON.stringify({
resource: `http://localhost:${mockServerPort}/mcp`,
authorization_servers: [`http://localhost:${mockServerPort}/auth`],
}),
)
return
}
// SDK constructs: new URL("/.well-known/oauth-authorization-server", "http://host/auth")
// which resolves to http://host/.well-known/oauth-authorization-server (origin-relative)
// Our custom fetchOAuthAuthServerMetadata constructs the RFC 8414 URL with issuer path:
// /.well-known/oauth-authorization-server/auth (with issuer path)
// Handle BOTH forms so our provider gets _authServerMeta.
if (
url === "/.well-known/oauth-authorization-server" ||
url === "/.well-known/oauth-authorization-server/auth"
) {
endpointsHit.add("auth-metadata")
res.writeHead(200, { "Content-Type": "application/json" })
res.end(
JSON.stringify({
issuer: `http://localhost:${mockServerPort}/auth`,
authorization_endpoint: `http://localhost:${mockServerPort}/auth/authorize`,
token_endpoint: `http://localhost:${mockServerPort}/auth/token`,
registration_endpoint: `http://localhost:${mockServerPort}/auth/register`,
code_challenge_methods_supported: ["S256"],
response_types_supported: ["code"],
}),
)
return
}
if (url === "/auth/register" && req.method === "POST") {
endpointsHit.add("register")
res.writeHead(201, { "Content-Type": "application/json" })
res.end(
JSON.stringify({
client_id: "test-client-id",
redirect_uris: ["http://localhost:3000/callback"],
}),
)
return
}
if (url === "/auth/token" && req.method === "POST") {
endpointsHit.add("token")
res.writeHead(200, { "Content-Type": "application/json" })
res.end(
JSON.stringify({
access_token: "test-access-token",
token_type: "Bearer",
expires_in: 3600,
}),
)
return
}
// Capture authorize hits (only reachable if a real browser is present)
if (url.startsWith("/auth/authorize")) {
endpointsHit.add("authorize")
res.writeHead(200, { "Content-Type": "text/plain" })
res.end("Authorization endpoint reached")
return
}
res.writeHead(404)
res.end()
})
// Find an available port
mockServerPort = await new Promise<number>((resolve, reject) => {
mockServer.listen(0, "127.0.0.1", () => {
const addr = mockServer.address()
if (!addr || typeof addr === "string") return reject(new Error("Failed to get address"))
resolve(addr.port)
})
mockServer.on("error", reject)
})
const workspaceDir = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || tempDir
const rooDir = path.join(workspaceDir, ".roo")
await fs.mkdir(rooDir, { recursive: true })
const mcpConfig = {
mcpServers: {
"test-oauth-server": {
type: "streamable-http",
url: `http://localhost:${mockServerPort}/mcp`,
},
},
}
testFiles = { mcpConfig: path.join(rooDir, "mcp.json") }
await fs.writeFile(testFiles.mcpConfig, JSON.stringify(mcpConfig, null, 2))
console.log("[TEST] Mock server port:", mockServerPort)
console.log("[TEST] MCP config:", testFiles.mcpConfig)
})
suiteTeardown(async () => {
delete process.env.MCP_OAUTH_TEST_MODE
try {
await globalThis.api.cancelCurrentTask()
} catch {
// Task might not be running
}
if (mockServer) {
await new Promise<void>((resolve) => mockServer.close(() => resolve()))
}
for (const filePath of Object.values(testFiles)) {
try {
await fs.unlink(filePath)
} catch {
// ignore
}
}
// Only remove .roo/mcp.json if it's inside the ephemeral tempDir — never
// touch a real workspace's config.
const workspaceDir = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || tempDir
if (workspaceDir === tempDir || workspaceDir.startsWith(tempDir + path.sep)) {
try {
await fs.unlink(path.join(workspaceDir, ".roo", "mcp.json"))
} catch {
// ignore
}
}
await fs.rm(tempDir, { recursive: true, force: true })
})
setup(async () => {
try {
await globalThis.api.cancelCurrentTask()
} catch {
// ignore
}
endpointsHit.clear()
await sleep(100)
})
teardown(async () => {
try {
await globalThis.api.cancelCurrentTask()
} catch {
// ignore
}
await sleep(100)
})
test("Should complete the full OAuth flow when connecting to an OAuth-protected MCP server", async function () {
// Re-write the config to trigger the file watcher and force a reconnect.
const workspaceDir = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || tempDir
const mcpConfigPath = path.join(workspaceDir, ".roo", "mcp.json")
await fs.writeFile(
mcpConfigPath,
JSON.stringify(
{
mcpServers: {
"test-oauth-server": {
type: "streamable-http",
url: `http://localhost:${mockServerPort}/mcp`,
},
},
},
null,
2,
),
)
// Step 1: Initial connection attempt gets 401 → triggers OAuth discovery
await waitFor(() => endpointsHit.has("mcp-401"), { timeout: 30_000 })
console.log("[TEST] Got initial 401, OAuth flow started")
// Step 2: SDK discovers OAuth metadata
await waitFor(() => endpointsHit.has("resource-metadata"), { timeout: 15_000 })
console.log("[TEST] Resource metadata fetched")
await waitFor(() => endpointsHit.has("auth-metadata"), { timeout: 15_000 })
console.log("[TEST] Auth server metadata fetched")
// Step 3: Dynamic client registration
await waitFor(() => endpointsHit.has("register"), { timeout: 15_000 })
console.log("[TEST] Client registered")
// Step 4: In MCP_OAUTH_TEST_MODE the callback server resolves immediately with
// a test auth code (no real browser needed). The SDK exchanges it for a token.
await waitFor(() => endpointsHit.has("token"), { timeout: 15_000 })
console.log("[TEST] Access token obtained")
// Step 5: The background _completeOAuthFlow task retries client.connect() with
// the bearer token. Verify the MCP server receives an authenticated request.
await waitFor(() => endpointsHit.has("mcp-authed"), { timeout: 15_000 })
console.log("[TEST] MCP server connected with valid Bearer token")
// Assert the complete OAuth flow ran
assert.ok(endpointsHit.has("mcp-401"), "MCP server should return 401 to trigger OAuth")
assert.ok(endpointsHit.has("resource-metadata"), "Resource metadata discovery should run")
assert.ok(endpointsHit.has("auth-metadata"), "Auth server metadata discovery should run")
assert.ok(endpointsHit.has("register"), "Dynamic client registration should run")
assert.ok(endpointsHit.has("token"), "Token exchange should succeed")
assert.ok(endpointsHit.has("mcp-authed"), "Retry connection should succeed with Bearer token")
console.log("[TEST] MCP OAuth flow completed successfully. Endpoints hit:", [...endpointsHit])
})
test("Should reuse stored token on reconnect without re-running the full OAuth flow", async function () {
// Ensure a token is in SecretStorage before testing reuse — this makes the
// test self-contained regardless of execution order.
await waitFor(() => endpointsHit.has("token"), { timeout: 30_000 })
// Clear hit tracking so we can assert the token endpoint is NOT re-hit.
endpointsHit.clear()
const workspaceDir = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || tempDir
const mcpConfigPath = path.join(workspaceDir, ".roo", "mcp.json")
// Slightly modify the config to force a reconnect
await fs.writeFile(
mcpConfigPath,
JSON.stringify(
{
mcpServers: {
"test-oauth-server": {
type: "streamable-http",
url: `http://localhost:${mockServerPort}/mcp`,
// A different but valid timeout value triggers config-change detection
timeout: 30,
},
},
},
null,
2,
),
)
// Wait for the MCP server to receive an authenticated request
await waitFor(() => endpointsHit.has("mcp-authed"), { timeout: 30_000 })
console.log("[TEST] Token reuse: MCP server got authenticated request")
// The full OAuth flow should NOT have re-run (token was cached in SecretStorage)
assert.ok(endpointsHit.has("mcp-authed"), "Reconnect should use cached token")
assert.ok(!endpointsHit.has("mcp-401"), "Should not get 401 when token is cached")
assert.ok(!endpointsHit.has("register"), "Should not re-register client when token is cached")
console.log("[TEST] Token reuse test passed. Endpoints hit:", [...endpointsHit])
})
})

View file

@ -143,7 +143,10 @@ async function main() {
copyLocales(srcDir, distDir)
setupLocaleWatcher(srcDir, distDir)
} else {
await Promise.all([extensionCtx.rebuild(), workerCtx.rebuild()])
// Run sequentially on rebuild to avoid Windows EBUSY races when both
// onEnd hooks copy the same asset directories concurrently.
await extensionCtx.rebuild()
await workerCtx.rebuild()
await Promise.all([extensionCtx.dispose(), workerCtx.dispose()])
}
}

View file

@ -24,5 +24,28 @@
"refreshing_all": "Actualitzant tots els servidors MCP...",
"all_refreshed": "Tots els servidors MCP han estat actualitzats.",
"project_config_deleted": "Fitxer de configuració MCP del projecte eliminat. Tots els servidors MCP del projecte han estat desconnectats."
},
"oauth": {
"callback": {
"title": "Callback OAuth - Roo Code",
"success": "Èxit!",
"failed": "Error",
"auth_failed": "L'autenticació ha fallat. Si us plau, comprova els registres del servidor MCP.",
"auth_success": "El servidor MCP s'ha autenticat correctament. Ara pots tancar aquesta pestanya del navegador.",
"server_connection_complete": "La connexió al servidor és completa.",
"tab_closing_in": "Aquesta pestanya intentarà tancar-se en <span id=\"count\">{{count}}</span>s...",
"safe_to_close": "Si la pestanya no s'ha tancat, pots tancar-la manualment sense cap problema.",
"invalid_state": "Error: Paràmetre d'estat no vàlid"
},
"flow": {
"authenticating": "El servidor MCP \"{{name}}\" requereix autenticació",
"waitingForBrowser": "Completa l'inici de sessió al navegador...",
"clickAuthenticate": "El servidor MCP \"{{name}}\" està esperant autenticació.",
"dismissedHint": "Notificació descartada — cancel·la i torna a connectar el servidor per autenticar-te.",
"authenticateButton": "Autenticar",
"cancelled": "L'autenticació OAuth ha estat cancel·lada",
"timedOut": "L'autenticació OAuth ha expirat",
"connected": "El servidor MCP \"{{name}}\" s'ha connectat correctament després de l'autenticació OAuth."
}
}
}

View file

@ -24,5 +24,28 @@
"refreshing_all": "Alle MCP-Server werden aktualisiert...",
"all_refreshed": "Alle MCP-Server wurden aktualisiert.",
"project_config_deleted": "Projekt-MCP-Konfigurationsdatei gelöscht. Alle Projekt-MCP-Server wurden getrennt."
},
"oauth": {
"callback": {
"title": "OAuth Callback - Roo Code",
"success": "Erfolg!",
"failed": "Fehlgeschlagen",
"auth_failed": "Authentifizierung fehlgeschlagen. Bitte überprüfe die MCP-Server-Logs.",
"auth_success": "MCP-Server erfolgreich authentifiziert. Du kannst diesen Browser-Tab jetzt schließen.",
"server_connection_complete": "Die Serververbindung ist abgeschlossen.",
"tab_closing_in": "Dieser Tab wird in <span id=\"count\">{{count}}</span>s geschlossen...",
"safe_to_close": "Falls sich der Tab nicht automatisch geschlossen hat, kannst du ihn jetzt manuell schließen.",
"invalid_state": "Fehler: Ungültiger State-Parameter"
},
"flow": {
"authenticating": "MCP-Server \"{{name}}\" erfordert Authentifizierung",
"waitingForBrowser": "Schließe die Anmeldung in deinem Browser ab...",
"clickAuthenticate": "MCP-Server \"{{name}}\" wartet auf Authentifizierung.",
"dismissedHint": "Benachrichtigung geschlossen — brich ab und verbinde den Server erneut, um dich zu authentifizieren.",
"authenticateButton": "Authentifizieren",
"cancelled": "OAuth-Authentifizierung wurde abgebrochen",
"timedOut": "OAuth-Authentifizierung ist abgelaufen",
"connected": "MCP-Server \"{{name}}\" wurde nach der OAuth-Authentifizierung erfolgreich verbunden."
}
}
}

View file

@ -24,5 +24,28 @@
"refreshing_all": "Refreshing all MCP servers...",
"all_refreshed": "All MCP servers have been refreshed.",
"project_config_deleted": "Project MCP configuration file deleted. All project MCP servers have been disconnected."
},
"oauth": {
"callback": {
"title": "OAuth Callback - Roo Code",
"success": "Success!",
"failed": "Failed",
"auth_failed": "Authentication failed. Please check the MCP server logs.",
"auth_success": "MCP server authenticated successfully. You can now close this browser tab.",
"server_connection_complete": "The server connection is complete.",
"tab_closing_in": "This tab will attempt to close in <span id=\"count\">{{count}}</span>s...",
"safe_to_close": "If the tab did not close, you can safely close it manually.",
"invalid_state": "Error: Invalid state parameter"
},
"flow": {
"authenticating": "MCP server \"{{name}}\" requires authentication",
"waitingForBrowser": "Complete sign-in in your browser...",
"clickAuthenticate": "MCP server \"{{name}}\" is waiting for authentication.",
"dismissedHint": "Notification dismissed — cancel and reconnect the server to re-authenticate.",
"authenticateButton": "Authenticate",
"cancelled": "OAuth authentication was cancelled",
"timedOut": "OAuth authentication timed out",
"connected": "MCP server \"{{name}}\" connected successfully after OAuth authentication."
}
}
}

View file

@ -24,5 +24,28 @@
"refreshing_all": "Actualizando todos los servidores MCP...",
"all_refreshed": "Todos los servidores MCP han sido actualizados.",
"project_config_deleted": "Archivo de configuración MCP del proyecto eliminado. Todos los servidores MCP del proyecto han sido desconectados."
},
"oauth": {
"callback": {
"title": "OAuth Callback - Roo Code",
"success": "¡Éxito!",
"failed": "Fallido",
"auth_failed": "La autenticación falló. Por favor, comprueba los registros del servidor MCP.",
"auth_success": "Servidor MCP autenticado con éxito. Ya puedes cerrar esta pestaña del navegador.",
"server_connection_complete": "La conexión con el servidor se ha completado.",
"tab_closing_in": "Esta pestaña intentará cerrarse en <span id=\"count\">{{count}}</span>s...",
"safe_to_close": "Si la pestaña no se cerró, puedes cerrarla manualmente de forma segura.",
"invalid_state": "Error: Parámetro de estado no válido"
},
"flow": {
"authenticating": "El servidor MCP \"{{name}}\" requiere autenticación",
"waitingForBrowser": "Completa el inicio de sesión en tu navegador...",
"clickAuthenticate": "El servidor MCP \"{{name}}\" está esperando autenticación.",
"dismissedHint": "Notificación descartada — cancela y vuelve a conectar el servidor para autenticarte.",
"authenticateButton": "Autenticar",
"cancelled": "La autenticación OAuth fue cancelada",
"timedOut": "La autenticación OAuth ha expirado",
"connected": "El servidor MCP \"{{name}}\" se conectó correctamente tras la autenticación OAuth."
}
}
}

View file

@ -24,5 +24,28 @@
"refreshing_all": "Rafraîchissement de tous les serveurs MCP...",
"all_refreshed": "Tous les serveurs MCP ont été rafraîchis.",
"project_config_deleted": "Fichier de configuration MCP du projet supprimé. Tous les serveurs MCP du projet ont été déconnectés."
},
"oauth": {
"callback": {
"title": "Rappel OAuth - Roo Code",
"success": "Succès !",
"failed": "Échec",
"auth_failed": "L'authentification a échoué. Veuillez vérifier les journaux du serveur MCP.",
"auth_success": "Le serveur MCP a été authentifié avec succès. Vous pouvez maintenant fermer cet onglet de navigateur.",
"server_connection_complete": "La connexion au serveur est terminée.",
"tab_closing_in": "Cet onglet tentera de se fermer dans <span id=\"count\">{{count}}</span>s...",
"safe_to_close": "Si l'onglet ne s'est pas fermé, vous pouvez le fermer manuellement en toute sécurité.",
"invalid_state": "Erreur : Paramètre d'état invalide"
},
"flow": {
"authenticating": "Le serveur MCP \"{{name}}\" requiert une authentification",
"waitingForBrowser": "Finalise la connexion dans ton navigateur...",
"clickAuthenticate": "Le serveur MCP \"{{name}}\" attend une authentification.",
"dismissedHint": "Notification ignorée — annule et reconnecte le serveur pour t'authentifier.",
"authenticateButton": "Authentifier",
"cancelled": "L'authentification OAuth a été annulée",
"timedOut": "L'authentification OAuth a expiré",
"connected": "Le serveur MCP \"{{name}}\" s'est connecté avec succès après l'authentification OAuth."
}
}
}

View file

@ -24,5 +24,28 @@
"refreshing_all": "सभी एमसीपी सर्वर रीफ्रेश हो रहे हैं...",
"all_refreshed": "सभी एमसीपी सर्वर रीफ्रेश हो गए हैं।",
"project_config_deleted": "प्रोजेक्ट एमसीपी कॉन्फ़िगरेशन फ़ाइल हटा दी गई है। सभी प्रोजेक्ट एमसीपी सर्वर डिस्कनेक्ट कर दिए गए हैं।"
},
"oauth": {
"callback": {
"title": "OAuth कॉलबैक - Roo Code",
"success": "सफलता!",
"failed": "विफल",
"auth_failed": "प्रमाणीकरण विफल रहा। कृपया MCP सर्वर लॉग की जाँच करें।",
"auth_success": "MCP सर्वर सफलतापूर्वक प्रमाणित हो गया। अब आप इस ब्राउज़र टैब को बंद कर सकते हैं।",
"server_connection_complete": "सर्वर कनेक्शन पूरा हो गया है।",
"tab_closing_in": "यह टैब <span id=\"count\">{{count}}</span> सेकंड में बंद होने का प्रयास करेगा...",
"safe_to_close": "यदि टैब बंद नहीं हुआ, तो आप इसे सुरक्षित रूप से मैन्युअल रूप से बंद कर सकते हैं।",
"invalid_state": "त्रुटि: अमान्य स्थिति पैरामीटर"
},
"flow": {
"authenticating": "MCP सर्वर \"{{name}}\" को प्रमाणीकरण की आवश्यकता है",
"waitingForBrowser": "अपने ब्राउज़र में साइन-इन पूरा करें...",
"clickAuthenticate": "MCP सर्वर \"{{name}}\" प्रमाणीकरण की प्रतीक्षा कर रहा है।",
"dismissedHint": "सूचना खारिज की गई — पुनः प्रमाणित करने के लिए सर्वर को रद्द करें और पुनः कनेक्ट करें।",
"authenticateButton": "प्रमाणित करें",
"cancelled": "OAuth प्रमाणीकरण रद्द किया गया",
"timedOut": "OAuth प्रमाणीकरण का समय समाप्त हो गया",
"connected": "OAuth प्रमाणीकरण के बाद MCP सर्वर \"{{name}}\" सफलतापूर्वक कनेक्ट हो गया।"
}
}
}

View file

@ -24,5 +24,28 @@
"refreshing_all": "Me-refresh semua server MCP...",
"all_refreshed": "Semua server MCP telah di-refresh.",
"project_config_deleted": "File konfigurasi MCP proyek dihapus. Semua server MCP proyek telah diputus koneksinya."
},
"oauth": {
"callback": {
"title": "Callback OAuth - Roo Code",
"success": "Berhasil!",
"failed": "Gagal",
"auth_failed": "Autentikasi gagal. Silakan periksa log server MCP.",
"auth_success": "Server MCP berhasil diautentikasi. Anda sekarang dapat menutup tab browser ini.",
"server_connection_complete": "Koneksi server selesai.",
"tab_closing_in": "Tab ini akan mencoba menutup dalam <span id=\"count\">{{count}}</span> detik...",
"safe_to_close": "Jika tab tidak tertutup, Anda dapat menutupnya secara manual dengan aman.",
"invalid_state": "Kesalahan: Parameter state tidak valid"
},
"flow": {
"authenticating": "Server MCP \"{{name}}\" memerlukan autentikasi",
"waitingForBrowser": "Selesaikan masuk di browser Anda...",
"clickAuthenticate": "Server MCP \"{{name}}\" menunggu autentikasi.",
"dismissedHint": "Notifikasi ditutup — batalkan dan hubungkan kembali server untuk autentikasi ulang.",
"authenticateButton": "Autentikasi",
"cancelled": "Autentikasi OAuth dibatalkan",
"timedOut": "Autentikasi OAuth habis waktu",
"connected": "Server MCP \"{{name}}\" berhasil terhubung setelah autentikasi OAuth."
}
}
}

View file

@ -24,5 +24,28 @@
"refreshing_all": "Aggiornamento di tutti i server MCP...",
"all_refreshed": "Tutti i server MCP sono stati aggiornati.",
"project_config_deleted": "File di configurazione MCP del progetto eliminato. Tutti i server MCP del progetto sono stati disconnessi."
},
"oauth": {
"callback": {
"title": "Callback OAuth - Roo Code",
"success": "Successo!",
"failed": "Fallito",
"auth_failed": "Autenticazione fallita. Controlla i log del server MCP.",
"auth_success": "Server MCP autenticato con successo. Ora puoi chiudere questa scheda del browser.",
"server_connection_complete": "La connessione al server è completa.",
"tab_closing_in": "Questa scheda tenterà di chiudersi tra <span id=\"count\">{{count}}</span>s...",
"safe_to_close": "Se la scheda non si è chiusa, puoi chiuderla manualmente in sicurezza.",
"invalid_state": "Errore: Parametro di stato non valido"
},
"flow": {
"authenticating": "Il server MCP \"{{name}}\" richiede autenticazione",
"waitingForBrowser": "Completa l'accesso nel tuo browser...",
"clickAuthenticate": "Il server MCP \"{{name}}\" è in attesa di autenticazione.",
"dismissedHint": "Notifica ignorata — annulla e riconnetti il server per autenticarti.",
"authenticateButton": "Autenticarsi",
"cancelled": "L'autenticazione OAuth è stata annullata",
"timedOut": "L'autenticazione OAuth è scaduta",
"connected": "Il server MCP \"{{name}}\" si è connesso con successo dopo l'autenticazione OAuth."
}
}
}

View file

@ -24,5 +24,28 @@
"refreshing_all": "すべてのMCPサーバーを更新しています...",
"all_refreshed": "すべてのMCPサーバーが更新されました。",
"project_config_deleted": "プロジェクトMCP設定ファイルが削除されました。すべてのプロジェクトMCPサーバーが切断されました。"
},
"oauth": {
"callback": {
"title": "OAuth コールバック - Roo Code",
"success": "成功!",
"failed": "失敗",
"auth_failed": "認証に失敗しました。MCP サーバーのログを確認してください。",
"auth_success": "MCP サーバーの認証に成功しました。このブラウザタブを閉じても構いません。",
"server_connection_complete": "サーバー接続が完了しました。",
"tab_closing_in": "このタブは <span id=\"count\">{{count}}</span> 秒後に閉じられます...",
"safe_to_close": "タブが閉じなかった場合は、手動で閉じてください。",
"invalid_state": "エラー: 無効な state パラメータ"
},
"flow": {
"authenticating": "MCPサーバー \"{{name}}\" は認証が必要です",
"waitingForBrowser": "ブラウザでサインインを完了してください...",
"clickAuthenticate": "MCPサーバー \"{{name}}\" は認証を待っています。",
"dismissedHint": "通知が閉じられました — 再認証するにはサーバーをキャンセルして再接続してください。",
"authenticateButton": "認証する",
"cancelled": "OAuth認証がキャンセルされました",
"timedOut": "OAuth認証がタイムアウトしました",
"connected": "OAuth認証後、MCPサーバー \"{{name}}\" が正常に接続されました。"
}
}
}

View file

@ -24,5 +24,28 @@
"refreshing_all": "모든 MCP 서버를 새로 고치는 중...",
"all_refreshed": "모든 MCP 서버가 새로 고쳐졌습니다.",
"project_config_deleted": "프로젝트 MCP 구성 파일이 삭제되었습니다. 모든 프로젝트 MCP 서버가 연결 해제되었습니다."
},
"oauth": {
"callback": {
"title": "OAuth 콜백 - Roo Code",
"success": "성공!",
"failed": "실패",
"auth_failed": "인증에 실패했습니다. MCP 서버 로그를 확인해 주세요.",
"auth_success": "MCP 서버 인증에 성공했습니다. 이제 이 브라우저 탭을 닫으셔도 됩니다.",
"server_connection_complete": "서버 연결이 완료되었습니다.",
"tab_closing_in": "이 탭은 <span id=\"count\">{{count}}</span>초 후에 닫힙니다...",
"safe_to_close": "탭이 자동으로 닫히지 않으면 직접 닫아주세요.",
"invalid_state": "오류: 유효하지 않은 state 파라미터"
},
"flow": {
"authenticating": "MCP 서버 \"{{name}}\"는 인증이 필요합니다",
"waitingForBrowser": "브라우저에서 로그인을 완료하세요...",
"clickAuthenticate": "MCP 서버 \"{{name}}\"가 인증을 기다리고 있습니다.",
"dismissedHint": "알림이 닫혔습니다 — 재인증하려면 서버를 취소하고 재연결하세요.",
"authenticateButton": "인증",
"cancelled": "OAuth 인증이 취소되었습니다",
"timedOut": "OAuth 인증 시간이 초과되었습니다",
"connected": "OAuth 인증 후 MCP 서버 \"{{name}}\"가 성공적으로 연결되었습니다."
}
}
}

View file

@ -24,5 +24,28 @@
"refreshing_all": "Alle MCP-servers worden vernieuwd...",
"all_refreshed": "Alle MCP-servers zijn vernieuwd.",
"project_config_deleted": "Project MCP-configuratiebestand verwijderd. Alle project MCP-servers zijn losgekoppeld."
},
"oauth": {
"callback": {
"title": "OAuth Callback - Roo Code",
"success": "Succes!",
"failed": "Mislukt",
"auth_failed": "Authenticatie mislukt. Controleer de MCP-serverlogs.",
"auth_success": "MCP-server succesvol geauthenticeerd. Je kunt dit browsertabblad nu sluiten.",
"server_connection_complete": "De serververbinding is voltooid.",
"tab_closing_in": "Dit tabblad wordt gesloten over <span id=\"count\">{{count}}</span>s...",
"safe_to_close": "Als het tabblad niet is gesloten, kun je het handmatig sluiten.",
"invalid_state": "Fout: Ongeldige state-parameter"
},
"flow": {
"authenticating": "MCP-server \"{{name}}\" vereist authenticatie",
"waitingForBrowser": "Voltooi het aanmelden in je browser...",
"clickAuthenticate": "MCP-server \"{{name}}\" wacht op authenticatie.",
"dismissedHint": "Melding gesloten — annuleer en verbind de server opnieuw om je te authenticeren.",
"authenticateButton": "Authenticeren",
"cancelled": "OAuth-authenticatie is geannuleerd",
"timedOut": "OAuth-authenticatie is verlopen",
"connected": "MCP-server \"{{name}}\" is succesvol verbonden na OAuth-authenticatie."
}
}
}

View file

@ -24,5 +24,28 @@
"refreshing_all": "Odświeżanie wszystkich serwerów MCP...",
"all_refreshed": "Wszystkie serwery MCP zostały odświeżone.",
"project_config_deleted": "Plik konfiguracyjny MCP projektu został usunięty. Wszystkie serwery MCP projektu zostały odłączone."
},
"oauth": {
"callback": {
"title": "Wywołanie zwrotne OAuth - Roo Code",
"success": "Sukces!",
"failed": "Niepowodzenie",
"auth_failed": "Uwierzytelnianie nie powiodło się. Sprawdź dzienniki serwera MCP.",
"auth_success": "Serwer MCP został pomyślnie uwierzytelniony. Możesz teraz zamknąć tę kartę przeglądarki.",
"server_connection_complete": "Połączenie z serwerem zostało zakończone.",
"tab_closing_in": "Ta karta spróbuje się zamknąć za <span id=\"count\">{{count}}</span>s...",
"safe_to_close": "Jeśli karta nie zamknęła się automatycznie, możesz ją bezpiecznie zamknąć ręcznie.",
"invalid_state": "Błąd: Nieprawidłowy parametr stanu"
},
"flow": {
"authenticating": "Serwer MCP \"{{name}}\" wymaga uwierzytelnienia",
"waitingForBrowser": "Ukończ logowanie w przeglądarce...",
"clickAuthenticate": "Serwer MCP \"{{name}}\" oczekuje na uwierzytelnienie.",
"dismissedHint": "Powiadomienie odrzucone — anuluj i ponownie połącz serwer, aby się uwierzytelnić.",
"authenticateButton": "Uwierzytelnij",
"cancelled": "Uwierzytelnianie OAuth zostało anulowane",
"timedOut": "Uwierzytelnianie OAuth przekroczyło limit czasu",
"connected": "Serwer MCP \"{{name}}\" połączył się pomyślnie po uwierzytelnieniu OAuth."
}
}
}

View file

@ -24,5 +24,28 @@
"refreshing_all": "Atualizando todos os servidores MCP...",
"all_refreshed": "Todos os servidores MCP foram atualizados.",
"project_config_deleted": "Arquivo de configuração MCP do projeto excluído. Todos os servidores MCP do projeto foram desconectados."
},
"oauth": {
"callback": {
"title": "Callback OAuth - Roo Code",
"success": "Sucesso!",
"failed": "Falhou",
"auth_failed": "A autenticação falhou. Verifique os logs do servidor MCP.",
"auth_success": "Servidor MCP autenticado com sucesso. Você já pode fechar esta aba do navegador.",
"server_connection_complete": "A conexão com o servidor foi concluída.",
"tab_closing_in": "Esta aba tentará fechar em <span id=\"count\">{{count}}</span>s...",
"safe_to_close": "Se a aba não fechar, você pode fechá-la manualmente com segurança.",
"invalid_state": "Erro: Parâmetro de estado inválido"
},
"flow": {
"authenticating": "O servidor MCP \"{{name}}\" requer autenticação",
"waitingForBrowser": "Conclua o login no seu navegador...",
"clickAuthenticate": "O servidor MCP \"{{name}}\" está aguardando autenticação.",
"dismissedHint": "Notificação descartada — cancele e reconecte o servidor para autenticar-se.",
"authenticateButton": "Autenticar",
"cancelled": "A autenticação OAuth foi cancelada",
"timedOut": "A autenticação OAuth expirou",
"connected": "O servidor MCP \"{{name}}\" foi conectado com sucesso após a autenticação OAuth."
}
}
}

View file

@ -24,5 +24,28 @@
"refreshing_all": "Обновление всех MCP серверов...",
"all_refreshed": "Все MCP серверы обновлены.",
"project_config_deleted": "Файл конфигурации MCP проекта удален. Все MCP серверы проекта отключены."
},
"oauth": {
"callback": {
"title": "OAuth Callback - Roo Code",
"success": "Успешно!",
"failed": "Ошибка",
"auth_failed": "Ошибка аутентификации. Пожалуйста, проверьте логи MCP-сервера.",
"auth_success": "MCP-сервер успешно аутентифицирован. Теперь вы можете закрыть эту вкладку браузера.",
"server_connection_complete": "Подключение к серверу завершено.",
"tab_closing_in": "Эта вкладка попытается закрыться через <span id=\"count\">{{count}}</span> сек...",
"safe_to_close": "Если вкладка не закрылась, вы можете закрыть ее вручную.",
"invalid_state": "Ошибка: Недопустимый параметр состояния"
},
"flow": {
"authenticating": "Сервер MCP \"{{name}}\" требует аутентификации",
"waitingForBrowser": "Завершите вход в браузере...",
"clickAuthenticate": "Сервер MCP \"{{name}}\" ожидает аутентификации.",
"dismissedHint": "Уведомление закрыто — отмените подключение и переподключите сервер для повторной аутентификации.",
"authenticateButton": "Аутентифицировать",
"cancelled": "Аутентификация OAuth отменена",
"timedOut": "Время аутентификации OAuth истекло",
"connected": "Сервер MCP \"{{name}}\" успешно подключён после аутентификации OAuth."
}
}
}

View file

@ -24,5 +24,28 @@
"refreshing_all": "Tüm MCP sunucuları yenileniyor...",
"all_refreshed": "Tüm MCP sunucuları yenilendi.",
"project_config_deleted": "Proje MCP yapılandırma dosyası silindi. Tüm proje MCP sunucuları bağlantısı kesildi."
},
"oauth": {
"callback": {
"title": "OAuth Geri Çağırma - Roo Code",
"success": "Başarılı!",
"failed": "Başarısız",
"auth_failed": "Kimlik doğrulama başarısız oldu. Lütfen MCP sunucu günlüklerini kontrol edin.",
"auth_success": "MCP sunucusu başarıyla doğrulandı. Artık bu tarayıcı sekmesini kapatabilirsiniz.",
"server_connection_complete": "Sunucu bağlantısı tamamlandı.",
"tab_closing_in": "Bu sekme <span id=\"count\">{{count}}</span> saniye içinde kapanmaya çalışacak...",
"safe_to_close": "Sekme kapanmadıysa, manuel olarak güvenle kapatabilirsiniz.",
"invalid_state": "Hata: Geçersiz durum parametresi"
},
"flow": {
"authenticating": "MCP sunucusu \"{{name}}\" kimlik doğrulaması gerektiriyor",
"waitingForBrowser": "Tarayıcınızda oturum açmayı tamamlayın...",
"clickAuthenticate": "MCP sunucusu \"{{name}}\" kimlik doğrulaması bekliyor.",
"dismissedHint": "Bildirim kapatıldı — yeniden kimlik doğrulamak için sunucuyu iptal edin ve yeniden bağlanın.",
"authenticateButton": "Kimlik Doğrula",
"cancelled": "OAuth kimlik doğrulaması iptal edildi",
"timedOut": "OAuth kimlik doğrulaması zaman aşımına uğradı",
"connected": "MCP sunucusu \"{{name}}\" OAuth kimlik doğrulamasından sonra başarıyla bağlandı."
}
}
}

View file

@ -24,5 +24,28 @@
"refreshing_all": "Đang làm mới tất cả các máy chủ MCP...",
"all_refreshed": "Tất cả các máy chủ MCP đã được làm mới.",
"project_config_deleted": "Tệp cấu hình MCP của dự án đã bị xóa. Tất cả các máy chủ MCP của dự án đã bị ngắt kết nối."
},
"oauth": {
"callback": {
"title": "OAuth Callback - Roo Code",
"success": "Thành công!",
"failed": "Thất bại",
"auth_failed": "Xác thực thất bại. Vui lòng kiểm tra nhật ký máy chủ MCP.",
"auth_success": "Máy chủ MCP đã được xác thực thành công. Bây giờ bạn có thể đóng tab trình duyệt này.",
"server_connection_complete": "Kết nối máy chủ đã hoàn tất.",
"tab_closing_in": "Tab này sẽ cố gắng đóng sau <span id=\"count\">{{count}}</span> giây...",
"safe_to_close": "Nếu tab không đóng, bạn có thể đóng nó một cách thủ công an toàn.",
"invalid_state": "Lỗi: Tham số trạng thái không hợp lệ"
},
"flow": {
"authenticating": "Máy chủ MCP \"{{name}}\" yêu cầu xác thực",
"waitingForBrowser": "Hoàn tất đăng nhập trong trình duyệt của bạn...",
"clickAuthenticate": "Máy chủ MCP \"{{name}}\" đang chờ xác thực.",
"dismissedHint": "Thông báo đã bị đóng — hủy và kết nối lại máy chủ để xác thực lại.",
"authenticateButton": "Xác thực",
"cancelled": "Xác thực OAuth đã bị hủy",
"timedOut": "Xác thực OAuth đã hết thời gian chờ",
"connected": "Máy chủ MCP \"{{name}}\" đã kết nối thành công sau khi xác thực OAuth."
}
}
}

View file

@ -24,5 +24,28 @@
"refreshing_all": "正在刷新所有 MCP 服务器...",
"all_refreshed": "所有 MCP 服务器已刷新。",
"project_config_deleted": "项目MCP配置文件已删除。所有项目MCP服务器已断开连接。"
},
"oauth": {
"callback": {
"title": "OAuth 回调 - Roo Code",
"success": "成功!",
"failed": "失败",
"auth_failed": "身份验证失败。请检查 MCP 服务器日志。",
"auth_success": "MCP 服务器身份验证成功。您现在可以关闭此浏览器标签页。",
"server_connection_complete": "服务器连接已完成。",
"tab_closing_in": "此标签页将在 <span id=\"count\">{{count}}</span> 秒后尝试关闭...",
"safe_to_close": "如果标签页没有关闭,您可以安全地手动关闭它。",
"invalid_state": "错误:无效的状态参数"
},
"flow": {
"authenticating": "MCP 服务 \"{{name}}\" 需要身份验证",
"waitingForBrowser": "在浏览器中完成登录...",
"clickAuthenticate": "MCP 服务 \"{{name}}\" 正在等待身份验证。",
"dismissedHint": "通知已关闭 — 请取消并重新连接服务以重新验证身份。",
"authenticateButton": "验证身份",
"cancelled": "OAuth 身份验证已取消",
"timedOut": "OAuth 身份验证已超时",
"connected": "OAuth 身份验证后MCP 服务 \"{{name}}\" 已成功连接。"
}
}
}

View file

@ -24,5 +24,28 @@
"refreshing_all": "正在重新整理所有 MCP 伺服器...",
"all_refreshed": "所有 MCP 伺服器已重新整理。",
"project_config_deleted": "專案MCP設定檔案已刪除。所有專案MCP伺服器已斷開連接。"
},
"oauth": {
"callback": {
"title": "OAuth 回呼 - Roo Code",
"success": "成功!",
"failed": "失敗",
"auth_failed": "身分驗證失敗。請檢查 MCP 伺服器日誌。",
"auth_success": "MCP 伺服器身分驗證成功。您現在可以關閉此瀏覽器分頁。",
"server_connection_complete": "伺服器連接已完成。",
"tab_closing_in": "此分頁將在 <span id=\"count\">{{count}}</span> 秒後嘗試關閉...",
"safe_to_close": "如果分頁沒有關閉,您可以安全地手動關閉它。",
"invalid_state": "錯誤:無效的狀態參數"
},
"flow": {
"authenticating": "MCP 伺服器 \"{{name}}\" 需要身份驗證",
"waitingForBrowser": "在瀏覽器中完成登入...",
"clickAuthenticate": "MCP 伺服器 \"{{name}}\" 正在等待身份驗證。",
"dismissedHint": "通知已關閉 — 請取消並重新連線伺服器以重新驗證身份。",
"authenticateButton": "驗證身份",
"cancelled": "OAuth 身份驗證已取消",
"timedOut": "OAuth 身份驗證已逾時",
"connected": "OAuth 身份驗證後MCP 伺服器 \"{{name}}\" 已成功連線。"
}
}
}

View file

@ -33,8 +33,12 @@ import { t } from "../../i18n"
import { ClineProvider } from "../../core/webview/ClineProvider"
import { GlobalFileNames } from "../../shared/globalFileNames"
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
import { fileExistsAtPath } from "../../utils/fs"
import { TOKEN_EXPIRY_BUFFER_MS, OAUTH_FLOW_TIMEOUT_MS } from "./constants"
import { SecretStorageService } from "./SecretStorageService"
import { McpOAuthClientProvider } from "./McpOAuthClientProvider"
import { arePathsEqual, getWorkspacePath } from "../../utils/path"
import { injectVariables } from "../../utils/config"
import { safeWriteJson } from "../../utils/safeWriteJson"
@ -46,6 +50,7 @@ export type ConnectedMcpConnection = {
server: McpServer
client: Client
transport: StdioClientTransport | SSEClientTransport | StreamableHTTPClientTransport
authProvider?: McpOAuthClientProvider
}
export type DisconnectedMcpConnection = {
@ -162,9 +167,15 @@ export class McpHub {
private flagResetTimer?: NodeJS.Timeout
private sanitizedNameRegistry: Map<string, string> = new Map()
private initializationPromise: Promise<void>
private secretStorage?: SecretStorageService
private reauthPromises: Map<string, Promise<void>> = new Map()
private _oauthWatchers: Map<string, { unsubscribe: () => void; abortHandle: NodeJS.Timeout }> = new Map()
constructor(provider: ClineProvider) {
constructor(provider: ClineProvider, secretStorage?: SecretStorageService) {
this.providerRef = new WeakRef(provider)
if (secretStorage) {
this.secretStorage = secretStorage
}
this.watchMcpSettingsFile()
this.watchProjectMcpFile().catch(console.error)
this.setupWorkspaceFoldersWatcher()
@ -181,13 +192,13 @@ export class McpHub {
async waitUntilReady(): Promise<void> {
await this.initializationPromise
}
/**
* Registers a client (e.g., ClineProvider) using this hub.
* Increments the reference count.
*/
public registerClient(): void {
this.refCount++
// console.log(`McpHub: Client registered. Ref count: ${this.refCount}`)
}
/**
@ -197,8 +208,6 @@ export class McpHub {
public async unregisterClient(): Promise<void> {
this.refCount--
// console.log(`McpHub: Client unregistered. Ref count: ${this.refCount}`)
if (this.refCount <= 0) {
console.log("McpHub: Last client unregistered. Disposing hub.")
await this.dispose()
@ -696,6 +705,7 @@ export class McpHub {
)
let transport: StdioClientTransport | SSEClientTransport | StreamableHTTPClientTransport
let streamableHttpAuthProvider: McpOAuthClientProvider | undefined
// Inject variables to the config (environment, magic variables,...)
const configInjected = (await injectVariables(config, {
@ -779,18 +789,84 @@ export class McpHub {
console.error(`No stderr stream for ${name}`)
}
} else if (configInjected.type === "streamable-http") {
// Streamable HTTP connection
if (!this.secretStorage) {
throw new Error("SecretStorageService not initialized — call setSecretStorage() before connecting")
}
// Decide whether to perform OAuth discovery (RFC 9728 + RFC 8414)
// upfront or skip it to avoid a wasted network round-trip for
// non-OAuth servers.
//
// Three cases:
// 1. SecretStorage has OAuth data → known OAuth server → discover
// 2. In-memory negative cache says non-OAuth → skip discovery
// 3. Unknown server (first connection) → discover once, cache result
const hasOAuthData = await this.secretStorage.hasOAuthData(configInjected.url)
const knownNonOAuth = McpOAuthClientProvider.isKnownNonOAuth(configInjected.url)
const skipDiscovery = !hasOAuthData && knownNonOAuth
const authProvider = await McpOAuthClientProvider.create(configInjected.url, this.secretStorage, name, {
skipDiscovery,
})
// Pre-register the OAuth client so the SDK can skip its own
// registration step (broken for path-prefixed issuers — see
// utils/oauth.ts for upstream issue links).
// Skip when discovery was skipped — there's no metadata to register with.
if (!skipDiscovery) {
try {
await authProvider.registerClientIfNeeded()
} catch {
// Registration may not be supported — the SDK will attempt its own.
}
}
transport = new StreamableHTTPClientTransport(new URL(configInjected.url), {
requestInit: {
headers: configInjected.headers,
},
authProvider,
requestInit: { headers: configInjected.headers },
})
// Set up Streamable HTTP specific error handling
transport.onerror = async (error) => {
console.error(`Transport error for "${name}" (streamable-http):`, error)
const connection = this.findConnection(name, source)
if (connection) {
if (connection && connection.type === "connected") {
if (error instanceof UnauthorizedError && authProvider) {
// If we're already in the OAuth / polling flow, ignore transport
// retries that surface another 401 — the poll or _completeOAuthFlow
// will reconnect from scratch once tokens arrive.
if (connection.server.status === "connecting") {
return
}
// Mid-session re-auth triggered by a tool call (401)
connection.server.status = "connecting"
const reauthKey = `${name}:${source}`
let reauthPromise = this.reauthPromises.get(reauthKey)
if (!reauthPromise) {
reauthPromise = this._initiateOAuthFlow(
name,
source,
JSON.parse(connection.server.config),
authProvider,
transport as StreamableHTTPClientTransport,
connection as ConnectedMcpConnection,
)
.catch((err) => {
console.error(`OAuth flow failed for "${name}":`, err)
})
.finally(() => {
this.reauthPromises.delete(reauthKey)
})
this.reauthPromises.set(reauthKey, reauthPromise)
}
return
}
connection.server.status = "disconnected"
this.appendErrorMessage(connection, error instanceof Error ? error.message : `${error}`)
} else if (connection) {
connection.server.status = "disconnected"
this.appendErrorMessage(connection, error instanceof Error ? error.message : `${error}`)
}
@ -800,10 +876,19 @@ export class McpHub {
transport.onclose = async () => {
const connection = this.findConnection(name, source)
if (connection) {
// If OAuth is in progress, don't overwrite "connecting" with "disconnected".
// The transport will close/retry while we await the browser flow or poll;
// the reconnect path (deleteConnection + connectToServer) handles cleanup.
if (connection.server.status === "connecting") {
return
}
connection.server.status = "disconnected"
}
await this.notifyWebviewOfServerChanges()
}
// Keep a reference so the UnauthorizedError handler can use it.
streamableHttpAuthProvider = authProvider
} else if (configInjected.type === "sse") {
// SSE connection
const sseOptions = {
@ -871,11 +956,54 @@ export class McpHub {
},
client,
transport,
authProvider: streamableHttpAuthProvider,
}
this.connections.push(connection)
// Connect (this will automatically start the transport)
await client.connect(transport)
try {
await client.connect(transport)
} catch (connectError) {
if (connectError instanceof UnauthorizedError && streamableHttpAuthProvider && configInjected.url) {
// The server requires OAuth.
McpOAuthClientProvider.clearNonOAuthCache(configInjected.url)
// If discovery was skipped (provider has no metadata), we need to
// tear down and reconnect with full discovery now that we know
// this is an OAuth server. The reconnect will do discovery since
// the negative cache was cleared and no SecretStorage data exists yet.
if (!streamableHttpAuthProvider.hasMetadata) {
await streamableHttpAuthProvider.close()
await this.deleteConnection(name, source)
void this.connectToServer(name, config, source)
return
}
// Mark this connection as "connecting" and detach the toast +
// browser flow from the initialization path so that
// waitUntilReady() resolves immediately and the MCP panel can load.
connection.server.status = "connecting"
void this._initiateOAuthFlow(
name,
source,
config,
streamableHttpAuthProvider,
transport as StreamableHTTPClientTransport,
connection,
)
return
}
// Non-OAuth error — let the outer catch handle it.
await streamableHttpAuthProvider?.close()
throw connectError
}
// Successful connection — close callback server if it was started.
// We keep the authProvider on the connection so it can handle mid-session 401s.
await streamableHttpAuthProvider?.close()
connection.server.status = "connected"
connection.server.error = ""
connection.server.instructions = client.getInstructions()
@ -895,6 +1023,293 @@ export class McpHub {
}
}
/**
* Background task: waits for the user to complete the OAuth browser flow,
* exchanges the auth code for tokens, then reconnects from scratch.
*
* After the SDK throws UnauthorizedError the transport is left in a
* "started" state (_abortController is set), so we cannot simply call
* client.connect() on it again the SDK would throw "already started".
* The clean solution is to delete the broken connection and let
* connectToServer() create fresh client/transport objects. The new
* provider will find the token in SecretStorage and connect without
* triggering another OAuth round-trip.
*
* This runs detached from the initialization path so `waitUntilReady()`
* and the rest of the extension are not blocked by the user's browser session.
*/
private async _initiateOAuthFlow(
name: string,
source: "global" | "project",
config: z.infer<typeof ServerConfigSchema>,
authProvider: McpOAuthClientProvider,
transport: StreamableHTTPClientTransport,
connection: ConnectedMcpConnection,
): Promise<void> {
const serverUrl = "url" in config ? (config as any).url : undefined
if (!serverUrl || !this.secretStorage) {
return
}
// Register the cross-window token watcher BEFORE the initial token read so we
// don't miss a write that lands in the gap between the read and the subscription.
// After the read we immediately unsubscribe; the watcher inside _runOAuthFlow
// will set up its own long-lived subscription for the duration of the flow.
// Note: onDidChange fires on both writes AND deletes — the re-read below is
// authoritative; we only reconnect if it returns a valid (non-expired) token.
let tokenChangedDuringRead = false
const unsubscribeCrossWindow = this.secretStorage.onDidChange(serverUrl, () => {
tokenChangedDuringRead = true
})
// Check if another window already saved valid tokens.
// Re-read after registering the watcher in case a write landed in the gap.
const existing = await this.secretStorage.getOAuthData(serverUrl)
if (this.isDisposed) {
unsubscribeCrossWindow()
return
}
// If the first read missed but the watcher fired during it, re-read — a write
// may have landed between subscription and read completion.
const tokenToUse =
existing ?? (tokenChangedDuringRead ? await this.secretStorage.getOAuthData(serverUrl) : undefined)
unsubscribeCrossWindow()
if (!this.isDisposed && tokenToUse && Date.now() < tokenToUse.expires_at - TOKEN_EXPIRY_BUFFER_MS) {
await authProvider.close()
await this.deleteConnection(name, source)
await this.connectToServer(name, config, source)
await this.notifyWebviewOfServerChanges()
return
}
// Persistent progress notification gives the user a visible Cancel button
// for the duration of the flow (even if they switch away from VS Code).
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: t("mcp:oauth.flow.authenticating", { name }),
cancellable: true,
},
(progress, cancellationToken) =>
this._runOAuthFlow(
name,
source,
config,
serverUrl,
authProvider,
transport,
connection,
progress,
cancellationToken,
),
)
}
private _runOAuthFlow(
name: string,
source: "global" | "project",
config: z.infer<typeof ServerConfigSchema>,
serverUrl: string,
authProvider: McpOAuthClientProvider,
transport: StreamableHTTPClientTransport,
connection: ConnectedMcpConnection,
progress: vscode.Progress<{ message?: string; increment?: number }>,
cancellationToken: vscode.CancellationToken,
): Promise<void> {
const watcherKey = `${name}:${source}`
// Cancel any existing watcher for this connection before starting a new one
const existing = this._oauthWatchers.get(watcherKey)
if (existing) {
existing.unsubscribe()
clearTimeout(existing.abortHandle)
this._oauthWatchers.delete(watcherKey)
}
return new Promise<void>((resolve) => {
let disposed = false
let cancellationDisposable: vscode.Disposable | undefined
const cleanup = () => {
if (disposed) return
disposed = true
clearTimeout(timeoutHandle)
unsubscribe()
cancellationDisposable?.dispose()
this._oauthWatchers.delete(watcherKey)
}
// --- Cross-window token watcher ---
const onTokensChanged = async () => {
if (disposed || this.isDisposed) return
// Don't reconnect if the server is already connected (e.g. a token refresh
// from another window fired after _completeOAuthFlow already succeeded).
const conn = this.findConnection(name, source)
if (!conn || conn.server.status === "connected") {
cleanup()
return
}
try {
const data = await this.secretStorage?.getOAuthData(serverUrl)
// Re-check after the async yield — cancellation or completion may have
// fired while getOAuthData was in flight.
if (disposed || this.isDisposed) return
if (data && Date.now() < data.expires_at - TOKEN_EXPIRY_BUFFER_MS) {
cleanup()
await authProvider.close()
await this.deleteConnection(name, source)
const validatedConfig = this.validateServerConfig(config, name)
await this.connectToServer(name, validatedConfig, source)
await this.notifyWebviewOfServerChanges()
resolve()
}
} catch (err) {
console.error(`[McpHub] OAuth token watcher failed for "${name}":`, err)
}
}
const unsubscribe = this.secretStorage!.onDidChange(serverUrl, () => {
void onTokensChanged()
})
// --- Timeout ---
const timeoutHandle = setTimeout(() => {
if (disposed) return
cleanup()
void authProvider.close()
const conn = this.findConnection(name, source)
if (conn && conn.server.status === "connecting") {
conn.server.status = "disconnected"
this.appendErrorMessage(conn, t("mcp:oauth.flow.timedOut"))
void this.notifyWebviewOfServerChanges()
}
resolve()
}, OAUTH_FLOW_TIMEOUT_MS)
// --- Cancellation (progress bar's Cancel button) ---
cancellationDisposable = cancellationToken.onCancellationRequested(() => {
if (disposed) return
cleanup()
void authProvider.close()
const conn = this.findConnection(name, source)
if (conn && conn.server.status !== "connected") {
conn.server.status = "disconnected"
this.appendErrorMessage(conn, t("mcp:oauth.flow.cancelled"))
void this.notifyWebviewOfServerChanges()
}
resolve()
})
// Register in _oauthWatchers so deleteConnection() and dispose() can clean up
this._oauthWatchers.set(watcherKey, { unsubscribe, abortHandle: timeoutHandle })
// Non-modal toast — fires once. If dismissed without clicking Authenticate,
// the flow stays alive via the persistent progress bar (Cancel to abort).
const authenticateLabel = t("mcp:oauth.flow.authenticateButton")
void (async () => {
const choice = await vscode.window.showInformationMessage(
t("mcp:oauth.flow.clickAuthenticate", { name }),
authenticateLabel,
)
if (disposed) return
if (choice !== authenticateLabel) {
progress.report({ message: t("mcp:oauth.flow.dismissedHint") })
return
}
// Guard: another window may have authed while the toast was open
const tokens = await this.secretStorage!.getOAuthData(serverUrl)
if (disposed) return
if (tokens && Date.now() < tokens.expires_at - TOKEN_EXPIRY_BUFFER_MS) {
cleanup()
await authProvider.close()
await this.deleteConnection(name, source)
const validatedFastPathConfig = this.validateServerConfig(config, name)
await this.connectToServer(name, validatedFastPathConfig, source)
await this.notifyWebviewOfServerChanges()
resolve()
return
}
// Clean up before exchanging tokens — this sets disposed=true so the
// cross-window token watcher doesn't race to reconnect when SecretStorage
// fires onDidChange during token exchange inside _completeOAuthFlow.
cleanup()
try {
await this._completeOAuthFlow(authProvider, transport, connection, name, source, cancellationToken)
} catch {
// _completeOAuthFlow handles its own error state
}
resolve()
})()
})
}
private async _completeOAuthFlow(
authProvider: McpOAuthClientProvider,
transport: StreamableHTTPClientTransport,
connection: ConnectedMcpConnection,
name: string,
source: "global" | "project",
cancellationToken: vscode.CancellationToken,
): Promise<void> {
const config = JSON.parse(connection.server.config)
// Build a promise that rejects when the progress bar Cancel is pressed,
// so we can race it against waitForAuthCode() which blocks indefinitely.
const cancelledError = new Error(t("mcp:oauth.flow.cancelled"))
let cancelListener: vscode.Disposable | undefined
const cancellationPromise = new Promise<never>((_, reject) => {
if (cancellationToken.isCancellationRequested) {
reject(cancelledError)
return
}
cancelListener = cancellationToken.onCancellationRequested(() => reject(cancelledError))
})
try {
// Open the browser now that the user has confirmed the toast.
// redirectToAuthorization() was already called by the SDK (which stored
// the URL in _pendingAuthorizationUrl), but deliberately did not open it.
await authProvider.openBrowser()
const code = await Promise.race([authProvider.waitForAuthCode(), cancellationPromise])
// Exchange auth code for tokens using the pre-fetched token_endpoint
// directly. The SDK's transport.finishAuth() re-runs discovery internally
// and hits the same broken URL for path-prefixed issuers (see
// utils/oauth.ts for upstream issue links).
await authProvider.exchangeCodeForTokens(code)
await authProvider.close()
// Recover the validated server config stored on the connection so we
// can pass it directly to connectToServer without re-reading the file.
const validatedConfig = this.validateServerConfig(config, name)
// Remove the broken connection (closes the old transport/client),
// then reconnect. The new McpOAuthClientProvider will find the token
// in SecretStorage and connect without another OAuth round-trip.
await this.deleteConnection(name, source)
await this.connectToServer(name, validatedConfig, source)
await this.notifyWebviewOfServerChanges()
void vscode.window.showInformationMessage(t("mcp:oauth.flow.connected", { name }))
} catch (error) {
await authProvider.close()
const conn = this.findConnection(name, source)
if (conn) {
conn.server.status = "disconnected"
this.appendErrorMessage(conn, error instanceof Error ? error.message : `${error}`)
}
await this.notifyWebviewOfServerChanges()
} finally {
cancelListener?.dispose()
}
}
private appendErrorMessage(connection: McpConnection, error: string, level: "error" | "warn" | "info" = "error") {
const MAX_ERROR_LENGTH = 1000
const truncatedError =
@ -1072,6 +1487,15 @@ export class McpHub {
}
async deleteConnection(name: string, source?: "global" | "project"): Promise<void> {
// Cancel any active OAuth token watchers for this connection
const watcherKey = `${name}:${source}`
const watcher = this._oauthWatchers.get(watcherKey)
if (watcher) {
watcher.unsubscribe()
clearTimeout(watcher.abortHandle)
this._oauthWatchers.delete(watcherKey)
}
// Clean up file watchers for this server
this.removeFileWatchersForServer(name)
@ -1085,9 +1509,10 @@ export class McpHub {
if (connection.type === "connected") {
await connection.transport.close()
await connection.client.close()
await connection.authProvider?.close()
}
} catch (error) {
console.error(`Failed to close transport for ${name}:`, error)
console.error(`Failed to close transport or auth provider for ${name}:`, error)
}
}
@ -1278,6 +1703,11 @@ export class McpHub {
// Validate the config
const validatedConfig = this.validateServerConfig(parsedConfig, serverName)
// Clear OAuth tokens for streamable-http servers on restart
if (validatedConfig.type === "streamable-http" && this.secretStorage) {
await this.secretStorage.deleteOAuthData(validatedConfig.url)
}
// Try to connect again using validated config
await this.connectToServer(serverName, validatedConfig, connection.server.source || "global")
vscode.window.showInformationMessage(t("mcp:info.server_connected", { serverName }))
@ -1753,19 +2183,77 @@ export class McpHub {
timeout = 60 * 1000
}
return await connection.client.request(
{
method: "tools/call",
params: {
name: toolName,
arguments: toolArguments,
try {
return await connection.client.request(
{
method: "tools/call",
params: {
name: toolName,
arguments: toolArguments,
},
},
},
CallToolResultSchema,
{
timeout,
},
)
CallToolResultSchema,
{
timeout,
},
)
} catch (error) {
if (error instanceof UnauthorizedError && connection.authProvider) {
// Mid-session re-auth triggered by a tool call (401)
connection.server.status = "connecting"
const reauthKey = `${serverName}:${source || connection.server.source || "global"}`
let reauthPromise = this.reauthPromises.get(reauthKey)
if (!reauthPromise) {
reauthPromise = this._initiateOAuthFlow(
serverName,
source || connection.server.source || "global",
JSON.parse(connection.server.config),
connection.authProvider,
connection.transport as StreamableHTTPClientTransport,
connection,
).finally(() => {
this.reauthPromises.delete(reauthKey)
})
this.reauthPromises.set(reauthKey, reauthPromise)
}
await reauthPromise
// After re-auth completes, the connection has been replaced.
// We need to find the new connection and retry the tool call.
const newConnection = this.findConnection(serverName, source)
if (!newConnection || newConnection.type !== "connected") {
throw new Error(`Failed to reconnect to server ${serverName} after OAuth`)
}
try {
return await newConnection.client.request(
{
method: "tools/call",
params: {
name: toolName,
arguments: toolArguments,
},
},
CallToolResultSchema,
{
timeout,
},
)
} catch (retryError) {
if (retryError instanceof UnauthorizedError) {
throw new Error(
`Authentication succeeded but server "${serverName}" still rejected the request. ` +
`This may indicate a token audience mismatch or server-side configuration issue.`,
)
}
throw retryError
}
}
throw error
}
}
/**
@ -1968,6 +2456,15 @@ export class McpHub {
}
this.isProgrammaticUpdate = false
// Cancel all active OAuth token watchers and in-flight reauth promises
for (const { unsubscribe, abortHandle } of this._oauthWatchers.values()) {
unsubscribe()
clearTimeout(abortHandle)
}
this._oauthWatchers.clear()
this.reauthPromises.clear()
this.removeAllFileWatchers()
for (const connection of this.connections) {

View file

@ -0,0 +1,544 @@
import * as http from "http"
import * as vscode from "vscode"
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
import type {
OAuthClientInformation,
OAuthClientInformationFull,
OAuthClientMetadata,
OAuthTokens,
} from "@modelcontextprotocol/sdk/shared/auth.js"
import { TOKEN_EXPIRY_BUFFER_MS } from "./constants"
import { SecretStorageService } from "./SecretStorageService"
import { startCallbackServer, stopCallbackServer } from "./utils/callbackServer"
import { fetchOAuthAuthServerMetadata } from "./utils/oauth"
/**
* Implements the MCP SDK's OAuthClientProvider interface for VS Code.
*
* Responsibilities:
* - Stores/loads OAuth tokens via VS Code SecretStorage
* - Runs a local HTTP callback server to receive the authorization code
* - Opens the browser for the authorization redirect
* - Provides PKCE code verifier round-trip storage
*
* Usage pattern in McpHub:
* 1. `const authProvider = await McpOAuthClientProvider.create(url, secretStorage)`
* 2. Pass `authProvider` to `StreamableHTTPClientTransport({ authProvider })`
* 3. `await client.connect(transport)` may throw `UnauthorizedError`
* 4. On `UnauthorizedError`: `code = await authProvider.waitForAuthCode()`
* 5. `await transport.finishAuth(code)` then retry `client.connect(transport)`
* 6. `await authProvider.close()` when done (success or permanent failure)
*/
export class McpOAuthClientProvider implements OAuthClientProvider {
// ── Static negative cache ────────────────────────────────────────────────
// Remembers servers that returned no OAuth metadata so we can skip the
// discovery probe on subsequent connection attempts (reconnect, restart).
private static _nonOAuthCache = new Map<string, number>() // serverUrl → timestamp
private static NON_OAUTH_TTL_MS = 30 * 60 * 1000 // 30 minutes
static isKnownNonOAuth(serverUrl: string): boolean {
const ts = McpOAuthClientProvider._nonOAuthCache.get(serverUrl)
if (ts === undefined) return false
if (Date.now() - ts > McpOAuthClientProvider.NON_OAUTH_TTL_MS) {
McpOAuthClientProvider._nonOAuthCache.delete(serverUrl)
return false
}
return true
}
static markNonOAuth(serverUrl: string): void {
McpOAuthClientProvider._nonOAuthCache.set(serverUrl, Date.now())
}
static clearNonOAuthCache(serverUrl?: string): void {
if (serverUrl) {
McpOAuthClientProvider._nonOAuthCache.delete(serverUrl)
} else {
McpOAuthClientProvider._nonOAuthCache.clear()
}
}
// ── Instance fields ──────────────────────────────────────────────────────
private _codeVerifier?: string
// Client info is kept in-memory only (not persisted) to avoid stale registrations
// when the redirect URI port changes between sessions.
private _clientInfo?: OAuthClientInformationFull
private _closed = false
private _refreshPromise: Promise<OAuthTokens> | null = null
/** Stored by redirectToAuthorization(); opened on-demand via openBrowser(). */
private _pendingAuthorizationUrl: URL | null = null
/** Deduplicates concurrent _ensureCallbackServer() calls. */
private _ensureServerPromise: Promise<void> | null = null
private constructor(
private readonly _serverUrl: string,
private readonly _secretStorage: SecretStorageService,
private _server: http.Server | null,
private _port: number,
private _authCodePromise: Promise<string> | null,
private _cancelCallbackServer: (() => void) | null,
private readonly _tokenEndpointAuthMethod: string,
private readonly _grantTypes: string[],
private readonly _scopes: string[],
private readonly _state: string,
private readonly _authServerMeta: Record<string, any> | null,
private readonly _resourceIndicator: string | null,
private readonly _clientName: string,
) {}
/**
* Factory discovers OAuth Authorization Server metadata once (RFC 9728 +
* RFC 8414), starts the local callback server, and returns a ready provider.
*
* Discovery and callback-server startup both happen here so that:
* - `redirectUrl` (used by the SDK to build the authorization URL) is
* stable before any connect attempt.
* - The same metadata object is reused for client registration without a
* second network round-trip.
*/
static async create(
serverUrl: string,
secretStorage: SecretStorageService,
serverName?: string,
options?: { skipDiscovery?: boolean },
): Promise<McpOAuthClientProvider> {
let authServerMeta: Record<string, any> | null = null
let resourceIndicator: string | null = null
if (!options?.skipDiscovery) {
// Fetch auth server metadata once. Reused for:
// - selecting token_endpoint_auth_method / grant_types / scopes
// - pre-registering the client (registration_endpoint)
// - RFC 8707 resource indicator (injected into authorization URL)
const discovery = await fetchOAuthAuthServerMetadata(serverUrl)
authServerMeta = discovery?.authServerMeta ?? null
resourceIndicator = discovery?.resourceIndicator ?? null
// Cache the result so subsequent connections can skip the probe.
if (!authServerMeta) {
McpOAuthClientProvider.markNonOAuth(serverUrl)
}
}
// Extract auth-method preferences.
// Only pick methods we actually implement: "none" or "client_secret_post".
const authMethods: string[] = authServerMeta?.token_endpoint_auth_methods_supported ?? []
const tokenEndpointAuthMethod = authMethods.includes("none") ? "none" : "client_secret_post"
const grantTypes: string[] = authServerMeta?.grant_types_supported ?? ["authorization_code", "refresh_token"]
const scopes: string[] = authServerMeta?.scopes_supported ?? []
// Generate a CSRF state token for the OAuth flow.
const state = Array.from(crypto.getRandomValues(new Uint8Array(16)))
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
// We start the callback server lazily in `redirectToAuthorization()` or `waitForAuthCode()`.
// We use a default port (0) initially; it will be updated when the server starts.
return new McpOAuthClientProvider(
serverUrl,
secretStorage,
null,
0,
null,
null,
tokenEndpointAuthMethod,
grantTypes,
scopes,
state,
authServerMeta,
resourceIndicator,
serverName || "Roo Code",
)
}
// ── OAuthClientProvider interface ────────────────────────────────────────
/** Whether this provider was created with OAuth metadata (discovery succeeded). */
get hasMetadata(): boolean {
return this._authServerMeta !== null
}
get redirectUrl(): string {
return `http://localhost:${this._port}/callback`
}
private _ensureCallbackServer(): Promise<void> {
// Guard against concurrent callers (e.g. redirectToAuthorization + registerClientIfNeeded
// called in parallel) both passing the "server not yet started" check and each launching
// their own startCallbackServer(), which would bind two ports and lose one handle.
if (this._server && !this._closed) return Promise.resolve()
if (!this._ensureServerPromise) {
this._ensureServerPromise = this._doStartCallbackServer().finally(() => {
this._ensureServerPromise = null
})
}
return this._ensureServerPromise
}
private async _doStartCallbackServer(): Promise<void> {
this._closed = false
const { server, port, result, cancel } = await startCallbackServer(this._port, this._state)
this._server = server
this._port = port
this._cancelCallbackServer = cancel
this._authCodePromise = result.then((r) => {
if (r.error) throw new Error(`OAuth authorization failed: ${r.error}`)
if (!r.code) throw new Error("No authorization code received in callback")
return r.code
})
}
state(): string {
return this._state
}
get clientMetadata(): OAuthClientMetadata {
return {
client_name: this._clientName,
redirect_uris: [this.redirectUrl],
grant_types: this._grantTypes,
response_types: ["code"],
token_endpoint_auth_method: this._tokenEndpointAuthMethod,
}
}
async clientInformation(): Promise<OAuthClientInformation | undefined> {
return this._clientInfo
}
async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
this._clientInfo = info
}
/**
* Registers this client with the authorization server if a
* `registration_endpoint` is present in the pre-fetched auth server
* metadata. No-ops if already registered or if the server doesn't
* support dynamic client registration.
*
* Called by McpHub before the first `client.connect()` attempt so that
* `clientInformation()` returns a valid client_id and the SDK skips its
* own registration step which fails for issuers with path components
* due to the same metadata discovery bug (see utils/oauth.ts for
* upstream issue links).
*/
async registerClientIfNeeded(): Promise<void> {
if (this._clientInfo) return // already registered
// Check if we have a cached client_id from previous registration
const cachedData = await this._secretStorage.getOAuthData(this._serverUrl)
if (cachedData?.client_info) {
// Use the full DCR response, override redirect_uris with the
// current port (which may have changed between sessions).
this._clientInfo = {
...cachedData.client_info,
redirect_uris: [this.redirectUrl],
}
return
}
if (!this._authServerMeta?.registration_endpoint) return // DCR not supported
// For Dynamic Client Registration, we MUST have a stable redirect URI.
// Ensure the callback server is started so we have a real port.
await this._ensureCallbackServer()
const response = await fetch(this._authServerMeta.registration_endpoint as string, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(this.clientMetadata),
})
if (!response.ok) {
throw new Error(`Dynamic client registration failed: HTTP ${response.status}`)
}
this._clientInfo = (await response.json()) as OAuthClientInformationFull
}
async tokens(): Promise<OAuthTokens | undefined> {
const data = await this._secretStorage.getOAuthData(this._serverUrl)
if (!data) return undefined
// If the access token is still valid (with 5m buffer), return it.
if (Date.now() < data.expires_at - TOKEN_EXPIRY_BUFFER_MS) {
return data.tokens
}
// Access token is expired or near expiry. Try to refresh if we have a refresh token.
if (data.tokens.refresh_token) {
if (this._refreshPromise) {
return this._refreshPromise
}
// Use the client_id stored alongside the tokens — it is the one the
// auth server bound the refresh token to. `this._clientInfo.client_id`
// may differ if a fresh DCR was performed (e.g. after stale token
// cleanup removed the cached data).
const clientIdForRefresh = data.client_info?.client_id ?? this._clientInfo?.client_id
this._refreshPromise = this.refreshAccessToken(data.tokens.refresh_token, clientIdForRefresh).finally(
() => {
this._refreshPromise = null
},
)
try {
return await this._refreshPromise
} catch (error) {
console.error(`Failed to refresh MCP OAuth token for ${this._serverUrl}:`, error)
// Clear stale tokens on refresh failure so we don't keep retrying a dead refresh token
await this._secretStorage.deleteOAuthData(this._serverUrl)
// Fall through to return undefined, which triggers full re-auth
}
}
return undefined
}
async saveTokens(tokens: OAuthTokens, clientIdOverride?: string): Promise<void> {
const expires_at = tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : Date.now() + 3600 * 1000 // default 1 hour when server omits expires_in
const clientInfo =
clientIdOverride && this._clientInfo
? { ...this._clientInfo, client_id: clientIdOverride }
: this._clientInfo
await this._secretStorage.saveOAuthData(this._serverUrl, {
tokens,
expires_at,
...(clientInfo ? { client_info: clientInfo } : {}),
})
}
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
// Ensure the callback server is running so redirectUrl has a real port.
// The server must be started here because the SDK calls this method as
// part of its internal auth flow (before throwing UnauthorizedError back
// to our caller). We do NOT open the browser here — that is deferred to
// openBrowser(), which McpHub calls only after the user confirms the toast.
await this._ensureCallbackServer()
// Workaround for SDK metadata discovery bug (see utils/oauth.ts for issue links).
// The SDK's discoverOAuthMetadata() builds a wrong well-known URL for issuers
// with path components, causing it to fall back to a default "/authorize" path.
// We correct the URL using our pre-fetched metadata:
// 1. Replace the origin+pathname with the real authorization_endpoint.
// 2. Preserve all SDK-generated query params (client_id, code_challenge, etc.)
// 3. Add `scope` when the server advertises scopes but the SDK omitted it.
// 4. Add RFC 8707 `resource` parameter when the protected resource metadata
// advertised a resource indicator.
let correctedUrl = authorizationUrl
if (this._authServerMeta?.authorization_endpoint) {
try {
const fixed = new URL(this._authServerMeta.authorization_endpoint as string)
// Validate the authorization_endpoint origin matches the issuer to prevent
// a compromised metadata document from redirecting users to a phishing page.
const expectedOrigin = this._authServerMeta.issuer
? new URL(this._authServerMeta.issuer as string).origin
: new URL(this._serverUrl).origin
if (fixed.origin !== expectedOrigin) {
// Fall through and use the SDK-supplied URL unchanged
throw new Error(
`authorization_endpoint origin mismatch: expected ${expectedOrigin}, got ${fixed.origin}`,
)
}
// Copy all query params generated by the SDK
authorizationUrl.searchParams.forEach((value, key) => {
fixed.searchParams.set(key, value)
})
// Ensure the scope param is present — the SDK sometimes omits it
if (!fixed.searchParams.has("scope") && this._scopes.length > 0) {
fixed.searchParams.set("scope", this._scopes.join(" "))
}
// RFC 8707: inject the resource indicator so the auth server can
// scope the issued access token to this specific resource server.
if (this._resourceIndicator && !fixed.searchParams.has("resource")) {
fixed.searchParams.set("resource", this._resourceIndicator)
}
correctedUrl = fixed
} catch {
// Fall through and use the original URL if correction fails
}
}
// Store the (possibly corrected) URL; it will be opened by openBrowser()
// once the user confirms the "Authenticate" toast in McpHub.
this._pendingAuthorizationUrl = correctedUrl
}
/**
* Opens the pending OAuth authorization URL in the system browser.
* Must be called after `redirectToAuthorization()` has been invoked by the SDK.
* McpHub calls this only after the user confirms the authentication toast.
*/
async openBrowser(): Promise<void> {
const url = this._pendingAuthorizationUrl
if (!url) {
throw new Error("No pending authorization URL — redirectToAuthorization() was not called")
}
try {
await vscode.env.openExternal(vscode.Uri.parse(url.toString()))
} catch {
void vscode.window.showInformationMessage(`Please open this URL in your browser to authenticate: ${url}`)
}
}
async saveCodeVerifier(codeVerifier: string): Promise<void> {
this._codeVerifier = codeVerifier
}
async codeVerifier(): Promise<string> {
if (!this._codeVerifier) throw new Error("No PKCE code verifier saved")
return this._codeVerifier
}
// ── Extra helpers for McpHub ─────────────────────────────────────────────
/**
* Resolves with the authorization code once the user completes the OAuth
* browser flow and the local callback server receives the redirect.
* Rejects on error or 5-minute timeout.
*/
async waitForAuthCode(): Promise<string> {
if (!this._authCodePromise) {
await this._ensureCallbackServer()
}
return this._authCodePromise!
}
/**
* Exchanges an authorization code for tokens by POSTing directly to the
* `token_endpoint` from our pre-fetched metadata.
*
* This bypasses the SDK's `transport.finishAuth()` which internally re-runs
* `discoverOAuthMetadata()` and hits the same broken URL construction for
* issuers with path components (see utils/oauth.ts for upstream issue links).
*
* After a successful exchange the tokens are persisted via `saveTokens()`
* so the next `client.connect()` call finds them in SecretStorage and
* connects without another OAuth round-trip.
*
* @param authorizationCode The code received in the OAuth callback redirect.
* @throws When the token endpoint is unknown or the exchange request fails.
*/
async exchangeCodeForTokens(authorizationCode: string): Promise<void> {
if (!this._authServerMeta?.token_endpoint) {
throw new Error("No token_endpoint in auth server metadata — cannot exchange code")
}
if (!this._clientInfo) {
throw new Error("No client information — registerClientIfNeeded() must be called first")
}
const codeVerifier = await this.codeVerifier()
// Build the token request body per RFC 6749 §4.1.3 + RFC 7636 §4.5.
const params: Record<string, string> = {
grant_type: "authorization_code",
code: authorizationCode,
redirect_uri: this.redirectUrl,
client_id: this._clientInfo.client_id,
code_verifier: codeVerifier,
}
// RFC 8707: include resource indicator so servers that bind token requests
// to a specific resource can validate the exchange.
if (this._resourceIndicator) {
params.resource = this._resourceIndicator
}
// Include client_secret in the body when the auth method is client_secret_post.
if (this._tokenEndpointAuthMethod === "client_secret_post" && this._clientInfo.client_secret) {
params.client_secret = this._clientInfo.client_secret
}
const response = await fetch(this._authServerMeta.token_endpoint as string, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams(params).toString(),
})
if (!response.ok) {
throw new Error(`Token exchange failed: HTTP ${response.status}`)
}
const tokens = (await response.json()) as OAuthTokens
await this.saveTokens(tokens)
}
/**
* Refreshes the access token using a refresh token.
*
* @param refreshToken The refresh token to use.
* @param clientIdOverride Optional client_id to use instead of `this._clientInfo.client_id`.
* This is used when the stored tokens were issued to a different client_id than the
* current in-memory registration (e.g. after a port change caused a new DCR).
* @returns The new tokens.
*/
async refreshAccessToken(refreshToken: string, clientIdOverride?: string): Promise<OAuthTokens> {
if (!this._authServerMeta?.token_endpoint) {
throw new Error("No token_endpoint in auth server metadata — cannot refresh token")
}
const clientId = clientIdOverride ?? this._clientInfo?.client_id
if (!clientId) {
throw new Error("No client information — registerClientIfNeeded() must be called first")
}
const params: Record<string, string> = {
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: clientId,
}
// RFC 8707: include resource indicator in refresh requests too.
if (this._resourceIndicator) {
params.resource = this._resourceIndicator
}
if (this._tokenEndpointAuthMethod === "client_secret_post" && this._clientInfo?.client_secret) {
params.client_secret = this._clientInfo.client_secret
}
const response = await fetch(this._authServerMeta.token_endpoint as string, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams(params).toString(),
})
if (!response.ok) {
const errorBody = await response.text().catch(() => "")
throw new Error(`Token refresh failed: HTTP ${response.status} ${errorBody}`)
}
const tokens = (await response.json()) as OAuthTokens
await this.saveTokens(tokens, clientId)
return tokens
}
/** Close the local callback server. Always call this when done. */
async close(): Promise<void> {
// If a server startup is in flight, wait for it to finish so we don't
// close before _server is set (which would leave a dangling server).
if (this._ensureServerPromise) {
await this._ensureServerPromise.catch(() => {})
}
if (!this._closed && this._server) {
this._closed = true
await stopCallbackServer(this._server, this._cancelCallbackServer ?? (() => {})).catch(() => {})
this._server = null
this._cancelCallbackServer = null
this._authCodePromise = null
}
}
}

View file

@ -1,5 +1,6 @@
import * as vscode from "vscode"
import { McpHub } from "./McpHub"
import { SecretStorageService } from "./SecretStorageService"
import { ClineProvider } from "../../core/webview/ClineProvider"
/**
@ -36,7 +37,8 @@ export class McpServerManager {
try {
// Double-check instance in case it was created while we were waiting
if (!this.instance) {
const hub = new McpHub(provider)
const secretStorage = new SecretStorageService(context)
const hub = new McpHub(provider, secretStorage)
// Wait for all MCP servers to finish connecting (or timing out)
await hub.waitUntilReady()
this.instance = hub

View file

@ -0,0 +1,77 @@
import * as vscode from "vscode"
import type { OAuthClientInformationFull, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"
export interface StoredMcpOAuthData {
tokens: OAuthTokens
/** Unix ms timestamp after which the access token should be considered expired. */
expires_at: number
/**
* Full DCR response from the auth server, persisted so that fields like
* client_secret, grant_types, and token_endpoint_auth_method survive restarts.
* Note: redirect_uris within this object may be stale (port changes between
* sessions); callers must override redirect_uris with the current value.
*/
client_info?: OAuthClientInformationFull
}
/**
* Thin wrapper around VS Code SecretStorage for persisting MCP OAuth tokens.
* Tokens are stored per-server (keyed by host) so different servers on the
* same host share credentials, which is the common case for multi-path APIs.
*/
export class SecretStorageService {
private readonly _storage: vscode.SecretStorage
private readonly _namespace = "mcp.oauth."
constructor(context: vscode.ExtensionContext) {
this._storage = context.secrets
}
private _key(serverUrl: string): string {
const url = new URL(serverUrl)
const normalizedPath = url.pathname.replace(/\/$/, "")
// Use base64url encoding to avoid collisions between paths like /a-b, /a_b, /a/b.
const pathSuffix = normalizedPath ? `.${Buffer.from(normalizedPath).toString("base64url")}` : ""
return `${this._namespace}${url.host}${pathSuffix}.data`
}
async getOAuthData(serverUrl: string): Promise<StoredMcpOAuthData | undefined> {
const raw = await this._storage.get(this._key(serverUrl))
if (!raw) return undefined
try {
return JSON.parse(raw) as StoredMcpOAuthData
} catch {
return undefined
}
}
async saveOAuthData(serverUrl: string, data: StoredMcpOAuthData): Promise<void> {
await this._storage.store(this._key(serverUrl), JSON.stringify(data))
}
async hasOAuthData(serverUrl: string): Promise<boolean> {
const raw = await this._storage.get(this._key(serverUrl))
return raw !== undefined
}
async deleteOAuthData(serverUrl: string): Promise<void> {
await this._storage.delete(this._key(serverUrl))
}
/**
* Subscribe to changes for a specific server URL's OAuth data.
* The callback fires (in all VS Code windows) immediately when another
* window writes or deletes the token for this server.
*
* @returns A dispose function call it to stop listening.
*/
onDidChange(serverUrl: string, callback: () => void): () => void {
const key = this._key(serverUrl)
const disposable = this._storage.onDidChange((e) => {
if (e.key === key) {
callback()
}
})
return () => disposable.dispose()
}
}

View file

@ -7,6 +7,8 @@ import type { ClineProvider } from "../../../core/webview/ClineProvider"
import type { McpHub as McpHubType, McpConnection, ConnectedMcpConnection, DisconnectedMcpConnection } from "../McpHub"
import { ServerConfigSchema, McpHub } from "../McpHub"
import { OAUTH_FLOW_TIMEOUT_MS } from "../constants"
import { t } from "../../../i18n"
// Mock fs/promises before importing anything that uses it
vi.mock("fs/promises", () => ({
@ -49,6 +51,8 @@ vi.mock("../../../utils/safeWriteJson", () => ({
}),
}))
vi.mock("delay", () => ({ default: vi.fn().mockResolvedValue(undefined) }))
vi.mock("vscode", () => ({
workspace: {
createFileSystemWatcher: vi.fn().mockReturnValue({
@ -68,6 +72,22 @@ vi.mock("vscode", () => ({
createTextEditorDecorationType: vi.fn().mockReturnValue({
dispose: vi.fn(),
}),
withProgress: vi.fn().mockImplementation((_options: any, task: any) => {
const progress = { report: vi.fn() }
const tokenListeners: Array<() => void> = []
const cancellationToken = {
isCancellationRequested: false,
onCancellationRequested: vi.fn((cb: () => void) => {
tokenListeners.push(cb)
return { dispose: vi.fn() }
}),
_fire: () => tokenListeners.forEach((cb) => cb()),
}
return task(progress, cancellationToken)
}),
},
ProgressLocation: {
Notification: 15,
},
Disposable: {
from: vi.fn(),
@ -2368,4 +2388,461 @@ describe("McpHub", () => {
)
})
})
describe("_initiateOAuthFlow with persistent notification", () => {
const serverName = "oauth-server"
const serverUrl = "https://example.com/mcp"
const source = "global" as const
const config = { url: serverUrl }
let mockAuthProvider: any
let mockTransport: any
let mockConnection: any
let mockSecretStorage: any
let vsc: any
beforeEach(async () => {
vi.clearAllMocks()
vsc = await import("vscode")
mockAuthProvider = {
openBrowser: vi.fn().mockResolvedValue(undefined),
waitForAuthCode: vi.fn().mockResolvedValue("auth-code-123"),
exchangeCodeForTokens: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
}
mockTransport = {}
mockConnection = {
server: {
status: "connecting",
config: JSON.stringify(config),
name: serverName,
},
}
mockSecretStorage = {
getOAuthData: vi.fn().mockResolvedValue(null),
onDidChange: vi.fn().mockReturnValue(vi.fn()),
}
;(mcpHub as any).secretStorage = mockSecretStorage
vi.spyOn(mcpHub as any, "deleteConnection").mockResolvedValue(undefined)
vi.spyOn(mcpHub as any, "connectToServer").mockResolvedValue(undefined)
vi.spyOn(mcpHub as any, "notifyWebviewOfServerChanges").mockResolvedValue(undefined)
vi.spyOn(mcpHub as any, "findConnection").mockReturnValue(mockConnection)
vi.spyOn(mcpHub as any, "validateServerConfig").mockReturnValue(config)
vi.spyOn(mcpHub as any, "appendErrorMessage").mockReturnValue(undefined)
})
afterEach(() => {
vi.useRealTimers()
})
it("should use withProgress for persistent notification", async () => {
vsc.window.showInformationMessage.mockResolvedValueOnce(t("mcp:oauth.flow.authenticateButton") as any)
vi.spyOn(mcpHub as any, "_completeOAuthFlow").mockResolvedValue(undefined)
await (mcpHub as any)._initiateOAuthFlow(
serverName,
source,
config,
mockAuthProvider,
mockTransport,
mockConnection,
)
expect(vsc.window.withProgress).toHaveBeenCalledWith(
expect.objectContaining({
location: vsc.ProgressLocation.Notification,
cancellable: true,
}),
expect.any(Function),
)
})
it("should update progress bar hint when toast is dismissed without clicking Authenticate", async () => {
let capturedProgress: any
vsc.window.withProgress.mockImplementationOnce((_options: any, task: any) => {
capturedProgress = { report: vi.fn() }
const cancellationToken = {
isCancellationRequested: false,
onCancellationRequested: vi.fn(() => ({ dispose: vi.fn() })),
_fire: vi.fn(),
}
return task(capturedProgress, cancellationToken)
})
vsc.window.showInformationMessage.mockResolvedValueOnce(undefined as any)
vi.useFakeTimers()
const flowPromise = (mcpHub as any)._initiateOAuthFlow(
serverName,
source,
config,
mockAuthProvider,
mockTransport,
mockConnection,
)
await vi.advanceTimersByTimeAsync(OAUTH_FLOW_TIMEOUT_MS)
await flowPromise
expect(vsc.window.showInformationMessage).toHaveBeenCalledTimes(1)
expect(capturedProgress.report).toHaveBeenCalledWith({
message: t("mcp:oauth.flow.dismissedHint"),
})
})
it("should resolve when cross-window tokens arrive", async () => {
vsc.window.showInformationMessage.mockImplementation(() => new Promise(() => {}))
mockSecretStorage.onDidChange.mockImplementation((_key: string, cb: () => void) => {
Promise.resolve().then(() => {
mockSecretStorage.getOAuthData.mockResolvedValue({
expires_at: Date.now() + 10 * 60 * 1000,
})
cb()
})
return vi.fn()
})
await (mcpHub as any)._initiateOAuthFlow(
serverName,
source,
config,
mockAuthProvider,
mockTransport,
mockConnection,
)
expect(mockAuthProvider.close).toHaveBeenCalled()
expect((mcpHub as any).deleteConnection).toHaveBeenCalledWith(serverName, source)
expect((mcpHub as any).connectToServer).toHaveBeenCalled()
})
it("should skip flow when valid tokens already exist", async () => {
mockSecretStorage.getOAuthData.mockResolvedValue({
expires_at: Date.now() + 10 * 60 * 1000,
})
await (mcpHub as any)._initiateOAuthFlow(
serverName,
source,
config,
mockAuthProvider,
mockTransport,
mockConnection,
)
expect(vsc.window.withProgress).not.toHaveBeenCalled()
expect(mockAuthProvider.close).toHaveBeenCalled()
expect((mcpHub as any).deleteConnection).toHaveBeenCalledWith(serverName, source)
expect((mcpHub as any).connectToServer).toHaveBeenCalled()
})
it("should disconnect and flag error when user cancels the OAuth flow", async () => {
// Override withProgress for this test to capture the cancellation token
let capturedCancellationToken: any
vsc.window.withProgress.mockImplementationOnce((_options: any, task: any) => {
const progress = { report: vi.fn() }
const tokenListeners: Array<() => void> = []
capturedCancellationToken = {
isCancellationRequested: false,
onCancellationRequested: vi.fn((cb: () => void) => {
tokenListeners.push(cb)
return { dispose: vi.fn() }
}),
_fire: () => tokenListeners.forEach((cb) => cb()),
}
return task(progress, capturedCancellationToken)
})
const flowPromise = (mcpHub as any)._initiateOAuthFlow(
serverName,
source,
config,
mockAuthProvider,
mockTransport,
mockConnection,
)
// _initiateOAuthFlow awaits getOAuthData() before calling withProgress.
// Two ticks: tick 1 resolves getOAuthData, tick 2 runs the continuation
// that calls withProgress, setting capturedCancellationToken.
await Promise.resolve()
await Promise.resolve()
capturedCancellationToken._fire()
await flowPromise
expect(mockConnection.server.status).toBe("disconnected")
expect((mcpHub as any).appendErrorMessage).toHaveBeenCalled()
expect(mockAuthProvider.close).toHaveBeenCalled()
})
it("should disconnect and flag error when OAuth flow times out", async () => {
vi.useFakeTimers()
vsc.window.showInformationMessage.mockImplementation(() => new Promise(() => {}))
const flowPromise = (mcpHub as any)._initiateOAuthFlow(
serverName,
source,
config,
mockAuthProvider,
mockTransport,
mockConnection,
)
await vi.advanceTimersByTimeAsync(OAUTH_FLOW_TIMEOUT_MS)
await flowPromise
expect(mockConnection.server.status).toBe("disconnected")
expect((mcpHub as any).appendErrorMessage).toHaveBeenCalled()
expect(mockAuthProvider.close).toHaveBeenCalled()
})
it("should resolve without calling _completeOAuthFlow when tokens exist at click time", async () => {
// Tokens are present when Authenticate is clicked (click-time guard in the loop).
// First call (pre-withProgress early-return check) returns null so withProgress runs.
// Second call (after click) returns valid tokens, exercising the click-time guard.
vsc.window.showInformationMessage.mockResolvedValueOnce(t("mcp:oauth.flow.authenticateButton") as any)
mockSecretStorage.getOAuthData
.mockResolvedValueOnce(null) // pre-check: no tokens yet, flow proceeds to withProgress
.mockResolvedValue({ expires_at: Date.now() + 10 * 60 * 1000 }) // at click time
const completeOAuthSpy = vi.spyOn(mcpHub as any, "_completeOAuthFlow")
await (mcpHub as any)._initiateOAuthFlow(
serverName,
source,
config,
mockAuthProvider,
mockTransport,
mockConnection,
)
expect(completeOAuthSpy).not.toHaveBeenCalled()
expect(mockAuthProvider.close).toHaveBeenCalled()
expect((mcpHub as any).deleteConnection).toHaveBeenCalledWith(serverName, source)
expect((mcpHub as any).connectToServer).toHaveBeenCalled()
})
it("should resolve cleanly even when _completeOAuthFlow throws", async () => {
vsc.window.showInformationMessage.mockResolvedValueOnce(t("mcp:oauth.flow.authenticateButton") as any)
vi.spyOn(mcpHub as any, "_completeOAuthFlow").mockRejectedValue(new Error("network failure"))
await expect(
(mcpHub as any)._initiateOAuthFlow(
serverName,
source,
config,
mockAuthProvider,
mockTransport,
mockConnection,
),
).resolves.toBeUndefined()
})
it("should disconnect and flag error when Cancel is pressed after Authenticate is clicked", async () => {
// Simulate: user clicks Authenticate in the toast, then cancels via the progress bar
// while waitForAuthCode is still pending.
let capturedCancellationToken: any
vsc.window.withProgress.mockImplementationOnce((_options: any, task: any) => {
const progress = { report: vi.fn() }
const tokenListeners: Array<() => void> = []
capturedCancellationToken = {
isCancellationRequested: false,
onCancellationRequested: vi.fn((cb: () => void) => {
tokenListeners.push(cb)
return { dispose: vi.fn() }
}),
_fire: () => {
capturedCancellationToken.isCancellationRequested = true
tokenListeners.forEach((cb) => cb())
},
}
return task(progress, capturedCancellationToken)
})
// Toast resolves immediately with Authenticate clicked
vsc.window.showInformationMessage.mockResolvedValueOnce(t("mcp:oauth.flow.authenticateButton") as any)
mockSecretStorage.getOAuthData.mockResolvedValue(null) // no pre-existing tokens
// waitForAuthCode never resolves — simulates browser waiting for the callback
mockAuthProvider.waitForAuthCode.mockReturnValue(new Promise(() => {}))
const flowPromise = (mcpHub as any)._initiateOAuthFlow(
serverName,
source,
config,
mockAuthProvider,
mockTransport,
mockConnection,
)
// Let the flow advance: getOAuthData resolves, withProgress runs,
// showInformationMessage resolves (Authenticate clicked), getOAuthData
// re-checked (null), cleanup() called, _completeOAuthFlow entered,
// openBrowser() called, then waitForAuthCode blocks.
await new Promise((r) => setTimeout(r, 0))
// Fire Cancel on the progress bar while waitForAuthCode is blocked
capturedCancellationToken._fire()
await flowPromise
expect(mockConnection.server.status).toBe("disconnected")
expect((mcpHub as any).appendErrorMessage).toHaveBeenCalledWith(
mockConnection,
t("mcp:oauth.flow.cancelled"),
)
expect(mockAuthProvider.close).toHaveBeenCalled()
})
it("should not reconnect when hub is disposed while cross-window tokens arrive", async () => {
vi.useFakeTimers()
vsc.window.showInformationMessage.mockImplementation(() => new Promise(() => {}))
mockSecretStorage.onDidChange.mockImplementation((_key: string, cb: () => void) => {
Promise.resolve().then(() => {
// Dispose the hub before the token callback runs
;(mcpHub as any).isDisposed = true
mockSecretStorage.getOAuthData.mockResolvedValue({
expires_at: Date.now() + 10 * 60 * 1000,
})
cb()
})
return vi.fn()
})
const flowPromise = (mcpHub as any)._initiateOAuthFlow(
serverName,
source,
config,
mockAuthProvider,
mockTransport,
mockConnection,
)
// Timeout to unblock the flow (watcher bailed due to isDisposed)
await vi.advanceTimersByTimeAsync(OAUTH_FLOW_TIMEOUT_MS)
await flowPromise
expect((mcpHub as any).connectToServer).not.toHaveBeenCalled()
})
it("should not reconnect when cross-window watcher fires but token is missing or expired", async () => {
vi.useFakeTimers()
vsc.window.showInformationMessage.mockImplementation(() => new Promise(() => {}))
// onDidChange fires immediately (simulates a storage event during the read gap)
// but getOAuthData returns undefined — no valid token written.
mockSecretStorage.onDidChange.mockImplementation((_key: string, cb: () => void) => {
cb() // fires synchronously — sets crossWindowTokenWritten flag
return vi.fn()
})
mockSecretStorage.getOAuthData.mockResolvedValue(undefined)
const flowPromise = (mcpHub as any)._initiateOAuthFlow(
serverName,
source,
config,
mockAuthProvider,
mockTransport,
mockConnection,
)
// Advance past the timeout so the flow can settle
await vi.advanceTimersByTimeAsync(OAUTH_FLOW_TIMEOUT_MS)
await flowPromise
// Should NOT have tried to reconnect — the watcher fired but no valid token exists
expect((mcpHub as any).connectToServer).not.toHaveBeenCalledWith(serverName, expect.anything(), source)
})
it("should reconnect when cross-window token is written while getOAuthData is in-flight", async () => {
// This covers the race: onDidChange fires BEFORE getOAuthData resolves.
// The flag causes a second read which finds the now-valid token.
let resolveGetOAuthData!: (value: any) => void
mockSecretStorage.getOAuthData
// First call (during the race window) — delayed, returns undefined
.mockImplementationOnce(
() =>
new Promise((r) => {
resolveGetOAuthData = r
}),
)
// Second call (after watcher fires) — valid token available
.mockResolvedValueOnce({ expires_at: Date.now() + 10 * 60 * 1000 })
// Watcher fires synchronously before getOAuthData resolves
mockSecretStorage.onDidChange.mockImplementation((_key: string, cb: () => void) => {
cb()
return vi.fn()
})
const flowPromise = (mcpHub as any)._initiateOAuthFlow(
serverName,
source,
config,
mockAuthProvider,
mockTransport,
mockConnection,
)
// Now let the first getOAuthData resolve with undefined
resolveGetOAuthData(undefined)
await flowPromise
expect(mockAuthProvider.close).toHaveBeenCalled()
expect((mcpHub as any).deleteConnection).toHaveBeenCalledWith(serverName, source)
expect((mcpHub as any).connectToServer).toHaveBeenCalled()
expect(vsc.window.withProgress).not.toHaveBeenCalled()
})
it("should cancel the previous watcher when called again for the same server", async () => {
vi.useFakeTimers()
vsc.window.showInformationMessage.mockImplementation(() => new Promise(() => {}))
// Start first flow (intentionally not awaited — the second call orphans it)
;(mcpHub as any)._initiateOAuthFlow(
serverName,
source,
config,
mockAuthProvider,
mockTransport,
mockConnection,
)
// Two ticks: getOAuthData resolves, then withProgress registers the watcher
await Promise.resolve()
await Promise.resolve()
expect((mcpHub as any)._oauthWatchers.size).toBe(1)
const firstEntry = (mcpHub as any)._oauthWatchers.get(`${serverName}:${source}`)
// Start second flow for the same server — should tear down the first watcher
const secondFlow = (mcpHub as any)._initiateOAuthFlow(
serverName,
source,
config,
mockAuthProvider,
mockTransport,
mockConnection,
)
await Promise.resolve()
await Promise.resolve()
// Still exactly one watcher for this server key
expect((mcpHub as any)._oauthWatchers.size).toBe(1)
// Watcher entry was replaced (second flow's entry, not first)
const secondEntry = (mcpHub as any)._oauthWatchers.get(`${serverName}:${source}`)
expect(secondEntry).not.toBe(firstEntry)
// Advance past timeout so the second flow resolves
await vi.advanceTimersByTimeAsync(OAUTH_FLOW_TIMEOUT_MS)
await secondFlow
})
})
})

View file

@ -0,0 +1,852 @@
import { describe, it, expect, vi, beforeEach, afterAll } from "vitest"
// Mock vscode
vi.mock("vscode", () => ({
window: {
showInformationMessage: vi.fn(),
},
env: {
openExternal: vi.fn().mockResolvedValue(true),
},
Uri: {
parse: vi.fn((url: string) => ({ toString: () => url })),
},
}))
// Mock callbackServer
vi.mock("../utils/callbackServer", () => ({
startCallbackServer: vi.fn(),
stopCallbackServer: vi.fn().mockResolvedValue(undefined),
}))
// Mock fetch for auth discovery so tests don't make real network calls
const mockFetch = vi.fn()
const originalFetch = global.fetch
global.fetch = mockFetch
// Mock SDK auth discovery functions
vi.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
discoverOAuthProtectedResourceMetadata: vi.fn().mockResolvedValue({
resource: "https://example.com/",
authorization_servers: ["https://auth.example.com"],
}),
}))
// Set up fetch mock to return auth metadata with "none" auth method
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
issuer: "https://auth.example.com",
authorization_endpoint: "https://auth.example.com/authorize",
token_endpoint: "https://auth.example.com/token",
registration_endpoint: "https://auth.example.com/register",
response_types_supported: ["code"],
token_endpoint_auth_methods_supported: ["none"],
grant_types_supported: ["authorization_code", "refresh_token"],
}),
})
import { McpOAuthClientProvider } from "../McpOAuthClientProvider"
import { SecretStorageService } from "../SecretStorageService"
import { startCallbackServer, stopCallbackServer } from "../utils/callbackServer"
import { discoverOAuthProtectedResourceMetadata } from "@modelcontextprotocol/sdk/client/auth.js"
import * as vscode from "vscode"
function createMockSecretStorage(): SecretStorageService {
const store = new Map<string, string>()
return {
getOAuthData: vi.fn(async (url: string) => {
const raw = store.get(url)
return raw ? JSON.parse(raw) : undefined
}),
saveOAuthData: vi.fn(async (url: string, data: any) => {
store.set(url, JSON.stringify(data))
}),
deleteOAuthData: vi.fn(async (url: string) => {
store.delete(url)
}),
} as unknown as SecretStorageService
}
function setupCallbackServerMock(code = "test-auth-code", state?: string) {
const mockServer = { close: vi.fn((cb: () => void) => cb()) }
const resultPromise = Promise.resolve({ code, state })
;(startCallbackServer as any).mockResolvedValue({
server: mockServer,
port: 12345,
result: resultPromise,
cancel: vi.fn(),
})
return { mockServer, resultPromise }
}
describe("McpOAuthClientProvider", () => {
beforeEach(() => {
vi.clearAllMocks()
McpOAuthClientProvider.clearNonOAuthCache()
})
afterAll(() => {
global.fetch = originalFetch
})
describe("static negative cache", () => {
it("isKnownNonOAuth returns false for unknown servers", () => {
expect(McpOAuthClientProvider.isKnownNonOAuth("https://unknown.com/mcp")).toBe(false)
})
it("markNonOAuth makes isKnownNonOAuth return true", () => {
McpOAuthClientProvider.markNonOAuth("https://example.com/mcp")
expect(McpOAuthClientProvider.isKnownNonOAuth("https://example.com/mcp")).toBe(true)
})
it("clearNonOAuthCache(url) clears a specific entry", () => {
McpOAuthClientProvider.markNonOAuth("https://a.com/mcp")
McpOAuthClientProvider.markNonOAuth("https://b.com/mcp")
McpOAuthClientProvider.clearNonOAuthCache("https://a.com/mcp")
expect(McpOAuthClientProvider.isKnownNonOAuth("https://a.com/mcp")).toBe(false)
expect(McpOAuthClientProvider.isKnownNonOAuth("https://b.com/mcp")).toBe(true)
})
it("clearNonOAuthCache() with no arg clears all entries", () => {
McpOAuthClientProvider.markNonOAuth("https://a.com/mcp")
McpOAuthClientProvider.markNonOAuth("https://b.com/mcp")
McpOAuthClientProvider.clearNonOAuthCache()
expect(McpOAuthClientProvider.isKnownNonOAuth("https://a.com/mcp")).toBe(false)
expect(McpOAuthClientProvider.isKnownNonOAuth("https://b.com/mcp")).toBe(false)
})
it("entries expire after the TTL", () => {
const realNow = Date.now()
McpOAuthClientProvider.markNonOAuth("https://example.com/mcp")
// Advance time past the 30-minute TTL
const spy = vi.spyOn(Date, "now").mockReturnValue(realNow + 31 * 60 * 1000)
expect(McpOAuthClientProvider.isKnownNonOAuth("https://example.com/mcp")).toBe(false)
spy.mockRestore()
})
})
describe("create", () => {
it("should return a provider without starting a callback server", async () => {
setupCallbackServerMock()
const secretStorage = createMockSecretStorage()
const provider = await McpOAuthClientProvider.create("https://example.com/mcp", secretStorage)
expect(startCallbackServer).not.toHaveBeenCalled()
expect(provider.redirectUrl).toBe("http://localhost:0/callback")
await provider.close()
})
it("should skip discovery when skipDiscovery option is true", async () => {
const secretStorage = createMockSecretStorage()
const provider = await McpOAuthClientProvider.create("https://example.com/mcp", secretStorage, undefined, {
skipDiscovery: true,
})
expect(discoverOAuthProtectedResourceMetadata).not.toHaveBeenCalled()
expect(provider.hasMetadata).toBe(false)
})
it("should have hasMetadata true when discovery succeeds", async () => {
const secretStorage = createMockSecretStorage()
const provider = await McpOAuthClientProvider.create("https://example.com/mcp", secretStorage)
expect(discoverOAuthProtectedResourceMetadata).toHaveBeenCalled()
expect(provider.hasMetadata).toBe(true)
})
it("should cache server as non-OAuth when discovery fails", async () => {
// Use mockImplementationOnce to override just this call
;(discoverOAuthProtectedResourceMetadata as any).mockImplementationOnce(() => {
throw new Error("not found")
})
const secretStorage = createMockSecretStorage()
const provider = await McpOAuthClientProvider.create("https://no-oauth.example.com/mcp", secretStorage)
expect(provider.hasMetadata).toBe(false)
expect(McpOAuthClientProvider.isKnownNonOAuth("https://no-oauth.example.com/mcp")).toBe(true)
})
})
describe("redirectUrl (pre-server-start)", () => {
it("should return localhost:0 before the callback server is started (intentional lazy-init behaviour)", async () => {
const secretStorage = createMockSecretStorage()
const provider = await McpOAuthClientProvider.create("https://example.com/mcp", secretStorage)
// Port 0 is intentional: the server starts lazily in _ensureCallbackServer().
// DCR and authorization flows always call _ensureCallbackServer() first to obtain
// a real port before using redirectUrl, so port 0 is never sent to an OAuth server.
expect(provider.redirectUrl).toBe("http://localhost:0/callback")
})
})
describe("clientMetadata", () => {
it("should return correct metadata with redirect URI", async () => {
setupCallbackServerMock()
const provider = await McpOAuthClientProvider.create("https://example.com/mcp", createMockSecretStorage())
const metadata = provider.clientMetadata
expect(metadata.client_name).toBe("Roo Code")
expect(metadata.redirect_uris).toEqual(["http://localhost:0/callback"])
expect(metadata.grant_types).toContain("authorization_code")
expect(metadata.response_types).toContain("code")
expect(metadata.token_endpoint_auth_method).toBe("none")
await provider.close()
})
it("should use server name as client_name when provided", async () => {
setupCallbackServerMock()
const provider = await McpOAuthClientProvider.create(
"https://example.com/mcp",
createMockSecretStorage(),
"figma",
)
expect(provider.clientMetadata.client_name).toBe("figma")
await provider.close()
})
})
describe("clientInformation / saveClientInformation", () => {
it("should return undefined initially", async () => {
setupCallbackServerMock()
const provider = await McpOAuthClientProvider.create("https://example.com/mcp", createMockSecretStorage())
expect(await provider.clientInformation()).toBeUndefined()
await provider.close()
})
it("should return saved client info", async () => {
setupCallbackServerMock()
const provider = await McpOAuthClientProvider.create("https://example.com/mcp", createMockSecretStorage())
const info = {
client_id: "test-id",
client_secret: "test-secret",
redirect_uris: ["http://localhost:12345/callback"],
}
await provider.saveClientInformation(info as any)
const result = await provider.clientInformation()
expect(result).toEqual(info)
await provider.close()
})
})
describe("tokens / saveTokens", () => {
it("should return undefined when no tokens stored", async () => {
setupCallbackServerMock()
const secretStorage = createMockSecretStorage()
const provider = await McpOAuthClientProvider.create("https://example.com/mcp", secretStorage)
expect(await provider.tokens()).toBeUndefined()
await provider.close()
})
it("should store and return tokens", async () => {
setupCallbackServerMock()
const secretStorage = createMockSecretStorage()
const provider = await McpOAuthClientProvider.create("https://example.com/mcp", secretStorage)
const tokens = {
access_token: "test-token",
token_type: "Bearer",
expires_in: 3600,
}
await provider.saveTokens(tokens)
const result = await provider.tokens()
expect(result).toEqual(tokens)
await provider.close()
})
it("should refresh tokens when access token is expired but refresh token exists", async () => {
setupCallbackServerMock()
const secretStorage = createMockSecretStorage()
const provider = await McpOAuthClientProvider.create("https://example.com/mcp", secretStorage)
const initialTokens = {
access_token: "expired-access",
refresh_token: "valid-refresh",
token_type: "Bearer",
}
const refreshedTokens = {
access_token: "new-access",
refresh_token: "new-refresh",
token_type: "Bearer",
expires_in: 3600,
}
await provider.saveClientInformation({ client_id: "id", redirect_uris: [] } as any)
await secretStorage.saveOAuthData("https://example.com/mcp", {
tokens: initialTokens,
expires_at: Date.now() - 1000,
})
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(refreshedTokens),
})
const result = await provider.tokens()
expect(result).toEqual(refreshedTokens)
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/token"),
expect.objectContaining({
body: expect.stringContaining("grant_type=refresh_token"),
}),
)
await provider.close()
})
it("should return undefined for expired tokens without refresh token", async () => {
setupCallbackServerMock()
const secretStorage = createMockSecretStorage()
const provider = await McpOAuthClientProvider.create("https://example.com/mcp", secretStorage)
// Directly store data with an expires_at in the past so tokens() returns undefined
await secretStorage.saveOAuthData("https://example.com/mcp", {
tokens: { access_token: "expired", token_type: "Bearer" },
expires_at: Date.now() - 1000, // already expired
})
expect(await provider.tokens()).toBeUndefined()
await provider.close()
})
})
describe("codeVerifier / saveCodeVerifier", () => {
it("should throw if no verifier saved", async () => {
setupCallbackServerMock()
const provider = await McpOAuthClientProvider.create("https://example.com/mcp", createMockSecretStorage())
await expect(provider.codeVerifier()).rejects.toThrow("No PKCE code verifier saved")
await provider.close()
})
it("should round-trip code verifier", async () => {
setupCallbackServerMock()
const provider = await McpOAuthClientProvider.create("https://example.com/mcp", createMockSecretStorage())
await provider.saveCodeVerifier("test-verifier-123")
expect(await provider.codeVerifier()).toBe("test-verifier-123")
await provider.close()
})
})
describe("redirectToAuthorization", () => {
it("should store the authorization URL without opening the browser", async () => {
setupCallbackServerMock()
const provider = await McpOAuthClientProvider.create("https://example.com/mcp", createMockSecretStorage())
const authUrl = new URL("https://auth.example.com/authorize?client_id=test")
await provider.redirectToAuthorization(authUrl)
// Browser must NOT have been opened yet — it is deferred to openBrowser()
expect(vscode.env.openExternal).not.toHaveBeenCalled()
await provider.close()
})
})
describe("openBrowser", () => {
it("should open the pending authorization URL in the browser", async () => {
setupCallbackServerMock()
const provider = await McpOAuthClientProvider.create("https://example.com/mcp", createMockSecretStorage())
const authUrl = new URL("https://auth.example.com/authorize?client_id=test")
await provider.redirectToAuthorization(authUrl)
await provider.openBrowser()
expect(vscode.env.openExternal).toHaveBeenCalled()
await provider.close()
})
it("should show URL as fallback if browser open fails", async () => {
setupCallbackServerMock()
;(vscode.env.openExternal as any).mockRejectedValueOnce(new Error("no browser"))
const provider = await McpOAuthClientProvider.create("https://example.com/mcp", createMockSecretStorage())
const authUrl = new URL("https://auth.example.com/authorize?client_id=test")
await provider.redirectToAuthorization(authUrl)
await provider.openBrowser()
expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(
expect.stringContaining("Please open this URL"),
)
await provider.close()
})
it("should throw if called before redirectToAuthorization", async () => {
setupCallbackServerMock()
const provider = await McpOAuthClientProvider.create("https://example.com/mcp", createMockSecretStorage())
await expect(provider.openBrowser()).rejects.toThrow("No pending authorization URL")
await provider.close()
})
it("should correct a wrong authorization URL using pre-fetched metadata", async () => {
// Mock discovery to return an issuer with a path component.
// The SDK's discoverOAuthMetadata builds the wrong URL for such issuers,
// so it typically falls back to a bare /authorize path.
;(discoverOAuthProtectedResourceMetadata as any).mockResolvedValueOnce({
resource: "https://mcp.kapa.ai/",
authorization_servers: ["https://mcp.kapa.ai/auth/public"],
})
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
issuer: "https://mcp.kapa.ai/auth/public",
authorization_endpoint: "https://mcp.kapa.ai/auth/public/authorize",
token_endpoint: "https://mcp.kapa.ai/auth/public/token",
registration_endpoint: "https://mcp.kapa.ai/auth/public/register",
token_endpoint_auth_methods_supported: ["client_secret_post"],
grant_types_supported: ["authorization_code", "refresh_token"],
scopes_supported: ["openid"],
}),
})
setupCallbackServerMock()
const provider = await McpOAuthClientProvider.create("https://mcp.kapa.ai/mcp", createMockSecretStorage())
// Simulate the SDK building the wrong base URL (using bare /authorize) and omitting scope
const sdkWrongUrl = new URL("https://mcp.kapa.ai/authorize?client_id=abc&code_challenge=xyz&state=123")
await provider.redirectToAuthorization(sdkWrongUrl)
await provider.openBrowser()
// The provider should have corrected the URL to use the real authorization_endpoint
const openedUri = (vscode.env.openExternal as any).mock.calls[0][0].toString()
expect(openedUri).toContain("https://mcp.kapa.ai/auth/public/authorize")
expect(openedUri).toContain("client_id=abc")
expect(openedUri).toContain("code_challenge=xyz")
expect(openedUri).toContain("state=123")
// scope should be injected from metadata
expect(openedUri).toContain("scope=openid")
// RFC 8707: resource indicator from protected resource metadata should be injected
expect(openedUri).toContain("resource=")
expect(decodeURIComponent(openedUri)).toContain("resource=https://mcp.kapa.ai/")
await provider.close()
})
it("should inject RFC 8707 resource indicator from protected resource metadata", async () => {
// Mock discovery returning a resource indicator (RFC 9728 `resource` field)
;(discoverOAuthProtectedResourceMetadata as any).mockResolvedValueOnce({
resource: "https://temporal.mcp.kapa.ai/",
authorization_servers: ["https://mcp.kapa.ai/auth/public"],
})
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
issuer: "https://mcp.kapa.ai/auth/public",
authorization_endpoint: "https://mcp.kapa.ai/auth/public/authorize",
token_endpoint: "https://mcp.kapa.ai/auth/public/token",
token_endpoint_auth_methods_supported: ["none"],
grant_types_supported: ["authorization_code"],
scopes_supported: ["openid"],
}),
})
setupCallbackServerMock()
const provider = await McpOAuthClientProvider.create(
"https://temporal.mcp.kapa.ai/mcp",
createMockSecretStorage(),
)
const sdkUrl = new URL("https://mcp.kapa.ai/authorize?client_id=abc&state=123")
await provider.redirectToAuthorization(sdkUrl)
await provider.openBrowser()
const openedUri = (vscode.env.openExternal as any).mock.calls[0][0].toString()
// The resource indicator from the protected resource metadata must appear
// as the `resource` query parameter (RFC 8707)
expect(decodeURIComponent(openedUri)).toContain("resource=https://temporal.mcp.kapa.ai/")
await provider.close()
})
it("should not duplicate resource if the SDK already included it", async () => {
setupCallbackServerMock()
const provider = await McpOAuthClientProvider.create("https://example.com/mcp", createMockSecretStorage())
// SDK URL already contains a resource param
const sdkUrl = new URL(
"https://auth.example.com/authorize?client_id=abc&resource=https%3A%2F%2Fexample.com%2F&state=123",
)
await provider.redirectToAuthorization(sdkUrl)
await provider.openBrowser()
const openedUri = (vscode.env.openExternal as any).mock.calls[0][0].toString()
const resourceMatches = (openedUri.match(/resource=/g) || []).length
expect(resourceMatches).toBe(1)
await provider.close()
})
it("should not duplicate scope if the SDK already included it", async () => {
setupCallbackServerMock()
const provider = await McpOAuthClientProvider.create("https://example.com/mcp", createMockSecretStorage())
// SDK URL already includes scope=openid
const sdkUrl = new URL("https://auth.example.com/authorize?client_id=abc&scope=openid&state=123")
await provider.redirectToAuthorization(sdkUrl)
await provider.openBrowser()
// scope should appear exactly once
const openedUri = (vscode.env.openExternal as any).mock.calls[0][0].toString()
const scopeMatches = (openedUri.match(/scope=/g) || []).length
expect(scopeMatches).toBe(1)
await provider.close()
})
})
describe("exchangeCodeForTokens", () => {
it("should POST to the token_endpoint and save tokens", async () => {
;(discoverOAuthProtectedResourceMetadata as any).mockResolvedValueOnce({
resource: "https://mcp.kapa.ai/",
authorization_servers: ["https://mcp.kapa.ai/auth/public"],
})
const tokenResponse = {
access_token: "access-token-xyz",
token_type: "Bearer",
expires_in: 3600,
}
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
issuer: "https://mcp.kapa.ai/auth/public",
authorization_endpoint: "https://mcp.kapa.ai/auth/public/authorize",
token_endpoint: "https://mcp.kapa.ai/auth/public/token",
registration_endpoint: "https://mcp.kapa.ai/auth/public/register",
token_endpoint_auth_methods_supported: ["client_secret_post"],
grant_types_supported: ["authorization_code", "refresh_token"],
scopes_supported: ["openid"],
}),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(tokenResponse),
})
setupCallbackServerMock()
const secretStorage = createMockSecretStorage()
const provider = await McpOAuthClientProvider.create("https://mcp.kapa.ai/mcp", secretStorage)
// Set up client info and code verifier
await provider.saveClientInformation({
client_id: "client-id-123",
client_secret: "client-secret-abc",
redirect_uris: ["http://localhost:12345/callback"],
} as any)
await provider.saveCodeVerifier("pkce-verifier-123")
await provider.exchangeCodeForTokens("auth-code-abc")
// Verify the token endpoint was called with correct params
const tokenCall = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]
expect(tokenCall[0]).toBe("https://mcp.kapa.ai/auth/public/token")
expect(tokenCall[1].method).toBe("POST")
const body = new URLSearchParams(tokenCall[1].body)
expect(body.get("grant_type")).toBe("authorization_code")
expect(body.get("code")).toBe("auth-code-abc")
expect(body.get("client_id")).toBe("client-id-123")
expect(body.get("client_secret")).toBe("client-secret-abc")
expect(body.get("code_verifier")).toBe("pkce-verifier-123")
expect(body.get("redirect_uri")).toBe("http://localhost:0/callback")
// Verify tokens were saved
const saved = await provider.tokens()
expect(saved).toEqual(tokenResponse)
await provider.close()
})
it("should throw when no token_endpoint is available", async () => {
;(discoverOAuthProtectedResourceMetadata as any).mockResolvedValueOnce({
authorization_servers: ["https://auth.example.com"],
})
// Return metadata without token_endpoint
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
issuer: "https://auth.example.com",
authorization_endpoint: "https://auth.example.com/authorize",
// no token_endpoint
}),
})
setupCallbackServerMock()
const provider = await McpOAuthClientProvider.create("https://example.com/mcp", createMockSecretStorage())
await provider.saveClientInformation({ client_id: "id", redirect_uris: [] } as any)
await provider.saveCodeVerifier("verifier")
await expect(provider.exchangeCodeForTokens("code")).rejects.toThrow("No token_endpoint")
await provider.close()
})
it("should throw when no client information is available", async () => {
setupCallbackServerMock()
const provider = await McpOAuthClientProvider.create("https://example.com/mcp", createMockSecretStorage())
await provider.saveCodeVerifier("verifier")
// No saveClientInformation called — should throw
await expect(provider.exchangeCodeForTokens("code")).rejects.toThrow("No client information")
await provider.close()
})
it("should throw when the token endpoint returns a non-OK response", async () => {
;(discoverOAuthProtectedResourceMetadata as any).mockResolvedValueOnce({
resource: "https://mcp.kapa.ai/",
authorization_servers: ["https://mcp.kapa.ai/auth/public"],
})
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
issuer: "https://mcp.kapa.ai/auth/public",
authorization_endpoint: "https://mcp.kapa.ai/auth/public/authorize",
token_endpoint: "https://mcp.kapa.ai/auth/public/token",
token_endpoint_auth_methods_supported: ["client_secret_post"],
grant_types_supported: ["authorization_code"],
}),
})
.mockResolvedValueOnce({
ok: false,
status: 400,
text: () => Promise.resolve('{"error":"invalid_grant"}'),
})
setupCallbackServerMock()
const provider = await McpOAuthClientProvider.create("https://mcp.kapa.ai/mcp", createMockSecretStorage())
await provider.saveClientInformation({ client_id: "id", redirect_uris: [] } as any)
await provider.saveCodeVerifier("verifier")
await expect(provider.exchangeCodeForTokens("bad-code")).rejects.toThrow("Token exchange failed: HTTP 400")
await provider.close()
})
})
describe("waitForAuthCode", () => {
it("should resolve with auth code from callback server", async () => {
setupCallbackServerMock("my-code")
const provider = await McpOAuthClientProvider.create("https://example.com/mcp", createMockSecretStorage())
const code = await provider.waitForAuthCode()
expect(code).toBe("my-code")
await provider.close()
})
it("should reject if callback returns error", async () => {
const mockServer = { close: vi.fn((cb: () => void) => cb()) }
;(startCallbackServer as any).mockResolvedValue({
server: mockServer,
port: 12345,
result: Promise.resolve({ error: "access_denied" }),
})
const provider = await McpOAuthClientProvider.create("https://example.com/mcp", createMockSecretStorage())
await expect(provider.waitForAuthCode()).rejects.toThrow("OAuth authorization failed: access_denied")
await provider.close()
})
it("should reject if callback returns no code", async () => {
const mockServer = { close: vi.fn((cb: () => void) => cb()) }
;(startCallbackServer as any).mockResolvedValue({
server: mockServer,
port: 12345,
result: Promise.resolve({}),
})
const provider = await McpOAuthClientProvider.create("https://example.com/mcp", createMockSecretStorage())
await expect(provider.waitForAuthCode()).rejects.toThrow("No authorization code received")
await provider.close()
})
})
describe("close", () => {
it("should stop the callback server if it was started", async () => {
const { mockServer } = setupCallbackServerMock()
const provider = await McpOAuthClientProvider.create("https://example.com/mcp", createMockSecretStorage())
// Start server lazily
await provider.waitForAuthCode().catch(() => {})
await provider.close()
expect(stopCallbackServer).toHaveBeenCalledWith(mockServer, expect.any(Function))
})
it("should be idempotent", async () => {
setupCallbackServerMock()
const provider = await McpOAuthClientProvider.create("https://example.com/mcp", createMockSecretStorage())
// Start server lazily
await provider.waitForAuthCode().catch(() => {})
await provider.close()
await provider.close()
expect(stopCallbackServer).toHaveBeenCalledTimes(1)
})
it("should not call stopCallbackServer if server was never started", async () => {
setupCallbackServerMock()
const provider = await McpOAuthClientProvider.create("https://example.com/mcp", createMockSecretStorage())
await provider.close()
expect(stopCallbackServer).not.toHaveBeenCalled()
})
})
describe("registerClientIfNeeded", () => {
it("should reuse cached client_info from previous registration", async () => {
setupCallbackServerMock()
const secretStorage = createMockSecretStorage()
// Pre-populate storage with cached data including full client_info
await secretStorage.saveOAuthData("https://example.com/mcp", {
tokens: { access_token: "cached-token", token_type: "Bearer" },
expires_at: Date.now() + 3600000,
client_info: {
client_id: "cached-client-id",
client_name: "Test Client",
redirect_uris: ["http://localhost:9999/callback"],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none",
},
})
const provider = await McpOAuthClientProvider.create("https://example.com/mcp", secretStorage)
await provider.registerClientIfNeeded()
expect((await provider.clientInformation())?.client_id).toBe("cached-client-id")
await provider.close()
})
it("should reuse cached client_info even when callback server port has changed", async () => {
setupCallbackServerMock()
const secretStorage = createMockSecretStorage()
// Pre-populate storage with cached data — the callback server port (12345)
// may differ from the port used in the original registration, but we still
// reuse the client_id to avoid "refresh token not issued to this client" errors.
await secretStorage.saveOAuthData("https://example.com/mcp", {
tokens: { access_token: "cached-token", token_type: "Bearer" },
expires_at: Date.now() + 3600000,
client_info: {
client_id: "cached-client-id",
client_name: "Test Client",
redirect_uris: ["http://localhost:9999/callback"],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none",
},
})
const provider = await McpOAuthClientProvider.create("https://example.com/mcp", secretStorage)
await provider.registerClientIfNeeded()
// Should still reuse the cached client_id, NOT perform a new DCR
expect((await provider.clientInformation())?.client_id).toBe("cached-client-id")
await provider.close()
})
it("should perform DCR when no cached client_id exists", async () => {
setupCallbackServerMock()
const secretStorage = createMockSecretStorage()
// Clear previous mocks and set up for this test
mockFetch.mockClear()
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
issuer: "https://auth.example.com",
authorization_endpoint: "https://auth.example.com/authorize",
token_endpoint: "https://auth.example.com/token",
registration_endpoint: "https://auth.example.com/register",
response_types_supported: ["code"],
token_endpoint_auth_methods_supported: ["none"],
grant_types_supported: ["authorization_code", "refresh_token"],
}),
})
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
client_id: "new-client-id",
redirect_uris: ["http://localhost:12345/callback"],
client_name: "Roo Code",
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none",
}),
})
// No cached client_id in storage
await secretStorage.saveOAuthData("https://example.com/mcp", {
tokens: { access_token: "cached-token", token_type: "Bearer" },
expires_at: Date.now() + 3600000,
})
const provider = await McpOAuthClientProvider.create("https://example.com/mcp", secretStorage)
await provider.registerClientIfNeeded()
expect((await provider.clientInformation())?.client_id).toBe("new-client-id")
await provider.close()
})
it("should use the same redirect URI in DCR and authorization flow", async () => {
setupCallbackServerMock()
const secretStorage = createMockSecretStorage()
mockFetch.mockClear()
// Auth server metadata
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
issuer: "https://auth.example.com",
authorization_endpoint: "https://auth.example.com/authorize",
token_endpoint: "https://auth.example.com/token",
registration_endpoint: "https://auth.example.com/register",
response_types_supported: ["code"],
token_endpoint_auth_methods_supported: ["none"],
grant_types_supported: ["authorization_code", "refresh_token"],
}),
})
// DCR response
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
client_id: "consistency-client-id",
redirect_uris: ["http://localhost:12345/callback"],
client_name: "Roo Code",
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none",
}),
})
const provider = await McpOAuthClientProvider.create("https://example.com/mcp", secretStorage)
await provider.registerClientIfNeeded()
// The redirect_uri sent in the DCR body must equal the redirectUrl property
// used during the authorization redirect so RFC 6749 §4.1.3 validation passes.
const dcrBody = JSON.parse(mockFetch.mock.calls[1][1]?.body as string)
expect(dcrBody.redirect_uris).toContain(provider.redirectUrl)
await provider.close()
})
})
})

View file

@ -0,0 +1,238 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
vi.mock("vscode", () => ({}))
import { SecretStorageService, StoredMcpOAuthData } from "../SecretStorageService"
function createMockContext() {
const store = new Map<string, string>()
// Listeners registered via onDidChange; keyed by arbitrary id for disposal.
const listeners = new Map<number, (e: { key: string }) => void>()
let nextId = 0
const secrets = {
get: vi.fn(async (key: string) => store.get(key)),
store: vi.fn(async (key: string, value: string) => {
store.set(key, value)
}),
delete: vi.fn(async (key: string) => {
store.delete(key)
}),
onDidChange: vi.fn((handler: (e: { key: string }) => void) => {
const id = nextId++
listeners.set(id, handler)
return { dispose: () => listeners.delete(id) }
}),
/** Test helper: simulate a storage change event. */
_emit: (key: string) => {
for (const handler of listeners.values()) handler({ key })
},
}
return { secrets } as any
}
describe("SecretStorageService", () => {
let service: SecretStorageService
let context: ReturnType<typeof createMockContext>
beforeEach(() => {
context = createMockContext()
service = new SecretStorageService(context)
})
describe("getOAuthData", () => {
it("should return undefined when no data stored", async () => {
const result = await service.getOAuthData("https://example.com/mcp")
expect(result).toBeUndefined()
})
it("should return stored data", async () => {
const data: StoredMcpOAuthData = {
tokens: { access_token: "tok", token_type: "Bearer" },
expires_at: Date.now() + 3600_000,
}
await service.saveOAuthData("https://example.com/mcp", data)
const result = await service.getOAuthData("https://example.com/mcp")
expect(result).toEqual(data)
})
it("should return undefined for malformed JSON", async () => {
// Manually store garbage via the underlying mock (key uses base64url-encoded path)
context.secrets.store("mcp.oauth.example.com.L21jcA.data", "not-json")
const result = await service.getOAuthData("https://example.com/mcp")
expect(result).toBeUndefined()
})
})
describe("saveOAuthData", () => {
it("should persist data under host and path-based key", async () => {
const data: StoredMcpOAuthData = {
tokens: { access_token: "abc", token_type: "Bearer" },
expires_at: 12345,
}
await service.saveOAuthData("https://example.com/mcp", data)
expect(context.secrets.store).toHaveBeenCalledWith(
"mcp.oauth.example.com.L21jcA.data",
JSON.stringify(data),
)
})
it("should handle root path correctly", async () => {
const data: StoredMcpOAuthData = {
tokens: { access_token: "abc", token_type: "Bearer" },
expires_at: 12345,
}
await service.saveOAuthData("https://example.com/", data)
expect(context.secrets.store).toHaveBeenCalledWith("mcp.oauth.example.com.data", JSON.stringify(data))
})
})
describe("hasOAuthData", () => {
it("should return false when no data stored", async () => {
expect(await service.hasOAuthData("https://example.com/mcp")).toBe(false)
})
it("should return true when data is stored", async () => {
const data: StoredMcpOAuthData = {
tokens: { access_token: "tok", token_type: "Bearer" },
expires_at: Date.now() + 3600_000,
}
await service.saveOAuthData("https://example.com/mcp", data)
expect(await service.hasOAuthData("https://example.com/mcp")).toBe(true)
})
it("should return false after data is deleted", async () => {
const data: StoredMcpOAuthData = {
tokens: { access_token: "tok", token_type: "Bearer" },
expires_at: Date.now() + 3600_000,
}
await service.saveOAuthData("https://example.com/mcp", data)
await service.deleteOAuthData("https://example.com/mcp")
expect(await service.hasOAuthData("https://example.com/mcp")).toBe(false)
})
})
describe("deleteOAuthData", () => {
it("should delete stored data", async () => {
const data: StoredMcpOAuthData = {
tokens: { access_token: "tok", token_type: "Bearer" },
expires_at: Date.now() + 3600_000,
}
await service.saveOAuthData("https://example.com/mcp", data)
await service.deleteOAuthData("https://example.com/mcp")
expect(context.secrets.delete).toHaveBeenCalledWith("mcp.oauth.example.com.L21jcA.data")
const result = await service.getOAuthData("https://example.com/mcp")
expect(result).toBeUndefined()
})
})
describe("onDidChange", () => {
it("should call the callback when the key for the given URL changes", () => {
const cb = vi.fn()
service.onDidChange("https://example.com/mcp", cb)
context.secrets._emit("mcp.oauth.example.com.L21jcA.data")
expect(cb).toHaveBeenCalledTimes(1)
})
it("should not call the callback for a different URL's key", () => {
const cb = vi.fn()
service.onDidChange("https://example.com/mcp", cb)
context.secrets._emit("mcp.oauth.other.com.L21jcA.data")
expect(cb).not.toHaveBeenCalled()
})
it("should stop calling the callback after the returned dispose function is called", () => {
const cb = vi.fn()
const unsubscribe = service.onDidChange("https://example.com/mcp", cb)
unsubscribe()
context.secrets._emit("mcp.oauth.example.com.L21jcA.data")
expect(cb).not.toHaveBeenCalled()
})
})
describe("client_info round-trip", () => {
it("should persist and retrieve full client_info", async () => {
const data: StoredMcpOAuthData = {
tokens: { access_token: "tok", token_type: "Bearer" },
expires_at: Date.now() + 3600_000,
client_info: {
client_id: "cid-123",
client_secret: "secret-456",
client_name: "Test Client",
redirect_uris: ["http://localhost:12345/callback"],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "client_secret_post",
},
}
await service.saveOAuthData("https://example.com/mcp", data)
const result = await service.getOAuthData("https://example.com/mcp")
expect(result).toEqual(data)
expect(result?.client_info?.client_id).toBe("cid-123")
expect(result?.client_info?.client_secret).toBe("secret-456")
expect(result?.client_info?.token_endpoint_auth_method).toBe("client_secret_post")
})
})
describe("key isolation", () => {
it("should isolate data by host", async () => {
const data1: StoredMcpOAuthData = {
tokens: { access_token: "a", token_type: "Bearer" },
expires_at: 1,
}
const data2: StoredMcpOAuthData = {
tokens: { access_token: "b", token_type: "Bearer" },
expires_at: 2,
}
await service.saveOAuthData("https://host1.com/mcp", data1)
await service.saveOAuthData("https://host2.com/mcp", data2)
expect((await service.getOAuthData("https://host1.com/mcp"))?.tokens.access_token).toBe("a")
expect((await service.getOAuthData("https://host2.com/mcp"))?.tokens.access_token).toBe("b")
})
it("should isolate data by path on the same host", async () => {
const data1: StoredMcpOAuthData = {
tokens: { access_token: "path1", token_type: "Bearer" },
expires_at: 1,
}
const data2: StoredMcpOAuthData = {
tokens: { access_token: "path2", token_type: "Bearer" },
expires_at: 2,
}
await service.saveOAuthData("https://example.com/service1", data1)
await service.saveOAuthData("https://example.com/service2", data2)
expect((await service.getOAuthData("https://example.com/service1"))?.tokens.access_token).toBe("path1")
expect((await service.getOAuthData("https://example.com/service2"))?.tokens.access_token).toBe("path2")
})
it("should not collide between paths that differ only in separators (/a-b, /a_b, /a/b)", async () => {
const urls = ["https://example.com/a-b", "https://example.com/a_b", "https://example.com/a/b"]
for (const [i, url] of urls.entries()) {
await service.saveOAuthData(url, {
tokens: { access_token: `tok-${i}`, token_type: "Bearer" },
expires_at: i,
})
}
for (const [i, url] of urls.entries()) {
expect((await service.getOAuthData(url))?.tokens.access_token).toBe(`tok-${i}`)
}
})
})
})

View file

@ -0,0 +1,2 @@
export const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000 // 5 minutes
export const OAUTH_FLOW_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes

View file

@ -0,0 +1,127 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { startCallbackServer, stopCallbackServer } from "../callbackServer"
import * as http from "http"
vi.mock("http", () => ({
createServer: vi.fn(),
}))
describe("startCallbackServer", () => {
beforeEach(() => {
vi.restoreAllMocks()
delete process.env.MCP_OAUTH_TEST_MODE
})
it("should start server and resolve with callback result", async () => {
const mockServer = {
listen: vi.fn((port, host, callback) => {
callback()
return mockServer
}),
address: vi.fn(() => ({ port: 3000 })),
on: vi.fn(),
close: vi.fn(),
}
;(http.createServer as any).mockReturnValue(mockServer)
const promise = startCallbackServer()
const { server, port, result } = await promise
expect(port).toBe(3000)
expect(server).toBe(mockServer)
// Simulate callback request
const requestCall = mockServer.on.mock.calls.find((call) => call[0] === "request")
const requestHandler = requestCall ? requestCall[1] : vi.fn()
const mockReq = {
url: "/callback?code=test-code&state=test-state",
method: "GET",
}
const mockRes = {
writeHead: vi.fn(),
end: vi.fn(),
on: vi.fn((event, cb) => {
if (event === "finish") setImmediate(cb)
}),
}
requestHandler(mockReq, mockRes)
const callbackResult = await result
expect(callbackResult.code).toBe("test-code")
expect(callbackResult.state).toBe("test-state")
})
it("should reject invalid state", async () => {
const mockServer = {
listen: vi.fn((port, host, callback) => {
callback()
return mockServer
}),
address: vi.fn(() => ({ port: 3000 })),
on: vi.fn(),
close: vi.fn(),
}
;(http.createServer as any).mockReturnValue(mockServer)
const promise = startCallbackServer(undefined, "expected-state")
const { result } = await promise
// Simulate callback request with wrong state
const requestCall = mockServer.on.mock.calls.find((call) => call[0] === "request")
const requestHandler = requestCall ? requestCall[1] : vi.fn()
const mockReq = {
url: "/callback?code=test-code&state=wrong-state",
method: "GET",
}
const mockRes = {
writeHead: vi.fn(),
end: vi.fn(),
on: vi.fn((event, cb) => {
if (event === "finish") setImmediate(cb)
}),
}
requestHandler(mockReq, mockRes)
await expect(result).rejects.toThrow("Invalid state parameter")
})
})
describe("stopCallbackServer", () => {
it("should close the server", async () => {
const mockServer = {
close: vi.fn((callback) => callback()),
}
await stopCallbackServer(mockServer as any, () => {})
expect(mockServer.close).toHaveBeenCalled()
})
it("should call the cancel function before closing", async () => {
const mockServer = { close: vi.fn((callback) => callback()) }
const cancel = vi.fn()
await stopCallbackServer(mockServer as any, cancel)
expect(cancel).toHaveBeenCalledTimes(1)
expect(mockServer.close).toHaveBeenCalled()
})
})
describe("startCallbackServer in test mode", () => {
it("should resolve immediately with mock auth code when MCP_OAUTH_TEST_MODE is set", async () => {
process.env.MCP_OAUTH_TEST_MODE = "true"
try {
const { port, result, cancel } = await startCallbackServer(undefined, "test-state")
expect(port).toBe(3000)
expect(typeof cancel).toBe("function")
const callbackResult = await result
expect(callbackResult.code).toBe("test-auth-code")
expect(callbackResult.state).toBe("test-state")
} finally {
delete process.env.MCP_OAUTH_TEST_MODE
}
})
})

View file

@ -0,0 +1,153 @@
import { describe, it, expect, vi, beforeEach, afterAll } from "vitest"
// Mock the SDK's discoverOAuthProtectedResourceMetadata
vi.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
discoverOAuthProtectedResourceMetadata: vi.fn(),
}))
import { discoverOAuthProtectedResourceMetadata } from "@modelcontextprotocol/sdk/client/auth.js"
import { fetchOAuthAuthServerMetadata } from "../oauth"
const mockFetch = vi.fn()
const originalFetch = global.fetch
global.fetch = mockFetch
describe("fetchOAuthAuthServerMetadata", () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterAll(() => {
global.fetch = originalFetch
})
it("returns null when resource metadata has no authorization_servers", async () => {
;(discoverOAuthProtectedResourceMetadata as any).mockResolvedValue({
resource: "https://example.com/",
authorization_servers: [],
})
const result = await fetchOAuthAuthServerMetadata("https://example.com/mcp")
expect(result).toBeNull()
})
it("returns null when discoverOAuthProtectedResourceMetadata throws", async () => {
;(discoverOAuthProtectedResourceMetadata as any).mockRejectedValue(new Error("network error"))
const result = await fetchOAuthAuthServerMetadata("https://example.com/mcp")
expect(result).toBeNull()
})
it("constructs the RFC 8414 discovery URL correctly for an issuer with a path", async () => {
;(discoverOAuthProtectedResourceMetadata as any).mockResolvedValue({
resource: "https://mcp.kapa.ai/",
authorization_servers: ["https://mcp.kapa.ai/auth/public"],
})
const mockMeta = {
issuer: "https://mcp.kapa.ai/auth/public",
registration_endpoint: "https://mcp.kapa.ai/auth/public/register",
}
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockMeta) })
const result = await fetchOAuthAuthServerMetadata("https://mcp.kapa.ai/mcp")
// Verify the RFC 8414 §3.1 URL: well-known inserted between host and path
expect(mockFetch).toHaveBeenCalledWith(
"https://mcp.kapa.ai/.well-known/oauth-authorization-server/auth/public",
expect.objectContaining({ headers: { Accept: "application/json" } }),
)
expect(result).toEqual({ authServerMeta: mockMeta, resourceIndicator: "https://mcp.kapa.ai/" })
})
it("constructs the RFC 8414 discovery URL correctly for an issuer without a path", async () => {
;(discoverOAuthProtectedResourceMetadata as any).mockResolvedValue({
resource: "https://auth.example.com/",
authorization_servers: ["https://auth.example.com"],
})
const mockMeta = { issuer: "https://auth.example.com" }
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockMeta) })
await fetchOAuthAuthServerMetadata("https://auth.example.com/mcp")
expect(mockFetch).toHaveBeenCalledWith(
"https://auth.example.com/.well-known/oauth-authorization-server",
expect.any(Object),
)
})
it("strips trailing slash from issuer path before inserting well-known", async () => {
;(discoverOAuthProtectedResourceMetadata as any).mockResolvedValue({
resource: "https://example.com/",
authorization_servers: ["https://example.com/issuer/"],
})
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) })
await fetchOAuthAuthServerMetadata("https://example.com/mcp")
expect(mockFetch).toHaveBeenCalledWith(
"https://example.com/.well-known/oauth-authorization-server/issuer",
expect.any(Object),
)
})
it("returns null when the discovery endpoint returns a non-OK response", async () => {
;(discoverOAuthProtectedResourceMetadata as any).mockResolvedValue({
resource: "https://example.com/",
authorization_servers: ["https://auth.example.com"],
})
mockFetch.mockResolvedValueOnce({ ok: false, status: 404 })
const result = await fetchOAuthAuthServerMetadata("https://example.com/mcp")
expect(result).toBeNull()
})
it("returns null when fetch throws", async () => {
;(discoverOAuthProtectedResourceMetadata as any).mockResolvedValue({
resource: "https://example.com/",
authorization_servers: ["https://auth.example.com"],
})
mockFetch.mockRejectedValueOnce(new Error("connection refused"))
const result = await fetchOAuthAuthServerMetadata("https://example.com/mcp")
expect(result).toBeNull()
})
it("returns the parsed metadata and resource indicator on success", async () => {
;(discoverOAuthProtectedResourceMetadata as any).mockResolvedValue({
resource: "https://example.com/",
authorization_servers: ["https://auth.example.com/oauth2"],
})
const meta = {
issuer: "https://auth.example.com/oauth2",
authorization_endpoint: "https://auth.example.com/oauth2/authorize",
token_endpoint: "https://auth.example.com/oauth2/token",
registration_endpoint: "https://auth.example.com/oauth2/register",
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
grant_types_supported: ["authorization_code", "refresh_token"],
scopes_supported: ["openid"],
}
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(meta) })
const result = await fetchOAuthAuthServerMetadata("https://example.com/mcp")
expect(result).toEqual({ authServerMeta: meta, resourceIndicator: "https://example.com/" })
})
it("returns null resourceIndicator when protected resource metadata has no resource field", async () => {
;(discoverOAuthProtectedResourceMetadata as any).mockResolvedValue({
authorization_servers: ["https://auth.example.com"],
// no `resource` field
})
const meta = { issuer: "https://auth.example.com" }
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(meta) })
const result = await fetchOAuthAuthServerMetadata("https://example.com/mcp")
expect(result).toEqual({ authServerMeta: meta, resourceIndicator: null })
})
})

View file

@ -0,0 +1,217 @@
import * as http from "http"
import { t } from "../../../i18n"
import { OAUTH_FLOW_TIMEOUT_MS } from "../constants"
export interface CallbackResult {
code?: string
error?: string
error_description?: string
state?: string
}
/**
* Starts a local HTTP server to handle OAuth callback.
* @param port Optional port to use (defaults to random available port)
* @param expectedState Optional expected state for CSRF protection
* @returns Promise<{server: http.Server, port: number, result: Promise<CallbackResult>}>
*/
export function startCallbackServer(
port?: number,
expectedState?: string,
): Promise<{
server: http.Server
port: number
result: Promise<CallbackResult>
cancel: () => void
}> {
// In test mode, immediately resolve with mock data
if (process.env.MCP_OAUTH_TEST_MODE === "true") {
return new Promise((resolve) => {
const mockServer = http.createServer()
resolve({
server: mockServer,
port: 3000,
result: Promise.resolve({ code: "test-auth-code", state: expectedState }),
cancel: () => {},
})
})
}
return new Promise((resolve, reject) => {
const server = http.createServer()
server.listen(port || 0, "127.0.0.1", () => {
const address = server.address()
if (!address || typeof address === "string") {
reject(new Error("Failed to get server address"))
return
}
const actualPort = address.port
let resolveResult!: (value: CallbackResult) => void
let rejectResult!: (reason: unknown) => void
const resultPromise = new Promise<CallbackResult>((res, rej) => {
resolveResult = res
rejectResult = rej
})
let resolved = false
const timeout = setTimeout(() => {
if (!resolved) {
resolved = true
rejectResult(new Error("Callback timeout"))
server.close()
}
}, OAUTH_FLOW_TIMEOUT_MS)
const cancel = () => {
if (!resolved) {
resolved = true
clearTimeout(timeout)
rejectResult(new Error("Callback cancelled"))
}
}
server.on("request", (req: any, res: any) => {
if (resolved) return
const url = new URL(req.url || "", `http://localhost:${actualPort}`)
const pathname = url.pathname
if (pathname === "/callback") {
resolved = true
clearTimeout(timeout)
const code = url.searchParams.get("code")
const error = url.searchParams.get("error")
const errorDescription = url.searchParams.get("error_description")
const state = url.searchParams.get("state")
const hasError = !!error
// Verify state for CSRF protection
if (expectedState && state !== expectedState) {
res.writeHead(400, {
"Content-Type": "text/html",
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'",
})
res.end(`
<!DOCTYPE html>
<html>
<head>
<title>${t("mcp:oauth.callback.title")}</title>
</head>
<body>
<h1>${t("mcp:oauth.callback.failed")}</h1>
<p>${t("mcp:oauth.callback.invalid_state")}</p>
</body>
</html>
`)
rejectResult(new Error("Invalid state parameter"))
server.close()
return
}
// Send HTML response
res.writeHead(200, {
"Content-Type": "text/html",
"Content-Security-Policy":
"default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'",
})
res.end(`
<!DOCTYPE html>
<html>
<head>
<title>${t("mcp:oauth.callback.title")}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 400px; margin: 50px auto; text-align: center; padding: 20px; }
h1 { color: #28a745; }
.error h1 { color: #dc3545; }
.spinner { border: 4px solid #f3f3f3; border-top: 4px solid #28a745; border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 20px auto; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
button { background: #007acc; color: white; border: none; padding: 10px 20px; border-radius: 6px; cursor: pointer; font-size: 16px; }
.countdown { font-size: 18px; margin: 10px 0; }
</style>
</head>
<body>
<h1 id="title">${hasError ? t("mcp:oauth.callback.failed") : t("mcp:oauth.callback.success")}</h1>
<div class="spinner" id="spinner" style="${hasError ? "display:none;" : ""}"></div>
<p id="message">
${hasError ? t("mcp:oauth.callback.auth_failed") : t("mcp:oauth.callback.auth_success")}
</p>
<div id="countdown" class="countdown" style="${hasError ? "display:none;" : ""}">${t("mcp:oauth.callback.server_connection_complete")}</div>
<script>
const isError = ${hasError ? "true" : "false"};
if (!isError) {
let count = 5;
const countdownEl = document.getElementById('countdown');
if (countdownEl) {
countdownEl.innerHTML = \`${t("mcp:oauth.callback.tab_closing_in", { count: 5 })}\`;
}
const timer = setInterval(() => {
count--;
const currentCountEl = document.getElementById('count');
if (currentCountEl) currentCountEl.textContent = count;
if (count <= 0) {
clearInterval(timer);
window.close();
if (countdownEl) {
countdownEl.textContent = \`${t("mcp:oauth.callback.safe_to_close")}\`;
}
}
}, 1000);
}
</script>
</body>
</html>
`)
resolveResult({
code: code || undefined,
error: error || undefined,
error_description: errorDescription || undefined,
state: state || undefined,
})
// Close server immediately after response drains
res.on("finish", () => {
server.close()
})
} else {
res.writeHead(404)
res.end("Not found")
}
})
server.on("error", (error: any) => {
if (!resolved) {
resolved = true
clearTimeout(timeout)
rejectResult(error)
}
})
resolve({
server,
port: actualPort,
result: resultPromise,
cancel,
})
})
server.on("error", reject)
})
}
/**
* Stops the callback server and cancels any pending result promise so its
* timeout doesn't fire after the provider is already closed.
*/
export function stopCallbackServer(server: http.Server, cancel: () => void): Promise<void> {
cancel()
return new Promise((resolve) => {
server.close(() => resolve())
})
}

View file

@ -0,0 +1,87 @@
import { discoverOAuthProtectedResourceMetadata } from "@modelcontextprotocol/sdk/client/auth.js"
/**
* Result of a successful OAuth discovery for an MCP server.
*/
export interface OAuthDiscoveryResult {
/** The raw OAuth Authorization Server metadata (RFC 8414). */
authServerMeta: Record<string, any>
/**
* The RFC 8707 resource indicator the `resource` field from the Protected
* Resource Metadata (RFC 9728). `null` when the server didn't advertise one.
*
* Must be sent as the `resource` query parameter in authorization requests so
* the auth server can scope the issued tokens to this specific resource server.
*/
resourceIndicator: string | null
}
/**
* Fetches the raw OAuth Authorization Server metadata for an MCP server URL.
*
* This replaces the SDK's built-in `discoverOAuthMetadata()` because it
* constructs the RFC 8414 well-known URL incorrectly for auth servers with
* path components a known bug tracked in multiple upstream issues:
*
* - https://github.com/modelcontextprotocol/typescript-sdk/issues/545
* (URL constructor discards base path with leading-slash well-known)
* - https://github.com/modelcontextprotocol/typescript-sdk/issues/762
* (uses MCP server URL instead of authorization server URL)
* - https://github.com/modelcontextprotocol/typescript-sdk/issues/744
* (doesn't respect provided authorization server URL)
* - https://github.com/modelcontextprotocol/typescript-sdk/issues/822
* (general RFC 8414 compliance affects Keycloak, Okta, Azure Entra)
*
* Performs two discovery steps:
* 1. RFC 9728 fetches the Protected Resource Metadata to find the issuer URL
* and the RFC 8707 resource indicator.
* 2. RFC 8414 §3.1 constructs the well-known discovery URL by inserting
* `/.well-known/oauth-authorization-server` *between* the host and the issuer
* path (not appended after the path).
*
* Correct: https://example.com/.well-known/oauth-authorization-server/auth/public
* SDK wrong: https://example.com/auth/public/.well-known/oauth-authorization-server
*
* Returns an {@link OAuthDiscoveryResult} on success, or `null` if any step fails.
*/
const DISCOVERY_TIMEOUT_MS = 5_000
export async function fetchOAuthAuthServerMetadata(serverUrl: string): Promise<OAuthDiscoveryResult | null> {
try {
// Step 1 RFC 9728: resolve the authorization server issuer URL and
// capture the resource indicator for RFC 8707.
// The SDK does not accept an AbortSignal, so we race it against a timeout.
const resourceMeta = await Promise.race([
discoverOAuthProtectedResourceMetadata(serverUrl),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("OAuth discovery timeout")), DISCOVERY_TIMEOUT_MS),
),
])
const authServers = resourceMeta.authorization_servers
if (!authServers?.length) return null
// RFC 8707: the `resource` field from the protected resource metadata is
// used as the `resource` parameter in the authorization request so the auth
// server can issue tokens scoped to this specific resource server.
const resourceIndicator: string | null =
typeof resourceMeta.resource === "string" ? resourceMeta.resource : null
// Step 2 RFC 8414 §3.1: build the well-known URL.
// For issuer "https://example.com/auth/public"
// → "https://example.com/.well-known/oauth-authorization-server/auth/public"
const parsed = new URL(authServers[0])
const base = `${parsed.protocol}//${parsed.host}`
const issuePath = parsed.pathname.replace(/\/$/, "") || ""
const discoveryUrl = `${base}/.well-known/oauth-authorization-server${issuePath}`
const response = await fetch(discoveryUrl, {
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS),
})
if (!response.ok) return null
const authServerMeta = (await response.json()) as Record<string, any>
return { authServerMeta, resourceIndicator }
} catch {
return null
}
}