mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
Merge 05b399b63e into b867ec9145
This commit is contained in:
commit
737c5dfa1f
32 changed files with 4087 additions and 25 deletions
372
apps/vscode-e2e/src/suite/mcp-oauth.test.ts
Normal file
372
apps/vscode-e2e/src/suite/mcp-oauth.test.ts
Normal 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])
|
||||
})
|
||||
})
|
||||
|
|
@ -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()])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
23
src/i18n/locales/ca/mcp.json
generated
23
src/i18n/locales/ca/mcp.json
generated
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
23
src/i18n/locales/de/mcp.json
generated
23
src/i18n/locales/de/mcp.json
generated
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
23
src/i18n/locales/es/mcp.json
generated
23
src/i18n/locales/es/mcp.json
generated
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
23
src/i18n/locales/fr/mcp.json
generated
23
src/i18n/locales/fr/mcp.json
generated
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
23
src/i18n/locales/hi/mcp.json
generated
23
src/i18n/locales/hi/mcp.json
generated
|
|
@ -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}}\" सफलतापूर्वक कनेक्ट हो गया।"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
23
src/i18n/locales/id/mcp.json
generated
23
src/i18n/locales/id/mcp.json
generated
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
23
src/i18n/locales/it/mcp.json
generated
23
src/i18n/locales/it/mcp.json
generated
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
23
src/i18n/locales/ja/mcp.json
generated
23
src/i18n/locales/ja/mcp.json
generated
|
|
@ -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}}\" が正常に接続されました。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
23
src/i18n/locales/ko/mcp.json
generated
23
src/i18n/locales/ko/mcp.json
generated
|
|
@ -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}}\"가 성공적으로 연결되었습니다."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
23
src/i18n/locales/nl/mcp.json
generated
23
src/i18n/locales/nl/mcp.json
generated
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
23
src/i18n/locales/pl/mcp.json
generated
23
src/i18n/locales/pl/mcp.json
generated
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
23
src/i18n/locales/pt-BR/mcp.json
generated
23
src/i18n/locales/pt-BR/mcp.json
generated
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
23
src/i18n/locales/ru/mcp.json
generated
23
src/i18n/locales/ru/mcp.json
generated
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
23
src/i18n/locales/tr/mcp.json
generated
23
src/i18n/locales/tr/mcp.json
generated
|
|
@ -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ı."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
23
src/i18n/locales/vi/mcp.json
generated
23
src/i18n/locales/vi/mcp.json
generated
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
23
src/i18n/locales/zh-CN/mcp.json
generated
23
src/i18n/locales/zh-CN/mcp.json
generated
|
|
@ -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}}\" 已成功连接。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
23
src/i18n/locales/zh-TW/mcp.json
generated
23
src/i18n/locales/zh-TW/mcp.json
generated
|
|
@ -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}}\" 已成功連線。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
544
src/services/mcp/McpOAuthClientProvider.ts
Normal file
544
src/services/mcp/McpOAuthClientProvider.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
77
src/services/mcp/SecretStorageService.ts
Normal file
77
src/services/mcp/SecretStorageService.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
852
src/services/mcp/__tests__/McpOAuthClientProvider.spec.ts
Normal file
852
src/services/mcp/__tests__/McpOAuthClientProvider.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
238
src/services/mcp/__tests__/SecretStorageService.spec.ts
Normal file
238
src/services/mcp/__tests__/SecretStorageService.spec.ts
Normal 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}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
2
src/services/mcp/constants.ts
Normal file
2
src/services/mcp/constants.ts
Normal 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
|
||||
127
src/services/mcp/utils/__tests__/callbackServer.spec.ts
Normal file
127
src/services/mcp/utils/__tests__/callbackServer.spec.ts
Normal 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
|
||||
}
|
||||
})
|
||||
})
|
||||
153
src/services/mcp/utils/__tests__/oauth.spec.ts
Normal file
153
src/services/mcp/utils/__tests__/oauth.spec.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
217
src/services/mcp/utils/callbackServer.ts
Normal file
217
src/services/mcp/utils/callbackServer.ts
Normal 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())
|
||||
})
|
||||
}
|
||||
87
src/services/mcp/utils/oauth.ts
Normal file
87
src/services/mcp/utils/oauth.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue