diff --git a/packages/app/src/components/dialog-select-mcp.tsx b/packages/app/src/components/dialog-select-mcp.tsx index 98f262ce5a32..add87be7113c 100644 --- a/packages/app/src/components/dialog-select-mcp.tsx +++ b/packages/app/src/components/dialog-select-mcp.tsx @@ -1,6 +1,6 @@ import { useMutation } from "@tanstack/solid-query" -import { Component, createEffect, createMemo, on, Show } from "solid-js" -import { createStore } from "solid-js/store" +import { Component, createEffect, createMemo, For, on, Show } from "solid-js" +import { createStore, produce } from "solid-js/store" import { useSync } from "@/context/sync" import { useSDK } from "@/context/sdk" import { Dialog } from "@opencode-ai/ui/dialog" @@ -25,6 +25,8 @@ export const DialogSelectMcp: Component = () => { loading: false, }) + const [pendingAuthUrls, setPendingAuthUrls] = createStore>({}) + createEffect( on( () => sync.data.mcp_ready, @@ -75,7 +77,12 @@ export const DialogSelectMcp: Component = () => { if (status?.status === "connected") { await sdk.client.mcp.disconnect({ name }) } else { - await sdk.client.mcp.connect({ name }) + const connectResult = await sdk.client.mcp.connect({ name }) + if (connectResult.data && typeof connectResult.data === "object" && "needs_oauth" in connectResult.data) { + const { authorization_url } = connectResult.data as { needs_oauth: true; authorization_url: string } + setPendingAuthUrls(name, authorization_url) + return + } } const result = await sdk.client.mcp.status() @@ -102,6 +109,32 @@ export const DialogSelectMcp: Component = () => { if (!x || toggle.isPending) return toggle.mutate(x.name) }} + itemWrapper={(item, node) => ( +
+ {node} + +
+

{language.t("mcp.oauth.authorizeRequired")}

+ +
+
+
+ )} > {(i) => { const mcpStatus = () => sync.data.mcp[i.name] diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx index 0f6a1c1355f0..c8304dd51f8f 100644 --- a/packages/app/src/components/status-popover-body.tsx +++ b/packages/app/src/components/status-popover-body.tsx @@ -7,7 +7,7 @@ import { useMutation } from "@tanstack/solid-query" import { showToast } from "@opencode-ai/ui/toast" import { useNavigate } from "@solidjs/router" import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js" -import { createStore, reconcile } from "solid-js/store" +import { createStore, produce, reconcile } from "solid-js/store" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" @@ -133,7 +133,7 @@ const useDefaultServerKey = ( } } -const useMcpToggleMutation = () => { +const useMcpToggleMutation = (onOAuth: (name: string, url: string) => void) => { const sync = useSync() const sdk = useSDK() const language = useLanguage() @@ -141,7 +141,16 @@ const useMcpToggleMutation = () => { return useMutation(() => ({ mutationFn: async (name: string) => { const status = sync.data.mcp[name] - await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name })) + if (status?.status === "connected") { + await sdk.client.mcp.disconnect({ name }) + } else { + const connectResult = await sdk.client.mcp.connect({ name }) + if (connectResult.data && typeof connectResult.data === "object" && "needs_oauth" in connectResult.data) { + const { authorization_url } = connectResult.data as { needs_oauth: true; authorization_url: string } + onOAuth(name, authorization_url) + return + } + } const result = await sdk.client.mcp.status() if (result.data) sync.set("mcp", result.data) }, @@ -163,6 +172,7 @@ export function StatusPopoverBody(props: { shown: Accessor }) { const language = useLanguage() const navigate = useNavigate() const sdk = useSDK() + const [pendingAuthUrls, setPendingAuthUrls] = createStore>({}) const [load, setLoad] = createStore({ lspDone: false, @@ -232,7 +242,7 @@ export function StatusPopoverBody(props: { shown: Accessor }) { }) const health = useServerHealth(servers, props.shown) const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health)) - const toggleMcp = useMcpToggleMutation() + const toggleMcp = useMcpToggleMutation((name, url) => setPendingAuthUrls(name, url)) const defaultServer = useDefaultServerKey(platform.getDefaultServer) const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b))) const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status @@ -352,38 +362,61 @@ export function StatusPopoverBody(props: { shown: Accessor }) { {(name) => { const status = () => mcpStatus(name) const enabled = () => status() === "connected" + const authUrl = () => pendingAuthUrls[name] return ( - + {name} +
event.stopPropagation()}> + { + if (toggleMcp.isPending) return + toggleMcp.mutate(name) + }} + /> +
+ + +
+

{language.t("mcp.oauth.authorizeRequired")}

+ +
+
+ ) }} diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 0cf3570a8b3d..b9cc0a0944c8 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -325,6 +325,13 @@ function createGlobalSync() { }) }, }) + if (event.type === "mcp.tools.changed") { + sdkFor(directory) + .mcp.status() + .then((x) => { + if (x.data) setStore("mcp", x.data) + }) + } }) onCleanup(unsub) diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 6c3f3bb55ef4..c20dd3e86404 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -277,6 +277,9 @@ export const dict = { "mcp.status.failed": "فشل", "mcp.status.needs_auth": "يحتاج إلى مصادقة", "mcp.status.disabled": "معطل", + "mcp.oauth.title": "مطلوب تفويض OAuth", + "mcp.oauth.authorizeRequired": "يرجى إكمال التفويض في نافذة المتصفح المفتوحة، ثم حاول الاتصال مرة أخرى.", + "mcp.oauth.openBrowser": "تفويض", "dialog.fork.empty": "لا توجد رسائل للتفرع منها", "dialog.directory.search.placeholder": "البحث في المجلدات", "dialog.directory.empty": "لم يتم العثور على مجلدات", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 63880462a467..c44f27308dac 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -277,6 +277,9 @@ export const dict = { "mcp.status.failed": "falhou", "mcp.status.needs_auth": "precisa de autenticação", "mcp.status.disabled": "desabilitado", + "mcp.oauth.title": "Autorização OAuth necessária", + "mcp.oauth.authorizeRequired": "Conclua a autorização na janela do navegador aberta e tente conectar novamente.", + "mcp.oauth.openBrowser": "Autorizar", "dialog.fork.empty": "Nenhuma mensagem para bifurcar", "dialog.directory.search.placeholder": "Buscar pastas", "dialog.directory.empty": "Nenhuma pasta encontrada", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index 2b589eb35f62..9a6e5e0844d4 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -302,6 +302,10 @@ export const dict = { "mcp.status.needs_auth": "potrebna autentifikacija", "mcp.status.disabled": "onemogućeno", + "mcp.oauth.title": "OAuth autorizacija je potrebna", + "mcp.oauth.authorizeRequired": + "Završi autorizaciju u otvorenom prozoru preglednika, zatim pokušaj ponovo da se povežeš.", + "mcp.oauth.openBrowser": "Autorizuj", "dialog.fork.empty": "Nema poruka za fork", "dialog.directory.search.placeholder": "Pretraži foldere", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index b096d87b4b7b..441dd31ecfc5 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -300,6 +300,10 @@ export const dict = { "mcp.status.needs_auth": "kræver godkendelse", "mcp.status.disabled": "deaktiveret", + "mcp.oauth.title": "OAuth-godkendelse kræves", + "mcp.oauth.authorizeRequired": + "Fuldfør godkendelsen i det åbnede browservindue, og prøv derefter at oprette forbindelse igen.", + "mcp.oauth.openBrowser": "Godkend", "dialog.fork.empty": "Ingen beskeder at forgrene fra", "dialog.directory.search.placeholder": "Søg mapper", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 6dc0b0497245..a0a5e2f6cbfa 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -283,6 +283,10 @@ export const dict = { "mcp.status.failed": "fehlgeschlagen", "mcp.status.needs_auth": "benötigt Authentifizierung", "mcp.status.disabled": "deaktiviert", + "mcp.oauth.title": "OAuth-Autorisierung erforderlich", + "mcp.oauth.authorizeRequired": + "Bitte schließen Sie die Autorisierung im geöffneten Browserfenster ab und versuchen Sie dann erneut, eine Verbindung herzustellen.", + "mcp.oauth.openBrowser": "Autorisieren", "dialog.fork.empty": "Keine Nachrichten zum Abzweigen vorhanden", "dialog.directory.search.placeholder": "Ordner durchsuchen", "dialog.directory.empty": "Keine Ordner gefunden", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index c6bcc37b116f..da021e02313d 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -306,6 +306,11 @@ export const dict = { "mcp.status.needs_auth": "needs auth", "mcp.status.disabled": "disabled", + "mcp.oauth.title": "OAuth Authorization Required", + "mcp.oauth.authorizeRequired": + "Please complete authorization in the opened browser window, then try connecting again.", + "mcp.oauth.openBrowser": "Authorize", + "dialog.fork.empty": "No messages to fork from", "dialog.directory.search.placeholder": "Search folders", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index c600232ef613..2de86f9b5095 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -301,6 +301,10 @@ export const dict = { "mcp.status.needs_auth": "necesita auth", "mcp.status.disabled": "deshabilitado", + "mcp.oauth.title": "Se requiere autorización OAuth", + "mcp.oauth.authorizeRequired": + "Completa la autorización en la ventana del navegador abierta y vuelve a intentar conectarte.", + "mcp.oauth.openBrowser": "Autorizar", "dialog.fork.empty": "No hay mensajes desde donde bifurcar", "dialog.directory.search.placeholder": "Buscar carpetas", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index a140c1e3a123..e82b5b3b7b8d 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -278,6 +278,10 @@ export const dict = { "mcp.status.failed": "échoué", "mcp.status.needs_auth": "nécessite auth", "mcp.status.disabled": "désactivé", + "mcp.oauth.title": "Autorisation OAuth requise", + "mcp.oauth.authorizeRequired": + "Veuillez terminer l'autorisation dans la fenêtre du navigateur ouverte, puis réessayer de vous connecter.", + "mcp.oauth.openBrowser": "Autoriser", "dialog.fork.empty": "Aucun message à partir duquel bifurquer", "dialog.directory.search.placeholder": "Rechercher des dossiers", "dialog.directory.empty": "Aucun dossier trouvé", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 3da1c4b43b58..4eef718c6f0c 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -276,6 +276,9 @@ export const dict = { "mcp.status.failed": "失敗", "mcp.status.needs_auth": "認証が必要", "mcp.status.disabled": "無効", + "mcp.oauth.title": "OAuth 認可が必要です", + "mcp.oauth.authorizeRequired": "開いているブラウザーウィンドウで認可を完了してから、もう一度接続をお試しください。", + "mcp.oauth.openBrowser": "認可", "dialog.fork.empty": "フォーク元のメッセージがありません", "dialog.directory.search.placeholder": "フォルダを検索", "dialog.directory.empty": "フォルダが見つかりません", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 0f2f7647abf5..bc95310b672a 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -280,6 +280,9 @@ export const dict = { "mcp.status.failed": "실패", "mcp.status.needs_auth": "인증 필요", "mcp.status.disabled": "비활성화됨", + "mcp.oauth.title": "OAuth 권한 부여가 필요합니다", + "mcp.oauth.authorizeRequired": "열린 브라우저 창에서 권한 부여를 완료한 다음 다시 연결해 주세요.", + "mcp.oauth.openBrowser": "승인", "dialog.fork.empty": "분기할 메시지 없음", "dialog.directory.search.placeholder": "폴더 검색", "dialog.directory.empty": "폴더 없음", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index a0a968179cd0..2fbe827767a6 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -304,6 +304,10 @@ export const dict = { "mcp.status.needs_auth": "trenger autentisering", "mcp.status.disabled": "deaktivert", + "mcp.oauth.title": "OAuth-autorisering kreves", + "mcp.oauth.authorizeRequired": + "Fullfør autoriseringen i det åpne nettleservinduet, og prøv deretter å koble til igjen.", + "mcp.oauth.openBrowser": "Godkjenn", "dialog.fork.empty": "Ingen meldinger å forgrene fra", "dialog.directory.search.placeholder": "Søk etter mapper", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 88d209f11ff2..467579a21f20 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -278,6 +278,10 @@ export const dict = { "mcp.status.failed": "niepowodzenie", "mcp.status.needs_auth": "wymaga autoryzacji", "mcp.status.disabled": "wyłączone", + "mcp.oauth.title": "Wymagana autoryzacja OAuth", + "mcp.oauth.authorizeRequired": + "Dokończ autoryzację w otwartym oknie przeglądarki, a następnie spróbuj połączyć się ponownie.", + "mcp.oauth.openBrowser": "Autoryzuj", "dialog.fork.empty": "Brak wiadomości do rozwidlenia", "dialog.directory.search.placeholder": "Szukaj folderów", "dialog.directory.empty": "Nie znaleziono folderów", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 688289b7e812..3d22f64e00e4 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -301,6 +301,10 @@ export const dict = { "mcp.status.needs_auth": "требуется авторизация", "mcp.status.disabled": "отключено", + "mcp.oauth.title": "Требуется авторизация OAuth", + "mcp.oauth.authorizeRequired": + "Пожалуйста, завершите авторизацию в открытом окне браузера, затем попробуйте подключиться снова.", + "mcp.oauth.openBrowser": "Авторизоваться", "dialog.fork.empty": "Нет сообщений для ответвления", "dialog.directory.search.placeholder": "Поиск папок", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 5decf3adb531..3ac1857e53b3 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -301,6 +301,10 @@ export const dict = { "mcp.status.needs_auth": "ต้องการการตรวจสอบสิทธิ์", "mcp.status.disabled": "ปิดใช้งาน", + "mcp.oauth.title": "ต้องมีการอนุญาต OAuth", + "mcp.oauth.authorizeRequired": + "โปรดดำเนินการอนุญาตให้เสร็จสิ้นในหน้าต่างเบราว์เซอร์ที่เปิดอยู่ จากนั้นลองเชื่อมต่ออีกครั้ง", + "mcp.oauth.openBrowser": "อนุญาต", "dialog.fork.empty": "ไม่มีข้อความให้แตกแขนง", "dialog.directory.search.placeholder": "ค้นหาโฟลเดอร์", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index 6a3ade0d0b07..aea9191614e2 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -306,6 +306,10 @@ export const dict = { "mcp.status.needs_auth": "kimlik doğrulama gerekli", "mcp.status.disabled": "devre dışı", + "mcp.oauth.title": "OAuth yetkilendirmesi gerekli", + "mcp.oauth.authorizeRequired": + "Lütfen açık tarayıcı penceresindeki yetkilendirmeyi tamamlayın, ardından yeniden bağlanmayı deneyin.", + "mcp.oauth.openBrowser": "Yetkilendir", "dialog.fork.empty": "Dallandırılacak mesaj yok", "dialog.directory.search.placeholder": "Klasör ara", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 28231733eaba..aa7ca3559dba 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -321,6 +321,9 @@ export const dict = { "mcp.status.needs_auth": "需要授权", "mcp.status.disabled": "已禁用", + "mcp.oauth.title": "需要 OAuth 授权", + "mcp.oauth.authorizeRequired": "请在已打开的浏览器窗口中完成授权,然后再次尝试连接。", + "mcp.oauth.openBrowser": "授权", "dialog.fork.empty": "没有可用于分叉的消息", "dialog.directory.search.placeholder": "搜索文件夹", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 4abdf5db574d..6c9ea58faf7c 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -301,6 +301,9 @@ export const dict = { "mcp.status.needs_auth": "需要授權", "mcp.status.disabled": "已停用", + "mcp.oauth.title": "需要 OAuth 授權", + "mcp.oauth.authorizeRequired": "請在已開啟的瀏覽器視窗中完成授權,然後再試一次連線。", + "mcp.oauth.openBrowser": "授權", "dialog.fork.empty": "沒有可用於分支的訊息", "dialog.directory.search.placeholder": "搜尋資料夾", diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index ec3931b209ad..1f9539231a2e 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -13,6 +13,7 @@ import { useArgs } from "./args" import { useSDK } from "./sdk" import { RGBA } from "@opentui/core" import { Filesystem } from "@/util/filesystem" +import open from "open" export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ name: "Local", @@ -375,11 +376,19 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ async toggle(name: string) { const status = sync.data.mcp[name] if (status?.status === "connected") { - // Disable: disconnect the MCP await sdk.client.mcp.disconnect({ name }) } else { - // Enable/Retry: connect the MCP (handles disabled, failed, and other states) - await sdk.client.mcp.connect({ name }) + const res = await sdk.client.mcp.connect({ name }) + if (res.data && typeof res.data === "object" && "needs_oauth" in res.data) { + const { authorization_url } = res.data as { needs_oauth: true; authorization_url: string } + open(authorization_url).catch(() => { + toast.show({ + variant: "warning", + message: `Authorization required. Open this URL manually: ${authorization_url}`, + duration: 10000, + }) + }) + } } }, } diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 498db99a1b78..1adb93b7c86c 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -340,6 +340,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ break } + case "mcp.tools.changed": { + sdk.client.mcp.status().then((x) => setStore("mcp", reconcile(x.data!))) + break + } + case "vcs.branch.updated": { setStore("vcs", { branch: event.properties.branch }) break diff --git a/packages/opencode/src/mcp/auth.ts b/packages/opencode/src/mcp/auth.ts index 773ca0a6f523..09539683c705 100644 --- a/packages/opencode/src/mcp/auth.ts +++ b/packages/opencode/src/mcp/auth.ts @@ -28,6 +28,7 @@ export namespace McpAuth { codeVerifier: z.string().optional(), oauthState: z.string().optional(), serverUrl: z.string().optional(), + redirectUrl: z.string().optional(), }) export type Entry = z.infer @@ -41,6 +42,7 @@ export namespace McpAuth { readonly remove: (mcpName: string) => Effect.Effect readonly updateTokens: (mcpName: string, tokens: Tokens, serverUrl?: string) => Effect.Effect readonly updateClientInfo: (mcpName: string, clientInfo: ClientInfo, serverUrl?: string) => Effect.Effect + readonly updateRedirectUrl: (mcpName: string, redirectUrl: string) => Effect.Effect readonly updateCodeVerifier: (mcpName: string, codeVerifier: string) => Effect.Effect readonly clearCodeVerifier: (mcpName: string) => Effect.Effect readonly updateOAuthState: (mcpName: string, oauthState: string) => Effect.Effect @@ -108,6 +110,7 @@ export namespace McpAuth { const updateClientInfo = updateField("clientInfo", "updateClientInfo") const updateCodeVerifier = updateField("codeVerifier", "updateCodeVerifier") const updateOAuthState = updateField("oauthState", "updateOAuthState") + const updateRedirectUrl = updateField("redirectUrl", "updateRedirectUrl") const clearCodeVerifier = clearField("codeVerifier", "clearCodeVerifier") const clearOAuthState = clearField("oauthState", "clearOAuthState") @@ -131,6 +134,7 @@ export namespace McpAuth { remove, updateTokens, updateClientInfo, + updateRedirectUrl, updateCodeVerifier, clearCodeVerifier, updateOAuthState, @@ -165,9 +169,14 @@ export namespace McpAuth { export const updateClientInfo = async (mcpName: string, clientInfo: ClientInfo, serverUrl?: string) => runPromise((svc) => svc.updateClientInfo(mcpName, clientInfo, serverUrl)) + export const updateRedirectUrl = async (mcpName: string, redirectUrl: string) => + runPromise((svc) => svc.updateRedirectUrl(mcpName, redirectUrl)) + export const updateCodeVerifier = async (mcpName: string, codeVerifier: string) => runPromise((svc) => svc.updateCodeVerifier(mcpName, codeVerifier)) export const updateOAuthState = async (mcpName: string, oauthState: string) => runPromise((svc) => svc.updateOAuthState(mcpName, oauthState)) + + export const clearOAuthState = async (mcpName: string) => runPromise((svc) => svc.clearOAuthState(mcpName)) } diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 2599a8dec904..f581c42a8e1a 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -117,6 +117,8 @@ export namespace MCP { // Store transports for OAuth servers to allow finishing auth type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport const pendingOAuthTransports = new Map() + // Store callback promises so authenticate() can await them without double-registering + const pendingCallbacks = new Map>() // Prompt cache types type PromptInfo = Awaited>["prompts"][number] @@ -230,7 +232,10 @@ export namespace MCP { clientName: string, resourceUri: string, ) => Effect.Effect> | undefined> - readonly startAuth: (mcpName: string) => Effect.Effect<{ authorizationUrl: string; oauthState: string }> + readonly startAuth: ( + mcpName: string, + redirectUrl?: string, + ) => Effect.Effect<{ authorizationUrl: string; oauthState: string }> readonly authenticate: (mcpName: string) => Effect.Effect readonly finishAuth: (mcpName: string, authorizationCode: string) => Effect.Effect readonly removeAuth: (mcpName: string) => Effect.Effect @@ -711,7 +716,7 @@ export namespace MCP { return mcpConfig }) - const startAuth = Effect.fn("MCP.startAuth")(function* (mcpName: string) { + const startAuth = Effect.fn("MCP.startAuth")(function* (mcpName: string, redirectUrl?: string) { const mcpConfig = yield* getMcpConfig(mcpName) if (!mcpConfig) throw new Error(`MCP server ${mcpName} not found or disabled`) if (mcpConfig.type !== "remote") throw new Error(`MCP server ${mcpName} is not a remote server`) @@ -742,6 +747,7 @@ export namespace MCP { capturedUrl = url }, }, + redirectUrl, ) const transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url), { authProvider }) @@ -756,6 +762,36 @@ export namespace MCP { Effect.catch((error) => { if (error instanceof UnauthorizedError && capturedUrl) { pendingOAuthTransports.set(mcpName, transport) + // Register the state so receiveCallback() can resolve it when the + // OAuth server redirects back to the backend /mcp/oauth/callback route. + // authenticate() (TUI) will await this promise directly instead of re-registering. + const callbackPromise = McpOAuthCallback.waitForCallback(oauthState, mcpName) + pendingCallbacks.set(mcpName, callbackPromise) + log.info("registered callback promise", { mcpName, oauthState }) + callbackPromise.then( + async (code) => { + log.info("callback promise resolved", { mcpName, owned: pendingCallbacks.has(mcpName) }) + // If authenticate() already took ownership (deleted from pendingCallbacks), skip + if (!pendingCallbacks.has(mcpName)) return + pendingCallbacks.delete(mcpName) + const entry = await McpAuth.get(mcpName) + if (entry?.oauthState !== oauthState) { + log.info("oauthState mismatch, skipping finishAuth", { mcpName }) + return + } + await McpAuth.clearOAuthState(mcpName) + log.info("calling finishAuth from fire-and-forget", { mcpName }) + await Effect.runPromise( + finishAuth(mcpName, code).pipe( + Effect.flatMap(() => bus.publish(ToolsChanged, { server: mcpName })), + ), + ) + }, + (err) => { + pendingCallbacks.delete(mcpName) + log.error("oauth callback failed", { mcpName, error: err }) + }, + ) return Effect.succeed({ authorizationUrl: capturedUrl.toString(), oauthState }) } return Effect.die(error) @@ -769,7 +805,8 @@ export namespace MCP { log.info("opening browser for oauth", { mcpName, url: authorizationUrl, state: oauthState }) - const callbackPromise = McpOAuthCallback.waitForCallback(oauthState, mcpName) + // startAuth already registered waitForCallback; reuse its promise to avoid double-registration + const callbackPromise = pendingCallbacks.get(mcpName) ?? McpOAuthCallback.waitForCallback(oauthState, mcpName) yield* Effect.tryPromise(() => open(authorizationUrl)).pipe( Effect.flatMap((subprocess) => @@ -794,6 +831,8 @@ export namespace MCP { ) const code = yield* Effect.promise(() => callbackPromise) + // Signal to the fire-and-forget in startAuth that authenticate() owns finishAuth + pendingCallbacks.delete(mcpName) const storedState = yield* auth.getOAuthState(mcpName) if (storedState !== oauthState) { @@ -906,7 +945,11 @@ export namespace MCP { export const disconnect = async (name: string) => runPromise((svc) => svc.disconnect(name)) - export const startAuth = async (mcpName: string) => runPromise((svc) => svc.startAuth(mcpName)) + export const getPrompt = async (clientName: string, name: string, args?: Record) => + runPromise((svc) => svc.getPrompt(clientName, name, args)) + + export const startAuth = async (mcpName: string, redirectUrl?: string) => + runPromise((svc) => svc.startAuth(mcpName, redirectUrl)) export const authenticate = async (mcpName: string) => runPromise((svc) => svc.authenticate(mcpName)) diff --git a/packages/opencode/src/mcp/oauth-callback.ts b/packages/opencode/src/mcp/oauth-callback.ts index b5b6a7a6ebbc..372b7c206b59 100644 --- a/packages/opencode/src/mcp/oauth-callback.ts +++ b/packages/opencode/src/mcp/oauth-callback.ts @@ -140,6 +140,17 @@ export namespace McpOAuthCallback { } export async function ensureRunning(redirectUri?: string): Promise { + if (redirectUri) { + const isExternal = await Promise.resolve(new URL(redirectUri)) + .then((url) => url.hostname !== "127.0.0.1" && url.hostname !== "localhost") + .catch(() => false) + + if (isExternal) { + log.info("redirect URI is external, skipping local callback server", { redirectUri }) + return + } + } + // Parse the redirect URI to get port and path (uses defaults if not provided) const { port, path } = parseRedirectUri(redirectUri) @@ -229,4 +240,45 @@ export namespace McpOAuthCallback { export function isRunning(): boolean { return server !== undefined } + + export async function receiveCallback(params: { + code?: string | null + state?: string | null + error?: string | null + errorDescription?: string | null + }): Promise<{ success: boolean; error?: string }> { + const { code, state, error, errorDescription } = params + + if (!state) { + return { success: false, error: "Missing required state parameter" } + } + + if (error) { + const errorMsg = errorDescription || error + if (pendingAuths.has(state)) { + const pending = pendingAuths.get(state)! + clearTimeout(pending.timeout) + pendingAuths.delete(state) + cleanupStateIndex(state) + pending.reject(new Error(errorMsg)) + } + return { success: false, error: errorMsg } + } + + if (!code) { + return { success: false, error: "No authorization code provided" } + } + + if (!pendingAuths.has(state)) { + return { success: false, error: "Invalid or expired state parameter" } + } + + const pending = pendingAuths.get(state)! + clearTimeout(pending.timeout) + pendingAuths.delete(state) + cleanupStateIndex(state) + pending.resolve(code) + + return { success: true } + } } diff --git a/packages/opencode/src/mcp/oauth-provider.ts b/packages/opencode/src/mcp/oauth-provider.ts index d675fc71e469..a694c405425a 100644 --- a/packages/opencode/src/mcp/oauth-provider.ts +++ b/packages/opencode/src/mcp/oauth-provider.ts @@ -30,12 +30,16 @@ export class McpOAuthProvider implements OAuthClientProvider { private serverUrl: string, private config: McpOAuthConfig, private callbacks: McpOAuthCallbacks, + private _redirectUrl?: string, ) {} get redirectUrl(): string { if (this.config.redirectUri) { return this.config.redirectUri } + if (this._redirectUrl) { + return this._redirectUrl + } return `http://127.0.0.1:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}` } @@ -63,6 +67,18 @@ export class McpOAuthProvider implements OAuthClientProvider { // Use getForUrl to validate credentials are for the current server URL const entry = await McpAuth.getForUrl(this.mcpName, this.serverUrl) if (entry?.clientInfo) { + // Only force re-registration when redirect_uri changed AND there are no tokens yet. + // If tokens exist the existing client_id is already trusted by the server — changing + // redirect_uri at this point would invalidate the token unnecessarily. + if (!entry.tokens && entry.redirectUrl !== this.redirectUrl) { + log.info("redirect_url changed or missing, clearing stale client info", { + mcpName: this.mcpName, + stored: entry.redirectUrl, + current: this.redirectUrl, + }) + await this.invalidateCredentials("client") + return undefined + } // Check if client secret has expired if (entry.clientInfo.clientSecretExpiresAt && entry.clientInfo.clientSecretExpiresAt < Date.now() / 1000) { log.info("client secret expired, need to re-register", { mcpName: this.mcpName }) @@ -89,9 +105,11 @@ export class McpOAuthProvider implements OAuthClientProvider { }, this.serverUrl, ) + await McpAuth.updateRedirectUrl(this.mcpName, this.redirectUrl) log.info("saved dynamically registered client", { mcpName: this.mcpName, clientId: info.client_id, + redirectUrl: this.redirectUrl, }) } diff --git a/packages/opencode/src/server/routes/mcp.ts b/packages/opencode/src/server/routes/mcp.ts index 1e604c99183b..3039dfff9bda 100644 --- a/packages/opencode/src/server/routes/mcp.ts +++ b/packages/opencode/src/server/routes/mcp.ts @@ -5,6 +5,7 @@ import { MCP } from "../../mcp" import { Config } from "../../config/config" import { errors } from "../error" import { lazy } from "../../util/lazy" +import { McpOAuthCallback } from "../../mcp/oauth-callback" export const McpRoutes = lazy(() => new Hono() @@ -60,6 +61,80 @@ export const McpRoutes = lazy(() => return c.json(result.status) }, ) + .get( + "/oauth/callback", + describeRoute({ + summary: "OAuth callback", + description: "Handle OAuth callback from browser for MCP server authentication.", + operationId: "mcp.oauth.callback", + responses: { + 200: { + description: "OAuth callback processed", + }, + }, + }), + async (c) => { + const url = new URL(c.req.url) + const code = url.searchParams.get("code") + const state = url.searchParams.get("state") + const error = url.searchParams.get("error") + const errorDescription = url.searchParams.get("error_description") + + const result = await McpOAuthCallback.receiveCallback({ + code, + state, + error, + errorDescription, + }) + + if (result.success) { + return c.html( + ` + + + OpenCode - Authorization Successful + + + +
+

Authorization Successful

+

You can close this window and return to OpenCode.

+
+ + +`, + ) + } else { + return c.html( + ` + + + OpenCode - Authorization Failed + + + +
+

Authorization Failed

+

An error occurred during authorization.

+
${result.error}
+
+ +`, + ) + } + }, + ) .post( "/:name/auth", describeRoute({ @@ -74,6 +149,7 @@ export const McpRoutes = lazy(() => schema: resolver( z.object({ authorizationUrl: z.string().describe("URL to open in browser for authorization"), + oauthState: z.string().describe("OAuth state for callback verification"), }), ), }, @@ -183,10 +259,18 @@ export const McpRoutes = lazy(() => operationId: "mcp.connect", responses: { 200: { - description: "MCP server connected successfully", + description: "MCP server connected successfully or needs OAuth", content: { "application/json": { - schema: resolver(z.boolean()), + schema: resolver( + z.union([ + z.boolean(), + z.object({ + needs_oauth: z.literal(true), + authorization_url: z.string(), + }), + ]), + ), }, }, }, @@ -195,6 +279,17 @@ export const McpRoutes = lazy(() => validator("param", z.object({ name: z.string() })), async (c) => { const { name } = c.req.valid("param") + const supportsOAuth = await MCP.supportsOAuth(name) + const hasTokens = supportsOAuth ? await MCP.hasStoredTokens(name) : false + + if (supportsOAuth && !hasTokens) { + const proto = c.req.header("x-forwarded-proto") ?? (c.req.url.startsWith("https") ? "https" : "http") + const host = c.req.header("host") + const redirectUrl = host ? `${proto}://${host}/mcp/oauth/callback` : undefined + const { authorizationUrl } = await MCP.startAuth(name, redirectUrl) + return c.json({ needs_oauth: true, authorization_url: authorizationUrl }) + } + await MCP.connect(name) return c.json(true) }, diff --git a/packages/opencode/test/mcp/oauth-callback.test.ts b/packages/opencode/test/mcp/oauth-callback.test.ts index 58a4fa8c86cc..8567ae66bc71 100644 --- a/packages/opencode/test/mcp/oauth-callback.test.ts +++ b/packages/opencode/test/mcp/oauth-callback.test.ts @@ -1,7 +1,125 @@ -import { test, expect, describe, afterEach } from "bun:test" +import { test, expect, describe, beforeEach, afterEach } from "bun:test" import { McpOAuthCallback } from "../../src/mcp/oauth-callback" import { parseRedirectUri } from "../../src/mcp/oauth-provider" +beforeEach(async () => { + await McpOAuthCallback.stop() +}) + +afterEach(async () => { + await McpOAuthCallback.stop() +}) + +describe("McpOAuthCallback", () => { + test("receiveCallback resolves pending auth with the authorization code", async () => { + const oauthState = "test-state-123" + const mcpName = "test-server" + const callbackPromise = McpOAuthCallback.waitForCallback(oauthState, mcpName) + + const result = await McpOAuthCallback.receiveCallback({ + code: "auth-code-abc", + state: oauthState, + }) + + expect(result.success).toBe(true) + expect(result.error).toBeUndefined() + + const code = await callbackPromise + expect(code).toBe("auth-code-abc") + }) + + test("receiveCallback rejects pending auth on OAuth error", async () => { + const oauthState = "error-state-456" + const mcpName = "error-server" + const callbackPromise = McpOAuthCallback.waitForCallback(oauthState, mcpName) + + const result = await McpOAuthCallback.receiveCallback({ + error: "access_denied", + errorDescription: "User denied access", + state: oauthState, + }) + + expect(result.success).toBe(false) + expect(result.error).toBe("User denied access") + + await expect(callbackPromise).rejects.toThrow("User denied access") + }) + + test("receiveCallback returns error when state is missing", async () => { + const result = await McpOAuthCallback.receiveCallback({ + code: "some-code", + state: null, + }) + + expect(result.success).toBe(false) + expect(result.error).toContain("Missing required state parameter") + }) + + test("receiveCallback returns error when code is missing", async () => { + const oauthState = "no-code-state" + const mcpName = "no-code-server" + const callbackPromise = McpOAuthCallback.waitForCallback(oauthState, mcpName) + + const result = await McpOAuthCallback.receiveCallback({ + code: null, + state: oauthState, + }) + + expect(result.success).toBe(false) + expect(result.error).toContain("No authorization code provided") + + McpOAuthCallback.cancelPending(mcpName) + await expect(callbackPromise).rejects.toThrow() + }) + + test("receiveCallback returns error for invalid/expired state", async () => { + const result = await McpOAuthCallback.receiveCallback({ + code: "auth-code", + state: "unknown-state-that-was-never-registered", + }) + + expect(result.success).toBe(false) + expect(result.error).toContain("Invalid or expired state parameter") + }) + + test("cancelPending rejects the callback promise for the given mcpName", async () => { + const oauthState = "cancel-state" + const mcpName = "cancel-server" + const callbackPromise = McpOAuthCallback.waitForCallback(oauthState, mcpName) + + McpOAuthCallback.cancelPending(mcpName) + + await expect(callbackPromise).rejects.toThrow("Authorization cancelled") + }) + + test("receiveCallback uses error over errorDescription when errorDescription is absent", async () => { + const oauthState = "error-no-desc" + const mcpName = "error-no-desc-server" + const callbackPromise = McpOAuthCallback.waitForCallback(oauthState, mcpName) + + const result = await McpOAuthCallback.receiveCallback({ + error: "server_error", + errorDescription: null, + state: oauthState, + }) + + expect(result.success).toBe(false) + expect(result.error).toBe("server_error") + + await expect(callbackPromise).rejects.toThrow("server_error") + }) + + test("waitForCallback registers mcpName-to-state reverse mapping", async () => { + const oauthState = "reverse-mapping-state" + const mcpName = "reverse-mapping-server" + const callbackPromise = McpOAuthCallback.waitForCallback(oauthState, mcpName) + + McpOAuthCallback.cancelPending(mcpName) + + await expect(callbackPromise).rejects.toThrow("Authorization cancelled") + }) +}) + describe("parseRedirectUri", () => { test("returns defaults when no URI provided", () => { const result = parseRedirectUri() @@ -23,10 +141,6 @@ describe("parseRedirectUri", () => { }) describe("McpOAuthCallback.ensureRunning", () => { - afterEach(async () => { - await McpOAuthCallback.stop() - }) - test("starts server with custom redirectUri port and path", async () => { await McpOAuthCallback.ensureRunning("http://127.0.0.1:18000/custom/callback") expect(McpOAuthCallback.isRunning()).toBe(true) diff --git a/packages/opencode/test/mcp/oauth-has-stored-tokens.test.ts b/packages/opencode/test/mcp/oauth-has-stored-tokens.test.ts new file mode 100644 index 000000000000..802dfe175e2a --- /dev/null +++ b/packages/opencode/test/mcp/oauth-has-stored-tokens.test.ts @@ -0,0 +1,115 @@ +import { test, expect, mock } from "bun:test" +import { McpAuth } from "../../src/mcp/auth" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" + +test("hasStoredTokens returns true when tokens exist", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await McpAuth.set( + "has-tokens-server", + { + tokens: { + accessToken: "valid-access-token", + refreshToken: "valid-refresh-token", + scope: "read", + }, + }, + "https://example.com/mcp", + ) + + const { MCP } = await import("../../src/mcp/index") + const result = await MCP.hasStoredTokens("has-tokens-server") + expect(result).toBe(true) + }, + }) +}) + +test("hasStoredTokens returns false when no tokens exist", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // No entry at all + const { MCP } = await import("../../src/mcp/index") + const result = await MCP.hasStoredTokens("no-entry-server") + expect(result).toBe(false) + }, + }) +}) + +test("hasStoredTokens returns false when entry exists but has no tokens", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await McpAuth.set("no-tokens-server", { + clientInfo: { clientId: "some-client" }, + }) + + const { MCP } = await import("../../src/mcp/index") + const result = await MCP.hasStoredTokens("no-tokens-server") + expect(result).toBe(false) + }, + }) +}) + +test("getAuthStatus returns authenticated for valid non-expired tokens", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const futureExpiry = Math.floor(Date.now() / 1000) + 3600 + await McpAuth.set("auth-valid", { + tokens: { + accessToken: "valid-token", + expiresAt: futureExpiry, + }, + }) + + const { MCP } = await import("../../src/mcp/index") + const status = await MCP.getAuthStatus("auth-valid") + expect(status).toBe("authenticated") + }, + }) +}) + +test("getAuthStatus returns expired for expired tokens", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const pastExpiry = Math.floor(Date.now() / 1000) - 3600 + await McpAuth.set("auth-expired", { + tokens: { + accessToken: "expired-token", + expiresAt: pastExpiry, + }, + }) + + const { MCP } = await import("../../src/mcp/index") + const status = await MCP.getAuthStatus("auth-expired") + expect(status).toBe("expired") + }, + }) +}) + +test("getAuthStatus returns not_authenticated when no entry exists", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { MCP } = await import("../../src/mcp/index") + const status = await MCP.getAuthStatus("nonexistent") + expect(status).toBe("not_authenticated") + }, + }) +}) diff --git a/packages/opencode/test/mcp/oauth-mcpauth.test.ts b/packages/opencode/test/mcp/oauth-mcpauth.test.ts new file mode 100644 index 000000000000..cdcd49410828 --- /dev/null +++ b/packages/opencode/test/mcp/oauth-mcpauth.test.ts @@ -0,0 +1,230 @@ +import { test, expect } from "bun:test" +import { McpAuth } from "../../src/mcp/auth" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" + +test("updateRedirectUrl persists redirect URL for an MCP server", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await McpAuth.updateRedirectUrl("test-redirect", "http://my-host:9999/mcp/oauth/callback") + + const entry = await McpAuth.get("test-redirect") + expect(entry).toBeDefined() + expect(entry!.redirectUrl).toBe("http://my-host:9999/mcp/oauth/callback") + }, + }) +}) + +test("updateRedirectUrl preserves other fields when updating", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await McpAuth.updateTokens( + "test-preserve", + { + accessToken: "some-token", + }, + "https://example.com/mcp", + ) + + await McpAuth.updateRedirectUrl("test-preserve", "http://new-host:8080/mcp/oauth/callback") + + const entry = await McpAuth.get("test-preserve") + expect(entry).toBeDefined() + expect(entry!.redirectUrl).toBe("http://new-host:8080/mcp/oauth/callback") + expect(entry!.tokens?.accessToken).toBe("some-token") + }, + }) +}) + +test("clearOAuthState removes oauthState field", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await McpAuth.updateOAuthState("test-clear-state", "some-oauth-state") + + let entry = await McpAuth.get("test-clear-state") + expect(entry?.oauthState).toBe("some-oauth-state") + + await McpAuth.clearOAuthState("test-clear-state") + + entry = await McpAuth.get("test-clear-state") + expect(entry?.oauthState).toBeUndefined() + }, + }) +}) + +test("clearOAuthState is a no-op when no entry exists", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await McpAuth.clearOAuthState("nonexistent-server") + + const entry = await McpAuth.get("nonexistent-server") + expect(entry).toBeUndefined() + }, + }) +}) + +test("getForUrl returns entry when serverUrl matches", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const serverUrl = "https://example.com/mcp" + await McpAuth.set( + "test-url-match", + { + tokens: { accessToken: "token-abc" }, + }, + serverUrl, + ) + + const entry = await McpAuth.getForUrl("test-url-match", serverUrl) + expect(entry).toBeDefined() + expect(entry!.tokens?.accessToken).toBe("token-abc") + }, + }) +}) + +test("getForUrl returns undefined when serverUrl does not match", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await McpAuth.set( + "test-url-mismatch", + { + tokens: { accessToken: "token-xyz" }, + }, + "https://original.com/mcp", + ) + + const entry = await McpAuth.getForUrl("test-url-mismatch", "https://different.com/mcp") + expect(entry).toBeUndefined() + }, + }) +}) + +test("getForUrl returns undefined when entry has no serverUrl", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Set entry without serverUrl + await McpAuth.set("test-no-server-url", { + tokens: { accessToken: "token-no-url" }, + }) + + const entry = await McpAuth.getForUrl("test-no-server-url", "https://example.com/mcp") + expect(entry).toBeUndefined() + }, + }) +}) + +test("updateOAuthState persists and retrieves oauth state", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await McpAuth.updateOAuthState("test-oauth-state", "abc123hex") + + const entry = await McpAuth.get("test-oauth-state") + expect(entry?.oauthState).toBe("abc123hex") + }, + }) +}) + +test("updateCodeVerifier persists code verifier", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await McpAuth.updateCodeVerifier("test-verifier", "verifier-xyz-789") + + const entry = await McpAuth.get("test-verifier") + expect(entry?.codeVerifier).toBe("verifier-xyz-789") + }, + }) +}) + +test("remove deletes the entire entry", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await McpAuth.set( + "test-remove", + { + tokens: { accessToken: "to-remove" }, + clientInfo: { clientId: "remove-client" }, + codeVerifier: "remove-verifier", + oauthState: "remove-state", + redirectUrl: "http://remove/mcp/oauth/callback", + }, + "https://remove.com/mcp", + ) + + await McpAuth.remove("test-remove") + + const entry = await McpAuth.get("test-remove") + expect(entry).toBeUndefined() + }, + }) +}) + +test("get returns undefined when no entry exists", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const entry = await McpAuth.get("nonexistent-mcp") + expect(entry).toBeUndefined() + }, + }) +}) + +test("updateTokens preserves other fields", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await McpAuth.updateClientInfo( + "test-tokens-preserve", + { + clientId: "client-abc", + }, + "https://example.com/mcp", + ) + + await McpAuth.updateTokens("test-tokens-preserve", { + accessToken: "new-token", + refreshToken: "new-refresh", + scope: "read", + }) + + const entry = await McpAuth.get("test-tokens-preserve") + expect(entry?.tokens?.accessToken).toBe("new-token") + expect(entry?.tokens?.refreshToken).toBe("new-refresh") + expect(entry?.tokens?.scope).toBe("read") + expect(entry?.clientInfo?.clientId).toBe("client-abc") + }, + }) +}) diff --git a/packages/opencode/test/mcp/oauth-provider.test.ts b/packages/opencode/test/mcp/oauth-provider.test.ts new file mode 100644 index 000000000000..4876ad9b5be5 --- /dev/null +++ b/packages/opencode/test/mcp/oauth-provider.test.ts @@ -0,0 +1,464 @@ +import { test, expect } from "bun:test" +import { McpOAuthProvider } from "../../src/mcp/oauth-provider" +import { McpAuth } from "../../src/mcp/auth" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" + +test("redirectUrl defaults to localhost callback URL when not provided", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const provider = new McpOAuthProvider( + "test-default", + "https://example.com/mcp", + {}, + { + onRedirect: async () => {}, + }, + ) + + expect(provider.redirectUrl).toBe("http://127.0.0.1:19876/mcp/oauth/callback") + }, + }) +}) + +test("redirectUrl uses custom URL when provided", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const customUrl = "http://my-host:9999/mcp/oauth/callback" + const provider = new McpOAuthProvider( + "test-custom-redirect", + "https://example.com/mcp", + {}, + { + onRedirect: async () => {}, + }, + customUrl, + ) + + expect(provider.redirectUrl).toBe(customUrl) + }, + }) +}) + +test("clientInformation returns config clientId when provided", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const provider = new McpOAuthProvider( + "test-config-client", + "https://example.com/mcp", + { + clientId: "pre-registered-client-id", + clientSecret: "secret", + }, + { + onRedirect: async () => {}, + }, + ) + + const info = await provider.clientInformation() + expect(info).toBeDefined() + expect(info!.client_id).toBe("pre-registered-client-id") + expect(info!.client_secret).toBe("secret") + }, + }) +}) + +test("clientInformation returns undefined when no stored info exists", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const provider = new McpOAuthProvider( + "test-no-client-info", + "https://example.com/mcp", + {}, + { + onRedirect: async () => {}, + }, + ) + + const info = await provider.clientInformation() + expect(info).toBeUndefined() + }, + }) +}) + +test("clientInformation returns stored client info when serverUrl matches and redirectUrl matches", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const serverUrl = "https://example.com/mcp" + const redirectUrl = "http://127.0.0.1:19876/mcp/oauth/callback" + await McpAuth.set( + "test-matched-url", + { + clientInfo: { clientId: "stored-client-id", clientSecret: "stored-secret" }, + redirectUrl, + }, + serverUrl, + ) + + const provider = new McpOAuthProvider( + "test-matched-url", + serverUrl, + {}, + { + onRedirect: async () => {}, + }, + ) + + const info = await provider.clientInformation() + expect(info).toBeDefined() + expect(info!.client_id).toBe("stored-client-id") + }, + }) +}) + +test("clientInformation returns undefined when serverUrl does not match", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await McpAuth.updateClientInfo( + "test-mismatched-url", + { + clientId: "old-client-id", + clientSecret: "old-secret", + }, + "https://old-server.com/mcp", + ) + + const provider = new McpOAuthProvider( + "test-mismatched-url", + "https://new-server.com/mcp", + {}, + { + onRedirect: async () => {}, + }, + ) + + const info = await provider.clientInformation() + expect(info).toBeUndefined() + }, + }) +}) + +test("clientInformation clears stale clientInfo when redirectUrl changed and no tokens exist", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const serverUrl = "https://example.com/mcp" + // Store clientInfo with an old redirectUrl + await McpAuth.set( + "test-stale-redirect", + { + clientInfo: { + clientId: "stale-client-id", + clientSecret: "stale-secret", + }, + redirectUrl: "http://old-host:19876/mcp/oauth/callback", + }, + serverUrl, + ) + + // Verify it was stored + const entryBefore = await McpAuth.get("test-stale-redirect") + expect(entryBefore?.clientInfo).toBeDefined() + expect(entryBefore?.redirectUrl).toBe("http://old-host:19876/mcp/oauth/callback") + + // Now create a provider with a different redirectUrl + const provider = new McpOAuthProvider( + "test-stale-redirect", + serverUrl, + {}, + { + onRedirect: async () => {}, + }, + "http://new-host:19876/mcp/oauth/callback", + ) + + const info = await provider.clientInformation() + // Should return undefined because redirectUrl changed and no tokens + expect(info).toBeUndefined() + + // Should have cleared the stale clientInfo + const entryAfter = await McpAuth.get("test-stale-redirect") + expect(entryAfter?.clientInfo).toBeUndefined() + }, + }) +}) + +test("clientInformation preserves clientInfo when tokens exist even if redirectUrl changed", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const serverUrl = "https://example.com/mcp" + // Store clientInfo AND tokens with an old redirectUrl + await McpAuth.set( + "test-preserve-with-tokens", + { + clientInfo: { + clientId: "preserved-client-id", + clientSecret: "preserved-secret", + }, + tokens: { + accessToken: "valid-token", + refreshToken: "refresh-token", + scope: "read", + }, + redirectUrl: "http://old-host:19876/mcp/oauth/callback", + }, + serverUrl, + ) + + // Create a provider with a different redirectUrl + const provider = new McpOAuthProvider( + "test-preserve-with-tokens", + serverUrl, + {}, + { + onRedirect: async () => {}, + }, + "http://new-host:19876/mcp/oauth/callback", + ) + + const info = await provider.clientInformation() + // Should return the stored clientInfo because tokens exist + expect(info).toBeDefined() + expect(info!.client_id).toBe("preserved-client-id") + + // Should NOT have cleared clientInfo + const entry = await McpAuth.get("test-preserve-with-tokens") + expect(entry?.clientInfo).toBeDefined() + }, + }) +}) + +test("saveClientInformation persists redirectUrl", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const serverUrl = "https://example.com/mcp" + const redirectUrl = "http://my-host:9999/mcp/oauth/callback" + const provider = new McpOAuthProvider( + "test-save-redirect", + serverUrl, + {}, + { + onRedirect: async () => {}, + }, + redirectUrl, + ) + + await provider.saveClientInformation({ + client_id: "new-client-id", + client_secret: "new-secret", + client_id_issued_at: 1234567890, + client_secret_expires_at: undefined, + redirect_uris: [provider.redirectUrl], + }) + + const entry = await McpAuth.get("test-save-redirect") + expect(entry?.clientInfo?.clientId).toBe("new-client-id") + expect(entry?.redirectUrl).toBe(redirectUrl) + expect(entry?.serverUrl).toBe(serverUrl) + }, + }) +}) + +test("tokens returns undefined when no entry exists", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const provider = new McpOAuthProvider( + "test-no-tokens", + "https://example.com/mcp", + {}, + { + onRedirect: async () => {}, + }, + ) + + const tokens = await provider.tokens() + expect(tokens).toBeUndefined() + }, + }) +}) + +test("tokens returns stored tokens when serverUrl matches", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const serverUrl = "https://example.com/mcp" + const expiresAt = Math.floor(Date.now() / 1000) + 3600 + await McpAuth.set( + "test-has-tokens", + { + tokens: { + accessToken: "valid-access", + refreshToken: "valid-refresh", + expiresAt, + scope: "read write", + }, + }, + serverUrl, + ) + + const provider = new McpOAuthProvider( + "test-has-tokens", + serverUrl, + {}, + { + onRedirect: async () => {}, + }, + ) + + const tokens = await provider.tokens() + expect(tokens).toBeDefined() + expect(tokens!.access_token).toBe("valid-access") + expect(tokens!.refresh_token).toBe("valid-refresh") + expect(tokens!.scope).toBe("read write") + expect(tokens!.token_type).toBe("Bearer") + }, + }) +}) + +test("tokens returns undefined when serverUrl does not match", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await McpAuth.set( + "test-tokens-mismatch", + { + tokens: { + accessToken: "valid-access", + }, + }, + "https://old-server.com/mcp", + ) + + const provider = new McpOAuthProvider( + "test-tokens-mismatch", + "https://new-server.com/mcp", + {}, + { + onRedirect: async () => {}, + }, + ) + + const tokens = await provider.tokens() + expect(tokens).toBeUndefined() + }, + }) +}) + +test("invalidateCredentials type=client removes clientInfo but keeps tokens", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const provider = new McpOAuthProvider( + "test-invalidate-client", + "https://example.com/mcp", + {}, + { + onRedirect: async () => {}, + }, + ) + + await McpAuth.set("test-invalidate-client", { + clientInfo: { clientId: "client-123", clientSecret: "secret-123" }, + tokens: { accessToken: "token-123" }, + }) + + await provider.invalidateCredentials("client") + + const entry = await McpAuth.get("test-invalidate-client") + expect(entry?.clientInfo).toBeUndefined() + expect(entry?.tokens).toBeDefined() + }, + }) +}) + +test("invalidateCredentials type=tokens removes tokens but keeps clientInfo", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const provider = new McpOAuthProvider( + "test-invalidate-tokens", + "https://example.com/mcp", + {}, + { + onRedirect: async () => {}, + }, + ) + + await McpAuth.set("test-invalidate-tokens", { + clientInfo: { clientId: "client-456" }, + tokens: { accessToken: "token-456" }, + }) + + await provider.invalidateCredentials("tokens") + + const entry = await McpAuth.get("test-invalidate-tokens") + expect(entry?.clientInfo).toBeDefined() + expect(entry?.tokens).toBeUndefined() + }, + }) +}) + +test("invalidateCredentials type=all removes the entire entry", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const provider = new McpOAuthProvider( + "test-invalidate-all", + "https://example.com/mcp", + {}, + { + onRedirect: async () => {}, + }, + ) + + await McpAuth.set("test-invalidate-all", { + clientInfo: { clientId: "client-789" }, + tokens: { accessToken: "token-789" }, + }) + + await provider.invalidateCredentials("all") + + const entry = await McpAuth.get("test-invalidate-all") + expect(entry).toBeUndefined() + }, + }) +}) diff --git a/packages/opencode/test/mcp/oauth-route.test.ts b/packages/opencode/test/mcp/oauth-route.test.ts new file mode 100644 index 000000000000..7a07520c686b --- /dev/null +++ b/packages/opencode/test/mcp/oauth-route.test.ts @@ -0,0 +1,86 @@ +import { test, expect, mock, beforeEach } from "bun:test" +import { McpOAuthCallback } from "../../src/mcp/oauth-callback" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" + +// Verify that the connect route logic correctlyHost header to build redirectUrl +// This tests the core business logic without the full Hono server + +beforeEach(async () => { + await McpOAuthCallback.stop() +}) + +test("Host header constructs correct redirectUrl pattern", () => { + // Verify the redirect URL format: http://{Host}/mcp/oauth/callback + const host = "my-host:9999" + const redirectUrl = `http://${host}/mcp/oauth/callback` + expect(redirectUrl).toBe("http://my-host:9999/mcp/oauth/callback") + + const hostWithoutPort = "192.168.1.100" + const redirectUrlNoPort = `http://${hostWithoutPort}/mcp/oauth/callback` + expect(redirectUrlNoPort).toBe("http://192.168.1.100/mcp/oauth/callback") +}) + +test("redirectUrl defaults to undefined when Host header is not provided", () => { + const host = undefined + const redirectUrl = host ? `http://${host}/mcp/oauth/callback` : undefined + expect(redirectUrl).toBeUndefined() +}) + +test("receiveCallback handles success case for the OAuth callback route", async () => { + const oauthState = "route-test-state" + const mcpName = "route-test-server" + const callbackPromise = McpOAuthCallback.waitForCallback(oauthState, mcpName) + + // Simulate what the GET /mcp/oauth/callback route does: extract params and call receiveCallback + const result = await McpOAuthCallback.receiveCallback({ + code: "auth-code-from-provider", + state: oauthState, + }) + + expect(result.success).toBe(true) + + const code = await callbackPromise + expect(code).toBe("auth-code-from-provider") + + await McpOAuthCallback.stop() +}) + +test("receiveCallback handles error case for the OAuth callback route", async () => { + const oauthState = "error-route-state" + const mcpName = "error-route-server" + const callbackPromise = McpOAuthCallback.waitForCallback(oauthState, mcpName) + + const result = await McpOAuthCallback.receiveCallback({ + error: "access_denied", + errorDescription: "The user denied the request", + state: oauthState, + }) + + expect(result.success).toBe(false) + expect(result.error).toBe("The user denied the request") + + await expect(callbackPromise).rejects.toThrow("The user denied the request") + await McpOAuthCallback.stop() +}) + +test("McpOAuthProvider redirectUrl with custom Host-based URL is used as-is", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { McpOAuthProvider } = await import("../../src/mcp/oauth-provider") + const customRedirect = "http://192.168.1.100:8080/mcp/oauth/callback" + const provider = new McpOAuthProvider( + "test-host-redirect", + "https://example.com/mcp", + {}, + { onRedirect: async () => {} }, + customRedirect, + ) + + expect(provider.redirectUrl).toBe(customRedirect) + }, + }) +}) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index d06a504d6c3c..e5b879049f46 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -68,6 +68,7 @@ import type { McpConnectResponses, McpDisconnectResponses, McpLocalConfig, + McpOauthCallbackResponses, McpRemoteConfig, McpStatusResponses, OutputFormat, @@ -3173,6 +3174,38 @@ export class Event extends HeyApiClient { } } +export class Oauth2 extends HeyApiClient { + /** + * OAuth callback + * + * Handle OAuth callback from browser for MCP server authentication. + */ + public callback( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/mcp/oauth/callback", + ...options, + ...params, + }) + } +} + export class Auth2 extends HeyApiClient { /** * Remove MCP OAuth @@ -3442,6 +3475,11 @@ export class Mcp extends HeyApiClient { }) } + private _oauth?: Oauth2 + get oauth(): Oauth2 { + return (this._oauth ??= new Oauth2({ client: this.client })) + } + private _auth?: Auth2 get auth(): Auth2 { return (this._auth ??= new Auth2({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index c1a77bfe8842..0fbed3667efb 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -4583,6 +4583,23 @@ export type McpAddResponses = { export type McpAddResponse = McpAddResponses[keyof McpAddResponses] +export type McpOauthCallbackData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/mcp/oauth/callback" +} + +export type McpOauthCallbackResponses = { + /** + * OAuth callback processed + */ + 200: unknown +} + export type McpAuthRemoveData = { body?: never path: { @@ -4649,6 +4666,10 @@ export type McpAuthStartResponses = { * URL to open in browser for authorization */ authorizationUrl: string + /** + * OAuth state for callback verification + */ + oauthState: string } } @@ -4741,9 +4762,14 @@ export type McpConnectData = { export type McpConnectResponses = { /** - * MCP server connected successfully + * MCP server connected successfully or needs OAuth */ - 200: boolean + 200: + | boolean + | { + needs_oauth: true + authorization_url: string + } } export type McpConnectResponse = McpConnectResponses[keyof McpConnectResponses]