Skip to content
Merged
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
767 changes: 626 additions & 141 deletions src-tauri/src/commands/mcp.rs

Large diffs are not rendered by default.

7 changes: 3 additions & 4 deletions src-tauri/src/web/handlers/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use serde_json::Value;
use crate::app_error::AppCommandError;
use crate::commands::mcp as mcp_commands;
use crate::commands::mcp::{
LocalMcpServer, McpAppType, McpMarketplaceItem, McpMarketplaceProvider,
LocalMcpScan, LocalMcpServer, McpAppType, McpMarketplaceItem, McpMarketplaceProvider,
McpMarketplaceServerDetail,
};

Expand Down Expand Up @@ -66,9 +66,8 @@ pub struct RemoveServerParams {
// Handlers
// ---------------------------------------------------------------------------

pub async fn mcp_scan_local() -> Result<Json<Vec<LocalMcpServer>>, AppCommandError> {
let result = mcp_commands::mcp_scan_local().await?;
Ok(Json(result))
pub async fn mcp_scan_local() -> Json<LocalMcpScan> {
Json(mcp_commands::mcp_scan_local().await)
}

pub async fn mcp_list_marketplaces() -> Result<Json<Vec<McpMarketplaceProvider>>, AppCommandError> {
Expand Down
87 changes: 78 additions & 9 deletions src/components/settings/mcp-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import { normalizeMcpType } from "@/lib/mcp-types"
import { cn } from "@/lib/utils"
import type {
LocalMcpServer,
LocalMcpSourceWarning,
McpAppType,
McpMarketplaceItem,
McpMarketplaceInstallOption,
Expand Down Expand Up @@ -104,6 +105,21 @@ const APP_OPTIONS: { value: McpAppType; label: string }[] = [
{ value: "antigravity", label: "Google Antigravity" },
]

// The backend SCANS one more agent than it lets you assign to: OpenClaw is read
// back so existing entries survive, but is not an assignable target (see the
// note in APP_OPTIONS). A scan warning can still name it, so it needs a label.
const SCAN_ONLY_APP_LABELS: Partial<Record<McpAppType, string>> = {
open_claw: "OpenClaw",
}

function appLabel(app: McpAppType): string {
return (
APP_OPTIONS.find((option) => option.value === app)?.label ??
SCAN_ONLY_APP_LABELS[app] ??
app
)
}

function isObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value)
}
Expand Down Expand Up @@ -338,6 +354,9 @@ export function McpSettings() {
const [selection, setSelection] = useState<Selection>(null)

const [installedServers, setInstalledServers] = useState<LocalMcpServer[]>([])
const [sourceWarnings, setSourceWarnings] = useState<LocalMcpSourceWarning[]>(
[]
)
const [localFilter, setLocalFilter] = useState("")

const [providers, setProviders] = useState<McpMarketplaceProvider[]>([])
Expand Down Expand Up @@ -409,6 +428,13 @@ export function McpSettings() {
[localSpecText]
)

// A scan that could not read every agent is fine to LIST from but not to
// reassign from: the app checkboxes it seeds drive removals, so an agent
// missing only because its file was unreadable would be stripped. The
// backend refuses such a save; the UI blocks composing one, which also stops
// the draft outliving the repair (fix the file, hit Refresh, then edit).
const scanDegraded = sourceWarnings.length > 0

const filteredLocalServers = useMemo(() => {
const q = localFilter.trim().toLowerCase()
if (!q) return installedServers
Expand All @@ -420,28 +446,30 @@ export function McpSettings() {
}, [installedServers, localFilter, mcpT])

const refreshLocalServers = useCallback(async () => {
const servers = await mcpScanLocal()
setInstalledServers(servers)
return servers
const scan = await mcpScanLocal()
setInstalledServers(scan.servers)
setSourceWarnings(scan.warnings)
return scan.servers
}, [])

const loadInitial = useCallback(async () => {
setLoading(true)
setLoadingError(null)

try {
const [servers, marketProviders] = await Promise.all([
const [scan, marketProviders] = await Promise.all([
mcpScanLocal(),
mcpListMarketplaces(),
])
setInstalledServers(servers)
setInstalledServers(scan.servers)
setSourceWarnings(scan.warnings)
setProviders(marketProviders)
setSelectedProvider(
(current) => current || marketProviders[0]?.id || "official_registry"
)

if (servers[0]) {
setSelection({ kind: "local", id: servers[0].id })
if (scan.servers[0]) {
setSelection({ kind: "local", id: scan.servers[0].id })
}
} catch (err) {
const message = toLocalizedErrorMessage(err, mcpT)
Expand Down Expand Up @@ -1082,6 +1110,21 @@ export function McpSettings() {
</div>
) : null}

{/* One agent's config being unreadable hides only that agent's
servers — the rest of the list below is still real, so this
is a warning beside it rather than an error instead of it. */}
{sourceWarnings.map((warning) => (
<div
key={warning.app}
className="rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs text-amber-500 break-all"
>
{t("local.sourceUnreadable", {
app: appLabel(warning.app),
message: warning.message,
})}
</div>
))}

<div className="flex-1 min-h-0 overflow-auto space-y-1">
{filteredLocalServers.length === 0 ? (
<div className="rounded-md border border-dashed p-3 text-xs text-muted-foreground">
Expand Down Expand Up @@ -1411,6 +1454,15 @@ export function McpSettings() {
</div>
) : null}

{/* Creating writes through the same command, which refuses while
any agent's config is unreadable — an id that already exists
in the unread one would be assigned away from it. */}
{scanDegraded ? (
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-600 dark:text-amber-400">
{t("local.saveBlockedByUnreadableSource")}
</div>
) : null}

<div className="flex justify-end gap-2">
<Button
variant="outline"
Expand All @@ -1425,7 +1477,10 @@ export function McpSettings() {
console.error("[Settings] create local MCP failed:", err)
})
}}
disabled={Boolean(runningAction?.startsWith("create:"))}
disabled={
scanDegraded ||
Boolean(runningAction?.startsWith("create:"))
}
>
{runningAction?.startsWith("create:") ? (
<>
Expand Down Expand Up @@ -1517,14 +1572,28 @@ export function McpSettings() {
</div>
) : null}

{/* The checkboxes above were seeded from a scan that could not
read every agent, so an agent that holds this server may be
showing as unchecked — and saving means "remove it from every
unchecked agent". The backend refuses such a save too; this
keeps the user from composing one whose stale draft would
still be accepted once they repair the file out of band. */}
{scanDegraded ? (
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-600 dark:text-amber-400">
{t("local.saveBlockedByUnreadableSource")}
</div>
) : null}

<div className="flex justify-end">
<Button
onClick={() => {
saveLocalServer().catch((err) => {
console.error("[Settings] save local MCP failed:", err)
})
}}
disabled={runningAction === `save:${selectedLocal.id}`}
disabled={
scanDegraded || runningAction === `save:${selectedLocal.id}`
}
>
{runningAction === `save:${selectedLocal.id}` ? (
<>
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/messages/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,8 @@
"local": {
"filterPlaceholder": "تصفية MCP المحلي...",
"loadFailed": "فشل التحميل: {message}",
"sourceUnreadable": "تعذّرت قراءة إعدادات MCP الخاصة بـ {app}، لذا تم إخفاء خوادمها: {message}",
"saveBlockedByUnreadableSource": "أصلح هذا الملف أو احذفه ثم حدّث الصفحة قبل الحفظ: تُحتسب الإسنادات من الوكلاء الذين تمكّنت الصفحة من قراءتهم، لذا قد يؤدي الحفظ الآن إلى إزالة هذا الخادم من وكيل تعذّرت قراءته.",
"empty": "لم يتم اكتشاف MCP محلي.",
"description": "يمكن تعديل إعداد MCP المحلي وحفظه مباشرة.",
"enabledApps": "التطبيقات المفعلة",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,8 @@
"local": {
"filterPlaceholder": "Lokales MCP filtern...",
"loadFailed": "Laden fehlgeschlagen: {message}",
"sourceUnreadable": "Die MCP-Konfiguration von {app} konnte nicht gelesen werden, daher werden ihre Server ausgeblendet: {message}",
"saveBlockedByUnreadableSource": "Reparieren oder entfernen Sie diese Datei und aktualisieren Sie, bevor Sie speichern: Zuweisungen werden aus den Agenten berechnet, die diese Seite lesen konnte, sodass ein Speichern jetzt diesen Server aus einem nicht lesbaren Agenten entfernen könnte.",
"empty": "Kein lokales MCP erkannt.",
"description": "Die lokale MCP-Konfiguration kann direkt bearbeitet und gespeichert werden.",
"enabledApps": "Aktivierte Apps",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,8 @@
"local": {
"filterPlaceholder": "Filter local MCP...",
"loadFailed": "Load failed: {message}",
"sourceUnreadable": "Could not read the MCP config for {app}, so its servers are hidden: {message}",
"saveBlockedByUnreadableSource": "Fix or remove that file and refresh before saving: assignments are computed from the agents this page could read, so saving now could drop this server from one it could not.",
"empty": "No local MCP detected.",
"description": "Local MCP configuration can be edited and saved directly.",
"enabledApps": "Enabled Apps",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,8 @@
"local": {
"filterPlaceholder": "Filtrar MCP local...",
"loadFailed": "Error de carga: {message}",
"sourceUnreadable": "No se pudo leer la configuración MCP de {app}, por lo que sus servidores están ocultos: {message}",
"saveBlockedByUnreadableSource": "Corrige o elimina ese archivo y actualiza antes de guardar: las asignaciones se calculan a partir de los agentes que esta página pudo leer, por lo que guardar ahora podría quitar este servidor de uno que no pudo leer.",
"empty": "No se detectó MCP local.",
"description": "La configuración de MCP local se puede editar y guardar directamente.",
"enabledApps": "Apps habilitadas",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,8 @@
"local": {
"filterPlaceholder": "Filtrer les MCP locaux...",
"loadFailed": "Échec du chargement : {message}",
"sourceUnreadable": "Impossible de lire la configuration MCP de {app}, ses serveurs sont donc masqués : {message}",
"saveBlockedByUnreadableSource": "Corrigez ou supprimez ce fichier puis actualisez avant d'enregistrer : les affectations sont calculées à partir des agents que cette page a pu lire, donc enregistrer maintenant pourrait retirer ce serveur d'un agent illisible.",
"empty": "Aucun MCP local détecté.",
"description": "La configuration MCP locale peut être modifiée et enregistrée directement.",
"enabledApps": "Applications activées",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/messages/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,8 @@
"local": {
"filterPlaceholder": "ローカルMCPを絞り込み...",
"loadFailed": "読み込み失敗: {message}",
"sourceUnreadable": "{app} の MCP 設定を読み取れませんでした。そのサーバーは表示されません: {message}",
"saveBlockedByUnreadableSource": "保存する前にそのファイルを修正または削除して再読み込みしてください。割り当てはこのページが読み取れたエージェントから計算されるため、今保存すると読み取れなかったエージェントからこのサーバーが削除される可能性があります。",
"empty": "ローカルMCPが見つかりません。",
"description": "ローカルMCP設定は直接編集して保存できます。",
"enabledApps": "有効なアプリ",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/messages/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,8 @@
"local": {
"filterPlaceholder": "로컬 MCP 필터...",
"loadFailed": "로드 실패: {message}",
"sourceUnreadable": "{app}의 MCP 구성을 읽을 수 없어 해당 서버가 표시되지 않습니다: {message}",
"saveBlockedByUnreadableSource": "저장하기 전에 해당 파일을 수정하거나 삭제한 뒤 새로 고치세요. 할당은 이 페이지가 읽을 수 있었던 에이전트를 기준으로 계산되므로, 지금 저장하면 읽지 못한 에이전트에서 이 서버가 제거될 수 있습니다.",
"empty": "감지된 로컬 MCP가 없습니다.",
"description": "로컬 MCP 구성은 직접 수정하고 저장할 수 있습니다.",
"enabledApps": "활성화된 앱",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/messages/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,8 @@
"local": {
"filterPlaceholder": "Filtrar MCP local...",
"loadFailed": "Falha ao carregar: {message}",
"sourceUnreadable": "Não foi possível ler a configuração MCP de {app}, portanto os seus servidores estão ocultos: {message}",
"saveBlockedByUnreadableSource": "Corrija ou remova esse ficheiro e atualize antes de guardar: as atribuições são calculadas a partir dos agentes que esta página conseguiu ler, pelo que guardar agora poderá remover este servidor de um que não conseguiu ler.",
"empty": "Nenhum MCP local detectado.",
"description": "A configuração local de MCP pode ser editada e salva diretamente.",
"enabledApps": "Apps habilitados",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/messages/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,8 @@
"local": {
"filterPlaceholder": "筛选本地 MCP...",
"loadFailed": "加载失败:{message}",
"sourceUnreadable": "无法读取 {app} 的 MCP 配置,其服务器已隐藏:{message}",
"saveBlockedByUnreadableSource": "保存前请先修复或删除该文件并刷新:分配结果按本页能读到的智能体计算,现在保存可能会把该服务器从读不到的智能体中移除。",
"empty": "当前未检测到本地 MCP。",
"description": "本地 MCP 配置可直接编辑并保存。",
"enabledApps": "启用应用",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/messages/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,8 @@
"local": {
"filterPlaceholder": "篩選本地 MCP...",
"loadFailed": "載入失敗:{message}",
"sourceUnreadable": "無法讀取 {app} 的 MCP 設定,其伺服器已隱藏:{message}",
"saveBlockedByUnreadableSource": "儲存前請先修復或刪除該檔案並重新整理:指派結果依本頁能讀取到的智慧體計算,現在儲存可能會將該伺服器從讀不到的智慧體中移除。",
"empty": "目前未檢測到本地 MCP。",
"description": "本地 MCP 配置可直接編輯並儲存。",
"enabledApps": "啟用應用",
Expand Down
3 changes: 2 additions & 1 deletion src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ import type {
GitHubAccountsSettings,
GitHubTokenValidation,
McpAppType,
LocalMcpScan,
LocalMcpServer,
McpMarketplaceProvider,
McpMarketplaceItem,
Expand Down Expand Up @@ -1896,7 +1897,7 @@ export async function deleteAccountToken(accountId: string): Promise<void> {
return getTransport().call("delete_account_token", { accountId })
}

export async function mcpScanLocal(): Promise<LocalMcpServer[]> {
export async function mcpScanLocal(): Promise<LocalMcpScan> {
return getTransport().call("mcp_scan_local")
}

Expand Down
3 changes: 2 additions & 1 deletion src/lib/tauri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import type {
GitHubAccountsSettings,
GitHubTokenValidation,
McpAppType,
LocalMcpScan,
LocalMcpServer,
McpMarketplaceProvider,
McpMarketplaceItem,
Expand Down Expand Up @@ -441,7 +442,7 @@ export async function deleteAccountToken(accountId: string): Promise<void> {
return invoke("delete_account_token", { accountId })
}

export async function mcpScanLocal(): Promise<LocalMcpServer[]> {
export async function mcpScanLocal(): Promise<LocalMcpScan> {
return invoke("mcp_scan_local")
}

Expand Down
16 changes: 16 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3794,6 +3794,22 @@ export interface LocalMcpServer {
apps: McpAppType[]
}

/** One agent whose MCP config the scan could not read. */
export interface LocalMcpSourceWarning {
app: McpAppType
message: string
}

/**
* A local MCP scan: everything codeg could read, plus a warning per source it
* could not. A single unreadable config degrades to a warning instead of
* failing the whole scan (issue #632).
*/
export interface LocalMcpScan {
servers: LocalMcpServer[]
warnings: LocalMcpSourceWarning[]
}

export interface McpMarketplaceProvider {
id: string
name: string
Expand Down
Loading