diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 6c6314485..a13bdf9c7 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -688,7 +688,7 @@ function openGitHub() { openUrl("https://github.com/t8y2/dbx"); } function openMcpGuide() { - openUrl("https://github.com/t8y2/dbx/blob/main/docs/mcp-guide.md"); + openUrl("https://dbxio.com/cn/docs/mcp"); } function ensureQueryTab(): string { diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index ee0f5bd97..a111250b6 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -3,14 +3,19 @@ import { ref, watch, shallowRef, computed, onMounted } from "vue"; import type { EditorView as EditorViewType } from "@codemirror/view"; import { useI18n } from "vue-i18n"; import { + AlertTriangle, + CheckCircle2, CircleHelp, Cloud, + Copy, Download, ExternalLink, Loader2, + PackageSearch, Pencil, RefreshCw, Settings, + Terminal, Trash2, Upload, X, @@ -41,9 +46,11 @@ import { import { loadEditorTheme, editorFontTheme } from "@/lib/editorThemes"; import { isTauriRuntime } from "@/lib/tauriRuntime"; import { useTheme } from "@/composables/useTheme"; +import { copyToClipboard } from "@/lib/clipboard"; import { aiListModels, aiTestConnection, + checkMcpServerStatus, forgetWebdavSavedPassword, listSystemFonts, saveWebdavSavedPassword, @@ -52,6 +59,7 @@ import { webdavSyncTest, webdavSyncUpload, type AiModelInfo, + type McpServerStatus, type WebDavConfig, } from "@/lib/api"; import { eventToShortcut } from "@/lib/keyboardShortcuts"; @@ -425,6 +433,7 @@ type SettingsCategory = | "snippets" | "sync" | "ai" + | "mcp" | "security" | "about"; const settingsCategoryNav = computed<{ value: SettingsCategory; label: string }[]>(() => [ @@ -436,6 +445,7 @@ const settingsCategoryNav = computed<{ value: SettingsCategory; label: string }[ { value: "snippets", label: t("settings.snippetsTab") }, ...(isWeb ? [] : [{ value: "sync" as const, label: t("settings.syncTab") }]), { value: "ai", label: t("settings.aiTab") }, + ...(isWeb ? [] : [{ value: "mcp" as const, label: t("settings.mcpTab") }]), ...(isWeb ? [{ value: "security" as const, label: t("settings.securityTab") }] : []), { value: "about", label: t("settings.aboutTab") }, ]); @@ -469,6 +479,66 @@ function openExternalUrl(url: string) { } } +// ---------- MCP Server ---------- +const mcpStatus = ref(null); +const mcpStatusLoading = ref(false); +const mcpStatusError = ref(""); +const mcpCopied = ref<"" | "install" | "config">(""); + +const mcpRecommendedConfig = `{ + "mcpServers": { + "dbx": { + "command": "dbx-mcp-server" + } + } +}`; + +const mcpStatusTone = computed<"ok" | "warning" | "muted">(() => { + if (!mcpStatus.value) return "muted"; + if (!mcpStatus.value.installed || mcpStatus.value.update_available || mcpStatus.value.error) return "warning"; + return "ok"; +}); + +const mcpStatusLabel = computed(() => { + if (mcpStatusLoading.value) return t("settings.mcpChecking"); + if (mcpStatusError.value) return t("settings.mcpStatusError"); + if (!mcpStatus.value) return t("settings.mcpStatusUnknown"); + if (!mcpStatus.value.installed) return t("settings.mcpNotInstalled"); + if (mcpStatus.value.update_available) return t("settings.mcpUpdateAvailable"); + return t("settings.mcpReady"); +}); + +const mcpCommand = computed(() => { + if (!mcpStatus.value) return "npm install -g @dbx-app/mcp-server@latest"; + return mcpStatus.value.installed ? mcpStatus.value.update_command : mcpStatus.value.install_command; +}); + +async function refreshMcpStatus() { + if (mcpStatusLoading.value) return; + mcpStatusLoading.value = true; + mcpStatusError.value = ""; + try { + mcpStatus.value = await checkMcpServerStatus(); + } catch (e: any) { + mcpStatusError.value = e?.message || String(e); + } finally { + mcpStatusLoading.value = false; + } +} + +async function copyMcpText(kind: "install" | "config", value: string) { + mcpCopied.value = kind; + try { + await copyToClipboard(value); + } catch { + mcpCopied.value = ""; + return; + } + window.setTimeout(() => { + if (mcpCopied.value === kind) mcpCopied.value = ""; + }, 1500); +} + // ---------- WebDAV Sync ---------- const webdavEndpoint = ref(localStorage.getItem("dbx-webdav-endpoint") || ""); const webdavUsername = ref(localStorage.getItem("dbx-webdav-username") || ""); @@ -617,6 +687,7 @@ watch( webdavPassword.value = ""; await refreshWebDavPasswordStatus(); syncAiEditState(); + if (!isWeb && activeSettingsTab.value === "mcp") void refreshMcpStatus(); } }, { immediate: true }, @@ -629,6 +700,10 @@ watch(webdavRememberPassword, (val) => { localStorage.setItem("dbx-webdav-remember-password", String(val)); }); +watch(activeSettingsTab, (tab) => { + if (tab === "mcp" && !mcpStatus.value && !mcpStatusLoading.value) void refreshMcpStatus(); +}); + onMounted(() => { void refreshWebDavPasswordStatus(); }); @@ -1849,6 +1924,123 @@ watch( +
+
+
+
+
+ + +
+

{{ t("settings.mcpDescription") }}

+
+ + + + + {{ mcpStatusLabel }} + +
+
+ +
+
+
{{ t("settings.mcpCurrent") }}
+
+ {{ mcpStatus?.current_version ? `v${mcpStatus.current_version}` : t("settings.mcpVersionMissing") }} +
+
+
+
{{ t("settings.mcpLatest") }}
+
+ {{ mcpStatus?.latest_version ? `v${mcpStatus.latest_version}` : t("settings.mcpVersionUnknown") }} +
+
+
+
Node.js
+
+ {{ mcpStatus?.node_version || t("settings.mcpVersionUnknown") }} +
+
+
+
npm
+
+ {{ mcpStatus?.npm_available ? t("settings.mcpAvailable") : t("settings.mcpUnavailable") }} +
+
+
+ +
+ +
+ {{ mcpStatus.bin_path }} +
+
+ +
+ +
+
+ {{ mcpCommand }} +
+ +
+
+ +
+ +
+
{{ mcpRecommendedConfig }}
+ +
+
+ +
+ {{ mcpStatusError || mcpStatus?.error }} +
+ +
+ + {{ t("settings.mcpDetectionTiming") }} {{ t("settings.mcpNpmBoundary") }} +
+
+
@@ -2082,6 +2274,25 @@ watch( + + +
+ + + + { return get("/api/update/check"); } +export async function checkMcpServerStatus(): Promise { + return { + installed: false, + npm_available: false, + node_version: null, + current_version: null, + latest_version: null, + update_available: false, + bin_path: null, + install_command: "npm install -g @dbx-app/mcp-server@latest", + update_command: "npm install -g @dbx-app/mcp-server@latest", + error: "MCP Server status is only available in the desktop app.", + }; +} + export async function getSystemProxyUrl(): Promise { return null; } diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index 7fdab330d..27acaf8a8 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -915,6 +915,23 @@ export interface UpdateInfo { release_notes: string; } +export interface McpServerStatus { + installed: boolean; + npm_available: boolean; + node_version: string | null; + current_version: string | null; + latest_version: string | null; + update_available: boolean; + bin_path: string | null; + install_command: string; + update_command: string; + error: string | null; +} + +export async function checkMcpServerStatus(): Promise { + return invoke("check_mcp_server_status"); +} + export async function checkForUpdates(): Promise { return invoke("check_for_updates"); } diff --git a/src-tauri/src/commands/mcp.rs b/src-tauri/src/commands/mcp.rs new file mode 100644 index 000000000..03cf46fac --- /dev/null +++ b/src-tauri/src/commands/mcp.rs @@ -0,0 +1,105 @@ +use std::process::Command; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +const MCP_PACKAGE_NAME: &str = "@dbx-app/mcp-server"; +const MCP_LATEST_URL: &str = "https://registry.npmjs.org/@dbx-app%2fmcp-server/latest"; + +#[derive(Debug, Serialize)] +pub struct McpServerStatus { + pub installed: bool, + pub npm_available: bool, + pub node_version: Option, + pub current_version: Option, + pub latest_version: Option, + pub update_available: bool, + pub bin_path: Option, + pub install_command: String, + pub update_command: String, + pub error: Option, +} + +#[derive(Debug, Deserialize)] +struct NpmLatestPackage { + version: String, +} + +#[tauri::command] +pub async fn check_mcp_server_status() -> Result { + let npm_available = command_success("npm", &["--version"]); + let node_version = command_stdout("node", &["--version"]).ok().and_then(first_non_empty_line); + let current_version = if npm_available { installed_mcp_version() } else { None }; + let bin_path = locate_mcp_bin(); + let latest_version = fetch_latest_mcp_version().await.ok(); + let update_available = current_version + .as_deref() + .zip(latest_version.as_deref()) + .is_some_and(|(current, latest)| dbx_core::update::is_newer_version(latest, current)); + let error = if npm_available { None } else { Some("npm is not available in PATH.".to_string()) }; + + Ok(McpServerStatus { + installed: current_version.is_some() || bin_path.is_some(), + npm_available, + node_version, + current_version, + latest_version, + update_available, + bin_path, + install_command: format!("npm install -g {MCP_PACKAGE_NAME}@latest"), + update_command: format!("npm install -g {MCP_PACKAGE_NAME}@latest"), + error, + }) +} + +async fn fetch_latest_mcp_version() -> Result { + let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(10)).user_agent("dbx-mcp-status-checker"); + if let Some(proxy_url) = dbx_core::update::system_proxy_url() { + let proxy = reqwest::Proxy::all(&proxy_url).map_err(|e| format!("Invalid system proxy URL: {e}"))?; + builder = builder.proxy(proxy); + } + let client = builder.build().map_err(|e| format!("Failed to create HTTP client: {e}"))?; + let package = client + .get(MCP_LATEST_URL) + .send() + .await + .and_then(|r| r.error_for_status()) + .map_err(|e| format!("Failed to check MCP Server updates: {e}"))? + .json::() + .await + .map_err(|e| format!("Failed to parse MCP Server update response: {e}"))?; + Ok(package.version) +} + +fn installed_mcp_version() -> Option { + let stdout = command_stdout("npm", &["list", "-g", MCP_PACKAGE_NAME, "--json", "--depth=0"]).ok()?; + let value = serde_json::from_str::(&stdout).ok()?; + value + .get("dependencies") + .and_then(|dependencies| dependencies.get(MCP_PACKAGE_NAME)) + .and_then(|package| package.get("version")) + .and_then(|version| version.as_str()) + .map(ToOwned::to_owned) +} + +fn locate_mcp_bin() -> Option { + let (command, args): (&str, &[&str]) = + if cfg!(windows) { ("where", &["dbx-mcp-server"]) } else { ("which", &["dbx-mcp-server"]) }; + command_stdout(command, args).ok().and_then(first_non_empty_line) +} + +fn command_success(command: &str, args: &[&str]) -> bool { + Command::new(command).args(args).output().is_ok_and(|output| output.status.success()) +} + +fn command_stdout(command: &str, args: &[&str]) -> Result { + let output = Command::new(command).args(args).output().map_err(|e| e.to_string())?; + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).trim().to_string()); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +fn first_non_empty_line(value: String) -> Option { + value.lines().map(str::trim).find(|line| !line.is_empty()).map(ToOwned::to_owned) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index dea43b105..3f9e3458b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -12,6 +12,7 @@ pub mod deep_link; pub mod external_db; pub mod external_sql; pub mod history; +pub mod mcp; pub mod mcp_bridge; pub mod mongo_cmd; pub mod plugins; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0885c0f1d..32cef0f3a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -489,6 +489,7 @@ pub fn run() { commands::history::load_history, commands::history::clear_history, commands::history::delete_history_entry, + commands::mcp::check_mcp_server_status, commands::update::check_for_updates, commands::update::get_system_proxy_url, commands::transfer::start_transfer,