diff --git a/packages/types/src/mcp.ts b/packages/types/src/mcp.ts index f1bfde325de..b8b35879577 100644 --- a/packages/types/src/mcp.ts +++ b/packages/types/src/mcp.ts @@ -55,6 +55,7 @@ export type McpServer = { name: string config: string status: "connected" | "connecting" | "disconnected" + authStatus?: "unauthenticated" | "awaiting_auth" | "authenticated" error?: string errorHistory?: McpErrorEntry[] tools?: McpTool[] @@ -105,16 +106,34 @@ export type McpToolCallResponse = { | { type: "text" text: string + annotations?: { + audience?: ("assistant" | "user")[] + priority?: number + lastModified?: string + } + _meta?: Record // eslint-disable-line @typescript-eslint/no-explicit-any } | { type: "image" data: string mimeType: string + annotations?: { + audience?: ("assistant" | "user")[] + priority?: number + lastModified?: string + } + _meta?: Record // eslint-disable-line @typescript-eslint/no-explicit-any } | { type: "audio" data: string mimeType: string + annotations?: { + audience?: ("assistant" | "user")[] + priority?: number + lastModified?: string + } + _meta?: Record // eslint-disable-line @typescript-eslint/no-explicit-any } | { type: "resource" @@ -124,6 +143,26 @@ export type McpToolCallResponse = { text?: string blob?: string } + annotations?: { + audience?: ("assistant" | "user")[] + priority?: number + lastModified?: string + } + _meta?: Record // eslint-disable-line @typescript-eslint/no-explicit-any + } + | { + type: "resource_link" + uri: string + name: string + description?: string + mimeType?: string + size?: number + annotations?: { + audience?: ("assistant" | "user")[] + priority?: number + lastModified?: string + } + _meta?: Record // eslint-disable-line @typescript-eslint/no-explicit-any } > isError?: boolean diff --git a/src/activate/handleUri.ts b/src/activate/handleUri.ts index c29b66e21af..6ec2ace46cb 100644 --- a/src/activate/handleUri.ts +++ b/src/activate/handleUri.ts @@ -43,6 +43,17 @@ export const handleUri = async (uri: vscode.Uri) => { ) break } + case "/mcp-auth/callback": { + const code = query.get("code") + const state = query.get("state") + if (code && state) { + const mcpHub = visibleProvider.getMcpHub() + if (mcpHub) { + mcpHub.handleOAuthCallback(code, state) + } + } + break + } default: break } diff --git a/src/i18n/locales/ca/mcp.json b/src/i18n/locales/ca/mcp.json index dcf3cbc76bf..2b71d6c6583 100644 --- a/src/i18n/locales/ca/mcp.json +++ b/src/i18n/locales/ca/mcp.json @@ -23,6 +23,8 @@ "already_refreshing": "Els servidors MCP ja s'estan actualitzant.", "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." + "project_config_deleted": "Fitxer de configuració MCP del projecte eliminat. Tots els servidors MCP del projecte han estat desconnectats.", + "server_requires_auth": "{{serverName}} requereix autenticació. Completeu el flux d'inici de sessió al vostre navegador.", + "server_auth_failed": "L'autenticació de {{serverName}} ha fallat: {{reason}}" } } diff --git a/src/i18n/locales/de/mcp.json b/src/i18n/locales/de/mcp.json index 30f3a2ed98f..3ffb0e1d6b4 100644 --- a/src/i18n/locales/de/mcp.json +++ b/src/i18n/locales/de/mcp.json @@ -23,6 +23,8 @@ "already_refreshing": "MCP-Server werden bereits aktualisiert.", "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." + "project_config_deleted": "Projekt-MCP-Konfigurationsdatei gelöscht. Alle Projekt-MCP-Server wurden getrennt.", + "server_requires_auth": "{{serverName}} erfordert Authentifizierung. Bitte schließe den Anmeldeprozess in deinem Browser ab.", + "server_auth_failed": "Authentifizierung von {{serverName}} fehlgeschlagen: {{reason}}" } } diff --git a/src/i18n/locales/en/mcp.json b/src/i18n/locales/en/mcp.json index 0200e26d223..5af8dc2a330 100644 --- a/src/i18n/locales/en/mcp.json +++ b/src/i18n/locales/en/mcp.json @@ -23,6 +23,8 @@ "already_refreshing": "MCP servers are already refreshing.", "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." + "project_config_deleted": "Project MCP configuration file deleted. All project MCP servers have been disconnected.", + "server_requires_auth": "{{serverName}} requires authentication. Please complete the sign-in flow in your browser.", + "server_auth_failed": "{{serverName}} authentication failed: {{reason}}" } } diff --git a/src/i18n/locales/es/mcp.json b/src/i18n/locales/es/mcp.json index 4ee7e9d66f4..6fff790b6d6 100644 --- a/src/i18n/locales/es/mcp.json +++ b/src/i18n/locales/es/mcp.json @@ -23,6 +23,8 @@ "already_refreshing": "Los servidores MCP ya se están actualizando.", "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." + "project_config_deleted": "Archivo de configuración MCP del proyecto eliminado. Todos los servidores MCP del proyecto han sido desconectados.", + "server_requires_auth": "{{serverName}} requiere autenticación. Completa el flujo de inicio de sesión en tu navegador.", + "server_auth_failed": "La autenticación de {{serverName}} falló: {{reason}}" } } diff --git a/src/i18n/locales/fr/mcp.json b/src/i18n/locales/fr/mcp.json index 162f9387345..d7eee922db1 100644 --- a/src/i18n/locales/fr/mcp.json +++ b/src/i18n/locales/fr/mcp.json @@ -23,6 +23,8 @@ "already_refreshing": "Les serveurs MCP sont déjà en cours de rafraîchissement.", "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." + "project_config_deleted": "Fichier de configuration MCP du projet supprimé. Tous les serveurs MCP du projet ont été déconnectés.", + "server_requires_auth": "{{serverName}} nécessite une authentification. Veuillez compléter le flux de connexion dans votre navigateur.", + "server_auth_failed": "L'authentification de {{serverName}} a échoué : {{reason}}" } } diff --git a/src/i18n/locales/hi/mcp.json b/src/i18n/locales/hi/mcp.json index 627aca205c6..b82505a75df 100644 --- a/src/i18n/locales/hi/mcp.json +++ b/src/i18n/locales/hi/mcp.json @@ -23,6 +23,8 @@ "already_refreshing": "एमसीपी सर्वर पहले से ही रीफ्रेश हो रहे हैं।", "refreshing_all": "सभी एमसीपी सर्वर रीफ्रेश हो रहे हैं...", "all_refreshed": "सभी एमसीपी सर्वर रीफ्रेश हो गए हैं।", - "project_config_deleted": "प्रोजेक्ट एमसीपी कॉन्फ़िगरेशन फ़ाइल हटा दी गई है। सभी प्रोजेक्ट एमसीपी सर्वर डिस्कनेक्ट कर दिए गए हैं।" + "project_config_deleted": "प्रोजेक्ट एमसीपी कॉन्फ़िगरेशन फ़ाइल हटा दी गई है। सभी प्रोजेक्ट एमसीपी सर्वर डिस्कनेक्ट कर दिए गए हैं।", + "server_requires_auth": "{{serverName}} को प्रमाणीकरण की आवश्यकता है। कृपया अपने ब्राउज़र में साइन-इन प्रवाह पूरा करें।", + "server_auth_failed": "{{serverName}} प्रमाणीकरण विफल: {{reason}}" } } diff --git a/src/i18n/locales/id/mcp.json b/src/i18n/locales/id/mcp.json index 60c09242440..d6843a72ee1 100644 --- a/src/i18n/locales/id/mcp.json +++ b/src/i18n/locales/id/mcp.json @@ -23,6 +23,8 @@ "already_refreshing": "Server MCP sudah sedang di-refresh.", "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." + "project_config_deleted": "File konfigurasi MCP proyek dihapus. Semua server MCP proyek telah diputus koneksinya.", + "server_requires_auth": "{{serverName}} memerlukan autentikasi. Silakan selesaikan alur masuk di browser kamu.", + "server_auth_failed": "Autentikasi {{serverName}} gagal: {{reason}}" } } diff --git a/src/i18n/locales/it/mcp.json b/src/i18n/locales/it/mcp.json index 2c890d10f74..6efe8c984b9 100644 --- a/src/i18n/locales/it/mcp.json +++ b/src/i18n/locales/it/mcp.json @@ -23,6 +23,8 @@ "already_refreshing": "I server MCP sono già in aggiornamento.", "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." + "project_config_deleted": "File di configurazione MCP del progetto eliminato. Tutti i server MCP del progetto sono stati disconnessi.", + "server_requires_auth": "{{serverName}} richiede l'autenticazione. Completa il flusso di accesso nel tuo browser.", + "server_auth_failed": "Autenticazione di {{serverName}} fallita: {{reason}}" } } diff --git a/src/i18n/locales/ja/mcp.json b/src/i18n/locales/ja/mcp.json index 87c4e0d8ad2..0f993be7659 100644 --- a/src/i18n/locales/ja/mcp.json +++ b/src/i18n/locales/ja/mcp.json @@ -23,6 +23,8 @@ "already_refreshing": "MCPサーバーはすでに更新中です。", "refreshing_all": "すべてのMCPサーバーを更新しています...", "all_refreshed": "すべてのMCPサーバーが更新されました。", - "project_config_deleted": "プロジェクトMCP設定ファイルが削除されました。すべてのプロジェクトMCPサーバーが切断されました。" + "project_config_deleted": "プロジェクトMCP設定ファイルが削除されました。すべてのプロジェクトMCPサーバーが切断されました。", + "server_requires_auth": "{{serverName}}は認証が必要です。ブラウザでサインインフローを完了してください。", + "server_auth_failed": "{{serverName}}の認証に失敗しました: {{reason}}" } } diff --git a/src/i18n/locales/ko/mcp.json b/src/i18n/locales/ko/mcp.json index 051ca82e838..b8b5bb369c5 100644 --- a/src/i18n/locales/ko/mcp.json +++ b/src/i18n/locales/ko/mcp.json @@ -23,6 +23,8 @@ "already_refreshing": "MCP 서버가 이미 새로 고쳐지고 있습니다.", "refreshing_all": "모든 MCP 서버를 새로 고치는 중...", "all_refreshed": "모든 MCP 서버가 새로 고쳐졌습니다.", - "project_config_deleted": "프로젝트 MCP 구성 파일이 삭제되었습니다. 모든 프로젝트 MCP 서버가 연결 해제되었습니다." + "project_config_deleted": "프로젝트 MCP 구성 파일이 삭제되었습니다. 모든 프로젝트 MCP 서버가 연결 해제되었습니다.", + "server_requires_auth": "{{serverName}}에 인증이 필요합니다. 브라우저에서 로그인 흐름을 완료해 주세요.", + "server_auth_failed": "{{serverName}} 인증 실패: {{reason}}" } } diff --git a/src/i18n/locales/nl/mcp.json b/src/i18n/locales/nl/mcp.json index 4599e87bdfc..e638f8f5f95 100644 --- a/src/i18n/locales/nl/mcp.json +++ b/src/i18n/locales/nl/mcp.json @@ -23,6 +23,8 @@ "already_refreshing": "MCP-servers worden al vernieuwd.", "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." + "project_config_deleted": "Project MCP-configuratiebestand verwijderd. Alle project MCP-servers zijn losgekoppeld.", + "server_requires_auth": "{{serverName}} vereist authenticatie. Voltooi het inlogproces in je browser.", + "server_auth_failed": "Authenticatie van {{serverName}} mislukt: {{reason}}" } } diff --git a/src/i18n/locales/pl/mcp.json b/src/i18n/locales/pl/mcp.json index a6536765143..96c0f9b88a3 100644 --- a/src/i18n/locales/pl/mcp.json +++ b/src/i18n/locales/pl/mcp.json @@ -23,6 +23,8 @@ "already_refreshing": "Serwery MCP są już odświeżane.", "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." + "project_config_deleted": "Plik konfiguracyjny MCP projektu został usunięty. Wszystkie serwery MCP projektu zostały odłączone.", + "server_requires_auth": "{{serverName}} wymaga uwierzytelnienia. Ukończ proces logowania w przeglądarce.", + "server_auth_failed": "Uwierzytelnienie {{serverName}} nie powiodło się: {{reason}}" } } diff --git a/src/i18n/locales/pt-BR/mcp.json b/src/i18n/locales/pt-BR/mcp.json index fbf22e7aa2d..251d6b284fe 100644 --- a/src/i18n/locales/pt-BR/mcp.json +++ b/src/i18n/locales/pt-BR/mcp.json @@ -23,6 +23,8 @@ "already_refreshing": "Os servidores MCP já estão atualizando.", "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." + "project_config_deleted": "Arquivo de configuração MCP do projeto excluído. Todos os servidores MCP do projeto foram desconectados.", + "server_requires_auth": "{{serverName}} requer autenticação. Complete o fluxo de login no seu navegador.", + "server_auth_failed": "Autenticação de {{serverName}} falhou: {{reason}}" } } diff --git a/src/i18n/locales/ru/mcp.json b/src/i18n/locales/ru/mcp.json index 581db3cd8db..686063d8799 100644 --- a/src/i18n/locales/ru/mcp.json +++ b/src/i18n/locales/ru/mcp.json @@ -23,6 +23,8 @@ "already_refreshing": "MCP серверы уже обновляются.", "refreshing_all": "Обновление всех MCP серверов...", "all_refreshed": "Все MCP серверы обновлены.", - "project_config_deleted": "Файл конфигурации MCP проекта удален. Все MCP серверы проекта отключены." + "project_config_deleted": "Файл конфигурации MCP проекта удален. Все MCP серверы проекта отключены.", + "server_requires_auth": "{{serverName}} требует аутентификации. Завершите процесс входа в браузере.", + "server_auth_failed": "Ошибка аутентификации {{serverName}}: {{reason}}" } } diff --git a/src/i18n/locales/tr/mcp.json b/src/i18n/locales/tr/mcp.json index 58e1bf1a740..b3ee060aeb2 100644 --- a/src/i18n/locales/tr/mcp.json +++ b/src/i18n/locales/tr/mcp.json @@ -23,6 +23,8 @@ "already_refreshing": "MCP sunucuları zaten yenileniyor.", "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." + "project_config_deleted": "Proje MCP yapılandırma dosyası silindi. Tüm proje MCP sunucuları bağlantısı kesildi.", + "server_requires_auth": "{{serverName}} kimlik doğrulaması gerektiriyor. Tarayıcında oturum açma akışını tamamla.", + "server_auth_failed": "{{serverName}} kimlik doğrulaması başarısız: {{reason}}" } } diff --git a/src/i18n/locales/vi/mcp.json b/src/i18n/locales/vi/mcp.json index 7e529b4874d..80b2a7fcbe9 100644 --- a/src/i18n/locales/vi/mcp.json +++ b/src/i18n/locales/vi/mcp.json @@ -23,6 +23,8 @@ "already_refreshing": "Các máy chủ MCP đã đang làm mới.", "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." + "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.", + "server_requires_auth": "{{serverName}} yêu cầu xác thực. Vui lòng hoàn tất quy trình đăng nhập trong trình duyệt của bạn.", + "server_auth_failed": "Xác thực {{serverName}} thất bại: {{reason}}" } } diff --git a/src/i18n/locales/zh-CN/mcp.json b/src/i18n/locales/zh-CN/mcp.json index 58e8fcec3d1..0d65717a849 100644 --- a/src/i18n/locales/zh-CN/mcp.json +++ b/src/i18n/locales/zh-CN/mcp.json @@ -23,6 +23,8 @@ "already_refreshing": "MCP 服务器已在刷新中。", "refreshing_all": "正在刷新所有 MCP 服务器...", "all_refreshed": "所有 MCP 服务器已刷新。", - "project_config_deleted": "项目MCP配置文件已删除。所有项目MCP服务器已断开连接。" + "project_config_deleted": "项目MCP配置文件已删除。所有项目MCP服务器已断开连接。", + "server_requires_auth": "{{serverName}}需要身份验证。请在浏览器中完成登录流程。", + "server_auth_failed": "{{serverName}}身份验证失败:{{reason}}" } } diff --git a/src/i18n/locales/zh-TW/mcp.json b/src/i18n/locales/zh-TW/mcp.json index 945f09fc0cc..b4230cd5565 100644 --- a/src/i18n/locales/zh-TW/mcp.json +++ b/src/i18n/locales/zh-TW/mcp.json @@ -23,6 +23,8 @@ "already_refreshing": "MCP 伺服器已在重新整理中。", "refreshing_all": "正在重新整理所有 MCP 伺服器...", "all_refreshed": "所有 MCP 伺服器已重新整理。", - "project_config_deleted": "專案MCP設定檔案已刪除。所有專案MCP伺服器已斷開連接。" + "project_config_deleted": "專案MCP設定檔案已刪除。所有專案MCP伺服器已斷開連接。", + "server_requires_auth": "{{serverName}}需要身分驗證。請在瀏覽器中完成登入流程。", + "server_auth_failed": "{{serverName}}身分驗證失敗:{{reason}}" } } diff --git a/src/services/mcp/McpHub.ts b/src/services/mcp/McpHub.ts index ea38ee02d6d..4aecba3fd1c 100644 --- a/src/services/mcp/McpHub.ts +++ b/src/services/mcp/McpHub.ts @@ -6,6 +6,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js" import { StdioClientTransport, getDefaultEnvironment } from "@modelcontextprotocol/sdk/client/stdio.js" import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js" import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" +import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" import ReconnectingEventSource from "reconnecting-eventsource" import { CallToolResultSchema, @@ -40,6 +41,8 @@ import { injectVariables } from "../../utils/config" import { safeWriteJson } from "../../utils/safeWriteJson" import { sanitizeMcpName, toolNamesMatch } from "../../utils/mcp-name" +import { McpOAuthProvider } from "./McpOAuthProvider" + // Discriminated union for connection states export type ConnectedMcpConnection = { type: "connected" @@ -703,6 +706,15 @@ export class McpHub { workspaceFolder: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? "", })) as typeof config + // Create OAuth provider for HTTP-based transports + let authProvider: McpOAuthProvider | undefined + if (configInjected.type !== "stdio") { + const provider = this.providerRef.deref() + if (provider) { + authProvider = new McpOAuthProvider(name, provider.context) + } + } + if (configInjected.type === "stdio") { // On Windows, wrap commands with cmd.exe to handle non-exe executables like npx.ps1 // This is necessary for node version managers (fnm, nvm-windows, volta) that implement @@ -784,6 +796,7 @@ export class McpHub { requestInit: { headers: configInjected.headers, }, + authProvider, }) // Set up Streamable HTTP specific error handling @@ -806,28 +819,39 @@ export class McpHub { } } else if (configInjected.type === "sse") { // SSE connection - const sseOptions = { - requestInit: { - headers: configInjected.headers, - }, - } - // Configure ReconnectingEventSource options - const reconnectingEventSourceOptions = { - max_retry_time: 5000, // Maximum retry time in milliseconds - withCredentials: configInjected.headers?.["Authorization"] ? true : false, // Enable credentials if Authorization header exists - fetch: (url: string | URL, init: RequestInit) => { - const headers = new Headers({ ...(init?.headers || {}), ...(configInjected.headers || {}) }) - return fetch(url, { - ...init, - headers, - }) - }, + if (authProvider) { + // OAuth handles Authorization; avoid eventSourceInit which would + // prevent automatic Authorization header attachment per SDK docs + transport = new SSEClientTransport(new URL(configInjected.url), { + requestInit: { + headers: configInjected.headers, + }, + authProvider, + }) + } else { + // Legacy path: manual headers + ReconnectingEventSource + const sseOptions = { + requestInit: { + headers: configInjected.headers, + }, + } + const reconnectingEventSourceOptions = { + max_retry_time: 5000, // Maximum retry time in milliseconds + withCredentials: configInjected.headers?.["Authorization"] ? true : false, // Enable credentials if Authorization header exists + fetch: (url: string | URL, init: RequestInit) => { + const headers = new Headers({ ...(init?.headers || {}), ...(configInjected.headers || {}) }) + return fetch(url, { + ...init, + headers, + }) + }, + } + global.EventSource = ReconnectingEventSource + transport = new SSEClientTransport(new URL(configInjected.url), { + ...sseOptions, + eventSourceInit: reconnectingEventSourceOptions, + }) } - global.EventSource = ReconnectingEventSource - transport = new SSEClientTransport(new URL(configInjected.url), { - ...sseOptions, - eventSourceInit: reconnectingEventSourceOptions, - }) // Set up SSE specific error handling transport.onerror = async (error) => { @@ -875,8 +899,69 @@ export class McpHub { this.connections.push(connection) // Connect (this will automatically start the transport) - await client.connect(transport) + try { + await client.connect(transport) + } catch (connectError) { + // Handle OAuth UnauthorizedError — the SDK has initiated the auth flow + // and opened the browser. We need to wait for the callback and retry. + if (connectError instanceof UnauthorizedError && authProvider) { + const connection = this.findConnection(name, source) + if (connection) { + connection.server.status = "connecting" + connection.server.authStatus = "awaiting_auth" + await this.notifyWebviewOfServerChanges() + } + + vscode.window.showInformationMessage(t("mcp:info.server_requires_auth", { serverName: name })) + + // Fire-and-forget: wait for callback, then complete connection + authProvider + .waitForAuthCode() + .then(async (authCode) => { + await (transport as StreamableHTTPClientTransport | SSEClientTransport).finishAuth(authCode) + await client.connect(transport) + if (connection) { + connection.server.status = "connected" + connection.server.authStatus = "authenticated" + connection.server.error = "" + connection.server.instructions = client.getInstructions() + connection.server.tools = await this.fetchToolsList(name, source) + connection.server.resources = await this.fetchResourcesList(name, source) + connection.server.resourceTemplates = await this.fetchResourceTemplatesList( + name, + source, + ) + } + await this.notifyWebviewOfServerChanges() + vscode.window.showInformationMessage(t("mcp:info.server_connected", { serverName: name })) + }) + .catch(async (authError) => { + if (connection) { + connection.server.status = "disconnected" + connection.server.authStatus = "unauthenticated" + this.appendErrorMessage( + connection, + authError instanceof Error ? authError.message : `${authError}`, + ) + } + await this.notifyWebviewOfServerChanges() + vscode.window.showErrorMessage( + t("mcp:info.server_auth_failed", { + serverName: name, + reason: authError instanceof Error ? authError.message : `${authError}`, + }), + ) + }) + + return // Don't throw — auth is in progress + } + + // Re-throw non-OAuth errors to be caught by the outer catch + throw connectError + } + connection.server.status = "connected" + connection.server.authStatus = authProvider ? "authenticated" : undefined connection.server.error = "" connection.server.instructions = client.getInstructions() @@ -977,6 +1062,15 @@ export class McpHub { return null } + /** + * Handles an OAuth callback from the VS Code URI handler. + * This is a fallback for auth servers that redirect via vscode:// URIs + * instead of the localhost callback server. + */ + handleOAuthCallback(code: string, state: string): boolean { + return McpOAuthProvider.resolveFromUriCallback(state, code) + } + private async fetchToolsList(serverName: string, source?: "global" | "project"): Promise { try { // Use the helper method to find the connection diff --git a/src/services/mcp/McpOAuthProvider.ts b/src/services/mcp/McpOAuthProvider.ts new file mode 100644 index 00000000000..d066817712a --- /dev/null +++ b/src/services/mcp/McpOAuthProvider.ts @@ -0,0 +1,508 @@ +import * as http from "http" + +import * as vscode from "vscode" + +import type { OAuthClientProvider, OAuthDiscoveryState } from "@modelcontextprotocol/sdk/client/auth.js" +import type { + OAuthClientInformationMixed, + OAuthClientMetadata, + OAuthTokens, +} from "@modelcontextprotocol/sdk/shared/auth.js" + +import { sanitizeMcpName } from "../../utils/mcp-name" + +/** + * SecretStorage key prefixes for per-server OAuth data. + */ +const STORAGE_KEY_PREFIXES = { + tokens: "mcp-oauth-tokens", + client: "mcp-oauth-client", + verifier: "mcp-oauth-verifier", + discovery: "mcp-oauth-discovery", +} as const + +/** + * HTML page shown in the browser after successful authentication. + * Matches the pattern from OpenAI Codex OAuth callback. + */ +const AUTH_SUCCESS_HTML = ` + + + +Authentication Successful + + + +
+

✓ Authentication Successful

+

You can close this window and return to VS Code.

+
+ + +` + +/** + * HTML page shown in the browser when authentication fails. + */ +const AUTH_FAILURE_HTML = ` + + + +Authentication Failed + + + +
+

✗ Authentication Failed

+

You can close this window and try again from VS Code.

+
+ + +` + +/** 5-minute timeout for the OAuth callback, matching the Codex pattern */ +const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 + +/** + * McpOAuthProvider implements the OAuthClientProvider interface from + * @modelcontextprotocol/sdk, adapting it to VS Code's SecretStorage, + * browser launching via vscode.env.openExternal, and a localhost callback + * server for the authorization code flow. + * + * Each instance is scoped to a single MCP server, identified by serverName. + * Token storage uses per-server keys in VS Code SecretStorage for isolation. + * + * The OAuth flow is orchestrated by the SDK — this provider only handles + * VS Code-specific concerns (storage, browser, callback). + */ +export class McpOAuthProvider implements OAuthClientProvider { + /** + * Static registry mapping OAuth state → provider instance. + * Used by the VS Code URI handler fallback to route callbacks + * when the auth server redirects via vscode:// URIs instead of localhost. + */ + private static readonly pendingByState = new Map() + + private readonly serverName: string + private readonly sanitizedName: string + private readonly context: vscode.ExtensionContext + private readonly _redirectUrl: string + private callbackServer?: http.Server + private callbackTimeout?: ReturnType + + /** Promise resolved when the callback server receives the authorization code */ + private authCodeResolve?: (code: string) => void + private authCodeReject?: (error: Error) => void + private authCodePromise?: Promise + + constructor(serverName: string, context: vscode.ExtensionContext) { + this.serverName = serverName + this.sanitizedName = sanitizeMcpName(serverName) + this.context = context + + // Allocate a free port for the localhost callback server. + // The SDK reads redirectUrl before calling redirectToAuthorization, + // so we need the port eagerly in the constructor. + this._redirectUrl = this.allocatePort() + } + + // ─── Required OAuthClientProvider members ────────────────────────── + + get redirectUrl(): string | URL | undefined { + return this._redirectUrl + } + + get clientMetadata(): OAuthClientMetadata { + return { + client_name: "Roo Code", + redirect_uris: [this._redirectUrl], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", // public client — PKCE provides protection + scope: "mcp", + } + } + + async clientInformation(): Promise { + const key = `${STORAGE_KEY_PREFIXES.client}:${this.sanitizedName}` + const stored = await this.context.secrets.get(key) + if (!stored) return undefined + try { + return JSON.parse(stored) as OAuthClientInformationMixed + } catch { + return undefined + } + } + + async saveClientInformation(clientInformation: OAuthClientInformationMixed): Promise { + const key = `${STORAGE_KEY_PREFIXES.client}:${this.sanitizedName}` + await this.context.secrets.store(key, JSON.stringify(clientInformation)) + } + + async tokens(): Promise { + const key = `${STORAGE_KEY_PREFIXES.tokens}:${this.sanitizedName}` + const stored = await this.context.secrets.get(key) + if (!stored) return undefined + try { + return JSON.parse(stored) as OAuthTokens + } catch { + return undefined + } + } + + async saveTokens(tokens: OAuthTokens): Promise { + const key = `${STORAGE_KEY_PREFIXES.tokens}:${this.sanitizedName}` + await this.context.secrets.store(key, JSON.stringify(tokens)) + } + + async redirectToAuthorization(authorizationUrl: URL): Promise { + // Create the promise that will be resolved when the callback server + // receives the authorization code + this.authCodePromise = new Promise((resolve, reject) => { + this.authCodeResolve = resolve + this.authCodeReject = reject + }) + + // Register in the static map so the URI handler fallback can route + // callbacks when auth servers redirect via vscode:// URIs. + // The SDK includes a `state` query parameter on the authorization URL. + const state = authorizationUrl.searchParams.get("state") + if (state) { + McpOAuthProvider.pendingByState.set(state, this) + } + + // Start the localhost callback server + await this.startCallbackServer() + + // Open the authorization URL in the user's default browser + await vscode.env.openExternal(vscode.Uri.parse(authorizationUrl.toString())) + } + + async saveCodeVerifier(codeVerifier: string): Promise { + const key = `${STORAGE_KEY_PREFIXES.verifier}:${this.sanitizedName}` + await this.context.secrets.store(key, codeVerifier) + } + + async codeVerifier(): Promise { + const key = `${STORAGE_KEY_PREFIXES.verifier}:${this.sanitizedName}` + const stored = await this.context.secrets.get(key) + if (!stored) { + throw new Error("No PKCE code verifier found for MCP OAuth flow") + } + return stored + } + + // ─── Optional OAuthClientProvider members ────────────────────────── + + async invalidateCredentials(scope: "all" | "client" | "tokens" | "verifier" | "discovery"): Promise { + const keysToDelete: string[] = [] + + switch (scope) { + case "all": + keysToDelete.push( + `${STORAGE_KEY_PREFIXES.tokens}:${this.sanitizedName}`, + `${STORAGE_KEY_PREFIXES.client}:${this.sanitizedName}`, + `${STORAGE_KEY_PREFIXES.verifier}:${this.sanitizedName}`, + `${STORAGE_KEY_PREFIXES.discovery}:${this.sanitizedName}`, + ) + break + case "tokens": + keysToDelete.push(`${STORAGE_KEY_PREFIXES.tokens}:${this.sanitizedName}`) + break + case "client": + keysToDelete.push(`${STORAGE_KEY_PREFIXES.client}:${this.sanitizedName}`) + break + case "verifier": + keysToDelete.push(`${STORAGE_KEY_PREFIXES.verifier}:${this.sanitizedName}`) + break + case "discovery": + keysToDelete.push(`${STORAGE_KEY_PREFIXES.discovery}:${this.sanitizedName}`) + break + } + + await Promise.all(keysToDelete.map((key) => this.context.secrets.delete(key))) + } + + async saveDiscoveryState(state: OAuthDiscoveryState): Promise { + const key = `${STORAGE_KEY_PREFIXES.discovery}:${this.sanitizedName}` + await this.context.secrets.store(key, JSON.stringify(state)) + } + + async discoveryState(): Promise { + const key = `${STORAGE_KEY_PREFIXES.discovery}:${this.sanitizedName}` + const stored = await this.context.secrets.get(key) + if (!stored) return undefined + try { + return JSON.parse(stored) as OAuthDiscoveryState + } catch { + return undefined + } + } + + // ─── Custom methods for McpHub integration ───────────────────────── + + /** + * Returns a promise that resolves with the authorization code once + * the user completes the OAuth flow in their browser. + * + * McpHub calls this after catching UnauthorizedError to wait for + * the callback in a non-blocking fashion. + */ + waitForAuthCode(): Promise { + if (!this.authCodePromise) { + return Promise.reject(new Error("No pending OAuth callback — redirectToAuthorization not yet called")) + } + return this.authCodePromise + } + + /** + * Cancels any pending OAuth flow and cleans up resources. + */ + cancelAuthFlow(): void { + if (this.authCodeReject) { + this.authCodeReject(new Error("OAuth flow cancelled")) + } + this.authCodeResolve = undefined + this.authCodeReject = undefined + this.authCodePromise = undefined + this.closeCallbackServer() + } + + /** + * Returns the server name this provider is scoped to. + */ + getServerName(): string { + return this.serverName + } + + /** + * Resolves a pending OAuth flow from the VS Code URI handler. + * Called when an auth server redirects via vscode:// URIs instead of + * the localhost callback server. + * + * @param state The `state` parameter from the callback URI + * @param code The authorization code from the callback URI + * @returns true if a pending provider was found and resolved + */ + static resolveFromUriCallback(state: string, code: string): boolean { + const provider = McpOAuthProvider.pendingByState.get(state) + if (!provider) return false + + provider.authCodeResolve?.(code) + provider.authCodeResolve = undefined + provider.authCodeReject = undefined + provider.authCodePromise = undefined + McpOAuthProvider.pendingByState.delete(state) + provider.closeCallbackServer() + return true + } + + // ─── Private helpers ─────────────────────────────────────────────── + + /** + * Allocates a free port by starting and immediately closing a server. + * Returns the localhost callback URL with the allocated port. + * + * There is a small race window between close and re-listen, but it's + * negligible in practice. The alternative (keeping the server running) + * is wasteful since most connections don't need OAuth. + */ + private allocatePort(): string { + const server = http.createServer() + server.listen(0, "127.0.0.1") + + const address = server.address() + const port = address && typeof address === "object" ? address.port : 0 + + server.close() + + if (!port) { + // Fallback: use a random port in the 14000-15000 range + const fallbackPort = 14000 + Math.floor(Math.random() * 1000) + return `http://localhost:${fallbackPort}/callback` + } + + return `http://localhost:${port}/callback` + } + + /** + * Starts the localhost HTTP callback server to receive the authorization + * code from the OAuth flow. + */ + private startCallbackServer(): Promise { + return new Promise((resolve, reject) => { + // Close any existing callback server + this.closeCallbackServer() + + const port = this.extractPort() + + this.callbackServer = http.createServer(async (req, res) => { + try { + const url = new URL(req.url || "/", `http://localhost:${port}`) + + if (url.pathname !== "/callback") { + res.writeHead(404) + res.end("Not Found") + return + } + + const code = url.searchParams.get("code") + const error = url.searchParams.get("error") + + if (error) { + const errorDesc = url.searchParams.get("error_description") || error + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }) + res.end(AUTH_FAILURE_HTML) + + if (this.authCodeReject) { + this.authCodeReject(new Error(`OAuth error: ${error} — ${errorDesc}`)) + this.authCodeResolve = undefined + this.authCodeReject = undefined + this.authCodePromise = undefined + } + this.closeCallbackServer() + return + } + + if (!code) { + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }) + res.end(AUTH_FAILURE_HTML) + + if (this.authCodeReject) { + this.authCodeReject(new Error("Missing authorization code in OAuth callback")) + this.authCodeResolve = undefined + this.authCodeReject = undefined + this.authCodePromise = undefined + } + this.closeCallbackServer() + return + } + + // Success — resolve the pending auth code promise + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) + res.end(AUTH_SUCCESS_HTML) + + if (this.authCodeResolve) { + this.authCodeResolve(code) + this.authCodeResolve = undefined + this.authCodeReject = undefined + this.authCodePromise = undefined + } + + // Clear the code verifier after successful code reception + await this.invalidateCredentials("verifier") + + this.closeCallbackServer() + } catch (err) { + res.writeHead(500, { "Content-Type": "text/html; charset=utf-8" }) + res.end(AUTH_FAILURE_HTML) + + if (this.authCodeReject) { + this.authCodeReject(err instanceof Error ? err : new Error(`${err}`)) + this.authCodeResolve = undefined + this.authCodeReject = undefined + this.authCodePromise = undefined + } + this.closeCallbackServer() + } + }) + + this.callbackServer.on("error", (err: NodeJS.ErrnoException) => { + if (this.authCodeReject) { + this.authCodeReject( + new Error( + err.code === "EADDRINUSE" + ? `OAuth callback port ${port} is already in use` + : `OAuth callback server error: ${err.message}`, + ), + ) + this.authCodeResolve = undefined + this.authCodeReject = undefined + this.authCodePromise = undefined + } + reject(err) + }) + + this.callbackServer.listen(port, "127.0.0.1", () => { + // Set a timeout for the callback + this.callbackTimeout = setTimeout(() => { + if (this.authCodeReject) { + this.authCodeReject(new Error("OAuth authentication timed out")) + this.authCodeResolve = undefined + this.authCodeReject = undefined + this.authCodePromise = undefined + } + this.closeCallbackServer() + }, CALLBACK_TIMEOUT_MS) + + resolve() + }) + }) + } + + /** + * Closes the localhost callback server and clears the timeout. + */ + private closeCallbackServer(): void { + if (this.callbackTimeout) { + clearTimeout(this.callbackTimeout) + this.callbackTimeout = undefined + } + + if (this.callbackServer) { + try { + this.callbackServer.close() + } catch { + // Ignore errors when closing + } + this.callbackServer = undefined + } + + // Remove from static URI handler registry + for (const [key, provider] of McpOAuthProvider.pendingByState) { + if (provider === this) { + McpOAuthProvider.pendingByState.delete(key) + } + } + } + + /** + * Extracts the port number from the redirect URL. + */ + private extractPort(): number { + try { + const url = new URL(this._redirectUrl) + return parseInt(url.port, 10) || 14000 + } catch { + return 14000 + Math.floor(Math.random() * 1000) + } + } +} diff --git a/src/services/mcp/__tests__/McpOAuthProvider.spec.ts b/src/services/mcp/__tests__/McpOAuthProvider.spec.ts new file mode 100644 index 00000000000..dec66cd74c9 --- /dev/null +++ b/src/services/mcp/__tests__/McpOAuthProvider.spec.ts @@ -0,0 +1,518 @@ +import type { ExtensionContext } from "vscode" + +import { McpOAuthProvider } from "../McpOAuthProvider" + +// ─── Hoisted mock values (accessible inside vi.mock factories) ──────── + +const { openExternalMock, mockServerInstance } = vi.hoisted(() => { + const openExternalMock = vi.fn().mockResolvedValue(true) + const mockServerInstance = { + listen: vi.fn().mockImplementation((_port?: number, _host?: string, cb?: () => void) => { + if (cb) cb() + }), + close: vi.fn(), + address: vi.fn().mockReturnValue({ port: 45000, family: "IPv4", address: "127.0.0.1" }), + on: vi.fn(), + } + return { openExternalMock, mockServerInstance } +}) + +// ─── Mock http module (prevent actual port binding) ─────────────────── + +vi.mock("http", () => ({ + createServer: vi.fn().mockReturnValue(mockServerInstance), +})) + +// ─── Mock vscode module ─────────────────────────────────────────────── + +vi.mock("vscode", () => ({ + env: { openExternal: openExternalMock }, + Uri: { + parse: (s: string) => ({ toString: () => s }), + }, +})) + +// ─── In-memory SecretStorage mock ───────────────────────────────────── + +function createMockSecretStorage() { + const backingStore = new Map() + return { + backingStore, + get: vi.fn((key: string) => Promise.resolve(backingStore.get(key))), + store: vi.fn((key: string, value: string) => { + backingStore.set(key, value) + return Promise.resolve() + }), + delete: vi.fn((key: string) => { + backingStore.delete(key) + return Promise.resolve() + }), + onDidChange: vi.fn(), + } +} + +// ─── Helpers ────────────────────────────────────────────────────────── + +function createMockContext() { + const secrets = createMockSecretStorage() + return { + secrets, + subscriptions: [], + } as unknown as ExtensionContext +} + +/** + * Helper: get the static pendingByState map from McpOAuthProvider. + */ +function getPendingMap(): Map { + return (McpOAuthProvider as any).pendingByState as Map +} + +describe("McpOAuthProvider", () => { + let provider: McpOAuthProvider + let mockContext: ReturnType + let secrets: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + + // Reset the static pendingByState map between tests + getPendingMap().clear() + + mockContext = createMockContext() + secrets = mockContext.secrets as any + provider = new McpOAuthProvider("test-server", mockContext) + }) + + afterEach(() => { + // Clean up any lingering callback servers and timeouts. + // Catch any rejected promise to avoid unhandled rejection errors. + try { + const promise = (provider as any).authCodePromise as Promise | undefined + provider.cancelAuthFlow() + if (promise) { + promise.catch(() => { + /* swallow expected rejection */ + }) + } + } catch { + /* already cleaned up */ + } + }) + + // ──────────────────────────────────────────────────────────────────── + // Token management + // ──────────────────────────────────────────────────────────────────── + + describe("tokens()", () => { + it("returns undefined when no tokens are stored", async () => { + const result = await provider.tokens() + expect(result).toBeUndefined() + }) + + it("stores and retrieves tokens with correct key format", async () => { + const tokens = { access_token: "abc123", token_type: "bearer", scope: "mcp" } + await provider.saveTokens(tokens as any) + + expect(secrets.store).toHaveBeenCalledWith("mcp-oauth-tokens:test-server", JSON.stringify(tokens)) + + const result = await provider.tokens() + expect(result).toEqual(tokens) + }) + + it("returns undefined for malformed JSON", async () => { + secrets.backingStore.set("mcp-oauth-tokens:test-server", "not-json") + const result = await provider.tokens() + expect(result).toBeUndefined() + }) + }) + + // ──────────────────────────────────────────────────────────────────── + // Client information + // ──────────────────────────────────────────────────────────────────── + + describe("clientInformation()", () => { + it("returns undefined when no client info is stored", async () => { + const result = await provider.clientInformation() + expect(result).toBeUndefined() + }) + + it("stores and retrieves client information with correct key format", async () => { + const clientInfo = { client_id: "cid-123", client_secret: null } + await provider.saveClientInformation(clientInfo as any) + + expect(secrets.store).toHaveBeenCalledWith("mcp-oauth-client:test-server", JSON.stringify(clientInfo)) + + const result = await provider.clientInformation() + expect(result).toEqual(clientInfo) + }) + + it("returns undefined for malformed JSON", async () => { + secrets.backingStore.set("mcp-oauth-client:test-server", "{invalid") + const result = await provider.clientInformation() + expect(result).toBeUndefined() + }) + }) + + // ──────────────────────────────────────────────────────────────────── + // Code verifier + // ──────────────────────────────────────────────────────────────────── + + describe("codeVerifier()", () => { + it("throws when no code verifier is stored", async () => { + await expect(provider.codeVerifier()).rejects.toThrow("No PKCE code verifier found for MCP OAuth flow") + }) + + it("stores and retrieves code verifier with correct key format", async () => { + await provider.saveCodeVerifier("my-verifier-123") + + expect(secrets.store).toHaveBeenCalledWith("mcp-oauth-verifier:test-server", "my-verifier-123") + + const result = await provider.codeVerifier() + expect(result).toBe("my-verifier-123") + }) + }) + + // ──────────────────────────────────────────────────────────────────── + // Discovery state + // ──────────────────────────────────────────────────────────────────── + + describe("discoveryState()", () => { + it("returns undefined when no discovery state is stored", async () => { + const result = await provider.discoveryState() + expect(result).toBeUndefined() + }) + + it("stores and retrieves discovery state with correct key format", async () => { + const state = { + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/authorize", + } + await provider.saveDiscoveryState(state as any) + + expect(secrets.store).toHaveBeenCalledWith("mcp-oauth-discovery:test-server", JSON.stringify(state)) + + const result = await provider.discoveryState() + expect(result).toEqual(state) + }) + + it("returns undefined for malformed JSON", async () => { + secrets.backingStore.set("mcp-oauth-discovery:test-server", "bad-json") + const result = await provider.discoveryState() + expect(result).toBeUndefined() + }) + }) + + // ──────────────────────────────────────────────────────────────────── + // redirectUrl + // ──────────────────────────────────────────────────────────────────── + + describe("redirectUrl", () => { + it("returns a localhost URL with /callback path", () => { + const url = provider.redirectUrl + expect(url).toBeDefined() + const urlStr = (url ?? "").toString() + expect(urlStr).toMatch(/^http:\/\/localhost:\d+\/callback$/) + }) + }) + + // ──────────────────────────────────────────────────────────────────── + // clientMetadata + // ──────────────────────────────────────────────────────────────────── + + describe("clientMetadata", () => { + it("has correct grant_types for PKCE public client", () => { + const meta = provider.clientMetadata + expect(meta.grant_types).toEqual(["authorization_code", "refresh_token"]) + expect(meta.response_types).toEqual(["code"]) + expect(meta.token_endpoint_auth_method).toBe("none") + expect(meta.client_name).toBe("Roo Code") + expect(meta.scope).toBe("mcp") + }) + + it("includes redirect_uris matching redirectUrl", () => { + const meta = provider.clientMetadata + expect(meta.redirect_uris).toContain(provider.redirectUrl) + }) + }) + + // ──────────────────────────────────────────────────────────────────── + // redirectToAuthorization + // ──────────────────────────────────────────────────────────────────── + + describe("redirectToAuthorization()", () => { + it("calls openExternal with the authorization URL", async () => { + const authUrl = new URL("https://auth.example.com/authorize?state=abc123&client_id=test") + + await provider.redirectToAuthorization(authUrl) + + expect(openExternalMock).toHaveBeenCalledTimes(1) + }) + + it("creates authCodePromise so waitForAuthCode does not reject", async () => { + const authUrl = new URL("https://auth.example.com/authorize?state=abc123&client_id=test") + + await provider.redirectToAuthorization(authUrl) + + // waitForAuthCode should return a promise (not reject immediately) + const promise = provider.waitForAuthCode() + expect(promise).toBeInstanceOf(Promise) + // Prevent unhandled rejection — clean up + provider.cancelAuthFlow() + promise.catch(() => {}) + }) + + it("handles authorization URL without state parameter without error", async () => { + const authUrl = new URL("https://auth.example.com/authorize?client_id=test") + + await expect(provider.redirectToAuthorization(authUrl)).resolves.toBeUndefined() + }) + }) + + // ──────────────────────────────────────────────────────────────────── + // invalidateCredentials + // ──────────────────────────────────────────────────────────────────── + + describe("invalidateCredentials()", () => { + it('deletes only tokens for scope "tokens"', async () => { + await provider.saveTokens({ access_token: "t" } as any) + await provider.saveClientInformation({ client_id: "c" } as any) + await provider.saveCodeVerifier("v") + await provider.saveDiscoveryState({ issuer: "i" } as any) + + await provider.invalidateCredentials("tokens") + + expect(await provider.tokens()).toBeUndefined() + expect(await provider.clientInformation()).toBeDefined() + await expect(provider.codeVerifier()).resolves.toBe("v") + expect(await provider.discoveryState()).toBeDefined() + }) + + it('deletes only client info for scope "client"', async () => { + await provider.saveTokens({ access_token: "t" } as any) + await provider.saveClientInformation({ client_id: "c" } as any) + await provider.saveCodeVerifier("v") + await provider.saveDiscoveryState({ issuer: "i" } as any) + + await provider.invalidateCredentials("client") + + expect(await provider.clientInformation()).toBeUndefined() + expect(await provider.tokens()).toBeDefined() + await expect(provider.codeVerifier()).resolves.toBe("v") + expect(await provider.discoveryState()).toBeDefined() + }) + + it('deletes only verifier for scope "verifier"', async () => { + await provider.saveTokens({ access_token: "t" } as any) + await provider.saveClientInformation({ client_id: "c" } as any) + await provider.saveCodeVerifier("v") + await provider.saveDiscoveryState({ issuer: "i" } as any) + + await provider.invalidateCredentials("verifier") + + await expect(provider.codeVerifier()).rejects.toThrow() + expect(await provider.tokens()).toBeDefined() + expect(await provider.clientInformation()).toBeDefined() + expect(await provider.discoveryState()).toBeDefined() + }) + + it('deletes only discovery state for scope "discovery"', async () => { + await provider.saveTokens({ access_token: "t" } as any) + await provider.saveClientInformation({ client_id: "c" } as any) + await provider.saveCodeVerifier("v") + await provider.saveDiscoveryState({ issuer: "i" } as any) + + await provider.invalidateCredentials("discovery") + + expect(await provider.discoveryState()).toBeUndefined() + expect(await provider.tokens()).toBeDefined() + expect(await provider.clientInformation()).toBeDefined() + await expect(provider.codeVerifier()).resolves.toBe("v") + }) + + it('deletes all keys for scope "all"', async () => { + await provider.saveTokens({ access_token: "t" } as any) + await provider.saveClientInformation({ client_id: "c" } as any) + await provider.saveCodeVerifier("v") + await provider.saveDiscoveryState({ issuer: "i" } as any) + + await provider.invalidateCredentials("all") + + expect(await provider.tokens()).toBeUndefined() + expect(await provider.clientInformation()).toBeUndefined() + await expect(provider.codeVerifier()).rejects.toThrow() + expect(await provider.discoveryState()).toBeUndefined() + }) + }) + + // ──────────────────────────────────────────────────────────────────── + // waitForAuthCode + // ──────────────────────────────────────────────────────────────────── + + describe("waitForAuthCode()", () => { + it("rejects when no pending OAuth callback exists", async () => { + await expect(provider.waitForAuthCode()).rejects.toThrow("No pending OAuth callback") + }) + + it("resolves when authCodeResolve is called (simulating callback)", async () => { + const authUrl = new URL("https://auth.example.com/authorize?state=s1&client_id=test") + await provider.redirectToAuthorization(authUrl) + + const authCodePromise = provider.waitForAuthCode() + + // Directly resolve via the internal authCodeResolve, + // simulating what resolveFromUriCallback or the callback server does. + const resolve = (provider as any).authCodeResolve as ((code: string) => void) | undefined + expect(resolve).toBeDefined() + resolve!("auth-code-xyz") + + await expect(authCodePromise).resolves.toBe("auth-code-xyz") + }) + }) + + // ──────────────────────────────────────────────────────────────────── + // cancelAuthFlow + // ──────────────────────────────────────────────────────────────────── + + describe("cancelAuthFlow()", () => { + it("rejects pending promise and cleans up", async () => { + const authUrl = new URL("https://auth.example.com/authorize?state=s2&client_id=test") + await provider.redirectToAuthorization(authUrl) + + const authCodePromise = provider.waitForAuthCode() + + provider.cancelAuthFlow() + + await expect(authCodePromise).rejects.toThrow("OAuth flow cancelled") + }) + + it("clears internal promise references", async () => { + const authUrl = new URL("https://auth.example.com/authorize?state=s2b&client_id=test") + await provider.redirectToAuthorization(authUrl) + + // Capture the promise before cancellation so we can swallow the rejection + const pendingPromise = provider.waitForAuthCode() + + provider.cancelAuthFlow() + + await expect(pendingPromise).rejects.toThrow("OAuth flow cancelled") + + expect((provider as any).authCodePromise).toBeUndefined() + expect((provider as any).authCodeResolve).toBeUndefined() + expect((provider as any).authCodeReject).toBeUndefined() + }) + + it("does nothing when there is no pending flow", () => { + // Should not throw + provider.cancelAuthFlow() + }) + }) + + // ──────────────────────────────────────────────────────────────────── + // Static registry — pendingByState and resolveFromUriCallback + // + // NOTE: closeCallbackServer() removes entries from pendingByState. + // redirectToAuthorization -> startCallbackServer -> closeCallbackServer + // cleans up the map, so we manually populate it for these tests. + // ──────────────────────────────────────────────────────────────────── + + describe("resolveFromUriCallback()", () => { + it("returns false when no pending provider matches the state", () => { + const result = McpOAuthProvider.resolveFromUriCallback("nonexistent", "code123") + expect(result).toBe(false) + }) + + it("returns true and resolves the pending auth code promise", async () => { + // Set up the provider with a pending auth code promise + const authUrl = new URL("https://auth.example.com/authorize?state=mystate&client_id=test") + await provider.redirectToAuthorization(authUrl) + + const authCodePromise = provider.waitForAuthCode() + + // Re-register in the pendingByState map (closeCallbackServer removed it) + getPendingMap().set("mystate", provider) + + const result = McpOAuthProvider.resolveFromUriCallback("mystate", "code-abc") + expect(result).toBe(true) + + await expect(authCodePromise).resolves.toBe("code-abc") + }) + + it("removes the entry from pendingByState after resolution", async () => { + const authUrl = new URL("https://auth.example.com/authorize?state=rmState&client_id=test") + await provider.redirectToAuthorization(authUrl) + + // Manually re-add to pendingByState + getPendingMap().set("rmState", provider) + + expect(getPendingMap().has("rmState")).toBe(true) + + McpOAuthProvider.resolveFromUriCallback("rmState", "code-xyz") + + expect(getPendingMap().has("rmState")).toBe(false) + }) + + it("does not resolve the same provider twice", async () => { + const authUrl = new URL("https://auth.example.com/authorize?state=dbState&client_id=test") + await provider.redirectToAuthorization(authUrl) + + const authCodePromise = provider.waitForAuthCode() + + // Manually re-add to pendingByState + getPendingMap().set("dbState", provider) + + // First resolution succeeds + McpOAuthProvider.resolveFromUriCallback("dbState", "code-1") + await expect(authCodePromise).resolves.toBe("code-1") + + // Second resolution returns false because entry was removed + const result = McpOAuthProvider.resolveFromUriCallback("dbState", "code-2") + expect(result).toBe(false) + }) + }) + + // ──────────────────────────────────────────────────────────────────── + // Name sanitization — special characters in server names + // ──────────────────────────────────────────────────────────────────── + + describe("name sanitization", () => { + it("produces valid storage keys for server names with spaces", () => { + const specialProvider = new McpOAuthProvider("my server name", mockContext) + specialProvider.saveTokens({ access_token: "t" } as any) + + expect(secrets.store).toHaveBeenCalledWith("mcp-oauth-tokens:my_server_name", expect.any(String)) + + specialProvider.cancelAuthFlow() + }) + + it("produces valid storage keys for server names with special characters", () => { + const specialProvider = new McpOAuthProvider("server@#$%!name", mockContext) + specialProvider.saveTokens({ access_token: "t" } as any) + + // sanitizeMcpName strips non-alphanumeric/underscore/hyphen chars + expect(secrets.store).toHaveBeenCalledWith("mcp-oauth-tokens:servername", expect.any(String)) + + specialProvider.cancelAuthFlow() + }) + + it("produces valid storage keys for server names starting with a digit", () => { + const specialProvider = new McpOAuthProvider("123server", mockContext) + specialProvider.saveTokens({ access_token: "t" } as any) + + // sanitizeMcpName prepends underscore when name starts with a digit + expect(secrets.store).toHaveBeenCalledWith("mcp-oauth-tokens:_123server", expect.any(String)) + + specialProvider.cancelAuthFlow() + }) + }) + + // ──────────────────────────────────────────────────────────────────── + // getServerName + // ──────────────────────────────────────────────────────────────────── + + describe("getServerName()", () => { + it("returns the original server name", () => { + expect(provider.getServerName()).toBe("test-server") + }) + }) +}) diff --git a/webview-ui/src/components/mcp/McpView.tsx b/webview-ui/src/components/mcp/McpView.tsx index 75a9a1a3800..36adee25fa0 100644 --- a/webview-ui/src/components/mcp/McpView.tsx +++ b/webview-ui/src/components/mcp/McpView.tsx @@ -203,6 +203,11 @@ const ServerRow = ({ server, alwaysAllowMcp }: { server: McpServer; alwaysAllowM return "var(--vscode-descriptionForeground)" } + // Auth-related status takes precedence + if (server.authStatus === "unauthenticated") { + return "var(--vscode-editorWarning-foreground)" + } + switch (server.status) { case "connected": return "var(--vscode-testing-iconPassed)" @@ -282,6 +287,19 @@ const ServerRow = ({ server, alwaysAllowMcp }: { server: McpServer; alwaysAllowM {server.source} )} + {server.authStatus === "unauthenticated" && ( + + {t("mcp:serverStatus.authRequired")} + + )}
-
+ {server.authStatus === "awaiting_auth" ? ( + + ) : ( +
+ )}
- {server.status === "connecting" - ? t("mcp:serverStatus.retrying") - : t("mcp:serverStatus.retryConnection")} + {server.authStatus === "awaiting_auth" + ? t("mcp:serverStatus.authenticating") + : server.authStatus === "unauthenticated" + ? t("mcp:serverStatus.signIn") + : server.status === "connecting" + ? t("mcp:serverStatus.retrying") + : t("mcp:serverStatus.retryConnection")}
)} diff --git a/webview-ui/src/i18n/locales/ca/mcp.json b/webview-ui/src/i18n/locales/ca/mcp.json index 654467b4435..429cd2ff8cb 100644 --- a/webview-ui/src/i18n/locales/ca/mcp.json +++ b/webview-ui/src/i18n/locales/ca/mcp.json @@ -54,7 +54,10 @@ }, "serverStatus": { "retrying": "Tornant a intentar...", - "retryConnection": "Torna a intentar la connexió" + "retryConnection": "Torna a intentar la connexió", + "signIn": "Inicia sessió", + "authenticating": "Autenticant...", + "authRequired": "Autenticació necessària" }, "refreshMCP": "Actualitza els servidors MCP", "execution": { diff --git a/webview-ui/src/i18n/locales/de/mcp.json b/webview-ui/src/i18n/locales/de/mcp.json index 50d27395a6c..e0c7f524fea 100644 --- a/webview-ui/src/i18n/locales/de/mcp.json +++ b/webview-ui/src/i18n/locales/de/mcp.json @@ -54,7 +54,10 @@ }, "serverStatus": { "retrying": "Wiederhole...", - "retryConnection": "Verbindung wiederholen" + "retryConnection": "Verbindung wiederholen", + "signIn": "Anmelden", + "authenticating": "Authentifizierung...", + "authRequired": "Authentifizierung erforderlich" }, "refreshMCP": "MCP-Server aktualisieren", "execution": { diff --git a/webview-ui/src/i18n/locales/en/mcp.json b/webview-ui/src/i18n/locales/en/mcp.json index 2fcae2840c7..4c16c3ad869 100644 --- a/webview-ui/src/i18n/locales/en/mcp.json +++ b/webview-ui/src/i18n/locales/en/mcp.json @@ -55,7 +55,10 @@ }, "serverStatus": { "retrying": "Retrying...", - "retryConnection": "Retry Connection" + "retryConnection": "Retry Connection", + "signIn": "Sign In", + "authenticating": "Authenticating...", + "authRequired": "Auth Required" }, "execution": { "running": "Running", diff --git a/webview-ui/src/i18n/locales/es/mcp.json b/webview-ui/src/i18n/locales/es/mcp.json index 5b7d8326267..0d5ecfb1beb 100644 --- a/webview-ui/src/i18n/locales/es/mcp.json +++ b/webview-ui/src/i18n/locales/es/mcp.json @@ -54,7 +54,10 @@ }, "serverStatus": { "retrying": "Reintentando...", - "retryConnection": "Reintentar conexión" + "retryConnection": "Reintentar conexión", + "signIn": "Iniciar sesión", + "authenticating": "Autenticando...", + "authRequired": "Autenticación requerida" }, "refreshMCP": "Actualizar Servidores MCP", "execution": { diff --git a/webview-ui/src/i18n/locales/fr/mcp.json b/webview-ui/src/i18n/locales/fr/mcp.json index 33fa3937968..e06163f29ca 100644 --- a/webview-ui/src/i18n/locales/fr/mcp.json +++ b/webview-ui/src/i18n/locales/fr/mcp.json @@ -54,7 +54,10 @@ }, "serverStatus": { "retrying": "Nouvelle tentative...", - "retryConnection": "Réessayer la connexion" + "retryConnection": "Réessayer la connexion", + "signIn": "Se connecter", + "authenticating": "Authentification...", + "authRequired": "Authentification requise" }, "refreshMCP": "Rafraîchir les serveurs MCP", "execution": { diff --git a/webview-ui/src/i18n/locales/hi/mcp.json b/webview-ui/src/i18n/locales/hi/mcp.json index 6a7bdd679ab..76365b63172 100644 --- a/webview-ui/src/i18n/locales/hi/mcp.json +++ b/webview-ui/src/i18n/locales/hi/mcp.json @@ -54,7 +54,10 @@ }, "serverStatus": { "retrying": "फिर से कोशिश कर रहा है...", - "retryConnection": "कनेक्शन फिर से आज़माएँ" + "retryConnection": "कनेक्शन फिर से आज़माएँ", + "signIn": "साइन इन", + "authenticating": "प्रमाणीकरण हो रहा है...", + "authRequired": "प्रमाणीकरण आवश्यक" }, "refreshMCP": "एमसीपी सर्वर रीफ्रेश करें", "execution": { diff --git a/webview-ui/src/i18n/locales/id/mcp.json b/webview-ui/src/i18n/locales/id/mcp.json index 6ae62ffd5ec..e900a0dff53 100644 --- a/webview-ui/src/i18n/locales/id/mcp.json +++ b/webview-ui/src/i18n/locales/id/mcp.json @@ -55,7 +55,10 @@ }, "serverStatus": { "retrying": "Mencoba lagi...", - "retryConnection": "Coba Koneksi Lagi" + "retryConnection": "Coba Koneksi Lagi", + "signIn": "Masuk", + "authenticating": "Mengautentikasi...", + "authRequired": "Autentikasi diperlukan" }, "execution": { "running": "Berjalan", diff --git a/webview-ui/src/i18n/locales/it/mcp.json b/webview-ui/src/i18n/locales/it/mcp.json index 3b6dce9cb15..0138f4ad9bc 100644 --- a/webview-ui/src/i18n/locales/it/mcp.json +++ b/webview-ui/src/i18n/locales/it/mcp.json @@ -54,7 +54,10 @@ }, "serverStatus": { "retrying": "Riprovo...", - "retryConnection": "Riprova connessione" + "retryConnection": "Riprova connessione", + "signIn": "Accedi", + "authenticating": "Autenticazione...", + "authRequired": "Autenticazione richiesta" }, "refreshMCP": "Aggiorna server MCP", "execution": { diff --git a/webview-ui/src/i18n/locales/ja/mcp.json b/webview-ui/src/i18n/locales/ja/mcp.json index d415e30f08f..3aafe124e86 100644 --- a/webview-ui/src/i18n/locales/ja/mcp.json +++ b/webview-ui/src/i18n/locales/ja/mcp.json @@ -54,7 +54,10 @@ }, "serverStatus": { "retrying": "再試行中...", - "retryConnection": "再接続" + "retryConnection": "再接続", + "signIn": "サインイン", + "authenticating": "認証中...", + "authRequired": "認証が必要です" }, "refreshMCP": "MCPサーバーを更新", "execution": { diff --git a/webview-ui/src/i18n/locales/ko/mcp.json b/webview-ui/src/i18n/locales/ko/mcp.json index b0ecb215e1c..6b994dc498f 100644 --- a/webview-ui/src/i18n/locales/ko/mcp.json +++ b/webview-ui/src/i18n/locales/ko/mcp.json @@ -54,7 +54,10 @@ }, "serverStatus": { "retrying": "다시 시도 중...", - "retryConnection": "연결 다시 시도" + "retryConnection": "연결 다시 시도", + "signIn": "로그인", + "authenticating": "인증 중...", + "authRequired": "인증 필요" }, "refreshMCP": "MCP 서버 새로 고침", "execution": { diff --git a/webview-ui/src/i18n/locales/nl/mcp.json b/webview-ui/src/i18n/locales/nl/mcp.json index 1eceb8681f0..c224de066f0 100644 --- a/webview-ui/src/i18n/locales/nl/mcp.json +++ b/webview-ui/src/i18n/locales/nl/mcp.json @@ -54,7 +54,10 @@ }, "serverStatus": { "retrying": "Opnieuw proberen...", - "retryConnection": "Verbinding opnieuw proberen" + "retryConnection": "Verbinding opnieuw proberen", + "signIn": "Inloggen", + "authenticating": "Verifiëren...", + "authRequired": "Verificatie vereist" }, "refreshMCP": "MCP-servers vernieuwen", "execution": { diff --git a/webview-ui/src/i18n/locales/pl/mcp.json b/webview-ui/src/i18n/locales/pl/mcp.json index 100e8748c88..65bba38f109 100644 --- a/webview-ui/src/i18n/locales/pl/mcp.json +++ b/webview-ui/src/i18n/locales/pl/mcp.json @@ -54,7 +54,10 @@ }, "serverStatus": { "retrying": "Ponawianie...", - "retryConnection": "Ponów połączenie" + "retryConnection": "Ponów połączenie", + "signIn": "Zaloguj się", + "authenticating": "Uwierzytelnianie...", + "authRequired": "Wymagane uwierzytelnienie" }, "refreshMCP": "Odśwież serwery MCP", "execution": { diff --git a/webview-ui/src/i18n/locales/pt-BR/mcp.json b/webview-ui/src/i18n/locales/pt-BR/mcp.json index da85d1ef6eb..b06200f66cc 100644 --- a/webview-ui/src/i18n/locales/pt-BR/mcp.json +++ b/webview-ui/src/i18n/locales/pt-BR/mcp.json @@ -54,7 +54,10 @@ }, "serverStatus": { "retrying": "Tentando novamente...", - "retryConnection": "Tentar reconectar" + "retryConnection": "Tentar reconectar", + "signIn": "Entrar", + "authenticating": "Autenticando...", + "authRequired": "Autenticação necessária" }, "refreshMCP": "Atualizar Servidores MCP", "execution": { diff --git a/webview-ui/src/i18n/locales/ru/mcp.json b/webview-ui/src/i18n/locales/ru/mcp.json index a2be25f5d6d..2f679351323 100644 --- a/webview-ui/src/i18n/locales/ru/mcp.json +++ b/webview-ui/src/i18n/locales/ru/mcp.json @@ -54,7 +54,10 @@ }, "serverStatus": { "retrying": "Повторная попытка...", - "retryConnection": "Повторить подключение" + "retryConnection": "Повторить подключение", + "signIn": "Войти", + "authenticating": "Аутентификация...", + "authRequired": "Требуется аутентификация" }, "refreshMCP": "Обновить MCP серверы", "execution": { diff --git a/webview-ui/src/i18n/locales/tr/mcp.json b/webview-ui/src/i18n/locales/tr/mcp.json index 18bc2af5f30..a4ae4d75a05 100644 --- a/webview-ui/src/i18n/locales/tr/mcp.json +++ b/webview-ui/src/i18n/locales/tr/mcp.json @@ -54,7 +54,10 @@ }, "serverStatus": { "retrying": "Tekrar deneniyor...", - "retryConnection": "Bağlantıyı tekrar dene" + "retryConnection": "Bağlantıyı tekrar dene", + "signIn": "Giriş yap", + "authenticating": "Doğrulanıyor...", + "authRequired": "Doğrulama gerekli" }, "refreshMCP": "MCP Sunucularını Yenile", "execution": { diff --git a/webview-ui/src/i18n/locales/vi/mcp.json b/webview-ui/src/i18n/locales/vi/mcp.json index d7d9a29d740..14ea7c96f64 100644 --- a/webview-ui/src/i18n/locales/vi/mcp.json +++ b/webview-ui/src/i18n/locales/vi/mcp.json @@ -54,7 +54,10 @@ }, "serverStatus": { "retrying": "Đang thử lại...", - "retryConnection": "Thử kết nối lại" + "retryConnection": "Thử kết nối lại", + "signIn": "Đăng nhập", + "authenticating": "Đang xác thực...", + "authRequired": "Cần xác thực" }, "refreshMCP": "Làm mới Máy chủ MCP", "execution": { diff --git a/webview-ui/src/i18n/locales/zh-CN/mcp.json b/webview-ui/src/i18n/locales/zh-CN/mcp.json index 601d1b2a135..f31cc5eac6b 100644 --- a/webview-ui/src/i18n/locales/zh-CN/mcp.json +++ b/webview-ui/src/i18n/locales/zh-CN/mcp.json @@ -54,7 +54,10 @@ }, "serverStatus": { "retrying": "重试中...", - "retryConnection": "重试连接" + "retryConnection": "重试连接", + "signIn": "登录", + "authenticating": "认证中...", + "authRequired": "需要认证" }, "refreshMCP": "刷新 MCP 服务器", "execution": { diff --git a/webview-ui/src/i18n/locales/zh-TW/mcp.json b/webview-ui/src/i18n/locales/zh-TW/mcp.json index ab01f9f2cfd..76f1676c7ee 100644 --- a/webview-ui/src/i18n/locales/zh-TW/mcp.json +++ b/webview-ui/src/i18n/locales/zh-TW/mcp.json @@ -55,7 +55,10 @@ }, "serverStatus": { "retrying": "重試中...", - "retryConnection": "重試連線" + "retryConnection": "重試連線", + "signIn": "登入", + "authenticating": "驗證中...", + "authRequired": "需要驗證" }, "execution": { "running": "執行中",