Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 36 additions & 3 deletions packages/app/src/components/dialog-select-mcp.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -25,6 +25,8 @@ export const DialogSelectMcp: Component = () => {
loading: false,
})

const [pendingAuthUrls, setPendingAuthUrls] = createStore<Record<string, string>>({})

createEffect(
on(
() => sync.data.mcp_ready,
Expand Down Expand Up @@ -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()
Expand All @@ -102,6 +109,32 @@ export const DialogSelectMcp: Component = () => {
if (!x || toggle.isPending) return
toggle.mutate(x.name)
}}
itemWrapper={(item, node) => (
<div class="w-full">
{node}
<Show when={pendingAuthUrls[item.name]}>
<div class="flex flex-col gap-3 p-4 bg-surface-raised-base rounded-sm">
<p class="text-14-regular text-text-base">{language.t("mcp.oauth.authorizeRequired")}</p>
<button
type="button"
class="self-end px-3 py-1.5 text-14-medium text-text-on-accent bg-accent-base rounded-md hover:bg-accent-base-hover transition-colors"
onClick={(e) => {
e.stopPropagation()
const url = pendingAuthUrls[item.name]
if (url) window.open(url, "_blank")
setPendingAuthUrls(
produce((draft) => {
delete draft[item.name]
}),
)
}}
>
{language.t("mcp.oauth.openBrowser")}
</button>
</div>
</Show>
</div>
)}
>
{(i) => {
const mcpStatus = () => sync.data.mcp[i.name]
Expand Down
97 changes: 65 additions & 32 deletions packages/app/src/components/status-popover-body.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -133,15 +133,24 @@ const useDefaultServerKey = (
}
}

const useMcpToggleMutation = () => {
const useMcpToggleMutation = (onOAuth: (name: string, url: string) => void) => {
const sync = useSync()
const sdk = useSDK()
const language = useLanguage()

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)
},
Expand All @@ -163,6 +172,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const language = useLanguage()
const navigate = useNavigate()
const sdk = useSDK()
const [pendingAuthUrls, setPendingAuthUrls] = createStore<Record<string, string>>({})

const [load, setLoad] = createStore({
lspDone: false,
Expand Down Expand Up @@ -232,7 +242,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
})
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
Expand Down Expand Up @@ -352,38 +362,61 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
{(name) => {
const status = () => mcpStatus(name)
const enabled = () => status() === "connected"
const authUrl = () => pendingAuthUrls[name]
return (
<button
type="button"
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
onClick={() => {
if (toggleMcp.isPending) return
toggleMcp.mutate(name)
}}
disabled={toggleMcp.isPending && toggleMcp.variables === name}
>
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": status() === "connected",
"bg-icon-critical-base": status() === "failed",
"bg-border-weak-base": status() === "disabled",
"bg-icon-warning-base":
status() === "needs_auth" || status() === "needs_client_registration",
<>
<button
type="button"
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
onClick={() => {
if (toggleMcp.isPending) return
toggleMcp.mutate(name)
}}
/>
<span class="text-14-regular text-text-base truncate flex-1">{name}</span>
<div onClick={(event) => event.stopPropagation()}>
<Switch
checked={enabled()}
disabled={toggleMcp.isPending && toggleMcp.variables === name}
onChange={() => {
if (toggleMcp.isPending) return
toggleMcp.mutate(name)
disabled={toggleMcp.isPending && toggleMcp.variables === name}
>
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": status() === "connected",
"bg-icon-critical-base": status() === "failed",
"bg-border-weak-base": status() === "disabled",
"bg-icon-warning-base":
status() === "needs_auth" || status() === "needs_client_registration",
}}
/>
</div>
</button>
<span class="text-14-regular text-text-base truncate flex-1">{name}</span>
<div onClick={(event) => event.stopPropagation()}>
<Switch
checked={enabled()}
disabled={toggleMcp.isPending && toggleMcp.variables === name}
onChange={() => {
if (toggleMcp.isPending) return
toggleMcp.mutate(name)
}}
/>
</div>
</button>
<Show when={authUrl()}>
<div class="flex flex-col gap-2 p-3 bg-surface-raised-base rounded-sm">
<p class="text-13-regular text-text-base">{language.t("mcp.oauth.authorizeRequired")}</p>
<button
type="button"
class="self-end px-3 py-1 text-13-medium text-text-on-accent bg-accent-base rounded-md hover:bg-accent-base-hover transition-colors"
onClick={() => {
const url = authUrl()
if (url) window.open(url, "_blank")
setPendingAuthUrls(
produce((draft) => {
delete draft[name]
}),
)
}}
>
{language.t("mcp.oauth.openBrowser")}
</button>
</div>
</Show>
</>
)
}}
</For>
Expand Down
7 changes: 7 additions & 0 deletions packages/app/src/context/global-sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/i18n/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": "لم يتم العثور على مجلدات",
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/i18n/br.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions packages/app/src/i18n/bs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions packages/app/src/i18n/da.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions packages/app/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions packages/app/src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions packages/app/src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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é",
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": "フォルダが見つかりません",
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": "폴더 없음",
Expand Down
4 changes: 4 additions & 0 deletions packages/app/src/i18n/no.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions packages/app/src/i18n/pl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions packages/app/src/i18n/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": "Поиск папок",
Expand Down
4 changes: 4 additions & 0 deletions packages/app/src/i18n/th.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": "ค้นหาโฟลเดอร์",
Expand Down
4 changes: 4 additions & 0 deletions packages/app/src/i18n/tr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": "搜索文件夹",
Expand Down
Loading
Loading