feat(settings): add MCP server status check

This commit is contained in:
t8y2 2026-06-02 00:14:03 +08:00
parent 088eaccecc
commit 17504be027
11 changed files with 422 additions and 1 deletions

View File

@ -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 {

View File

@ -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<McpServerStatus | null>(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(
</div>
</section>
<section v-else-if="activeSettingsTab === 'mcp' && !isWeb" class="flex flex-col gap-5 py-2">
<div class="rounded-md border bg-muted/20 p-4">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 space-y-2">
<div class="flex items-center gap-2">
<PackageSearch class="h-4 w-4 text-muted-foreground" />
<Label class="text-base">{{ t("settings.mcpTitle") }}</Label>
</div>
<p class="text-sm text-muted-foreground">{{ t("settings.mcpDescription") }}</p>
</div>
<Badge
variant="outline"
class="shrink-0 rounded-md"
:class="
mcpStatusTone === 'ok'
? 'border-green-500/40 text-green-600 dark:text-green-400'
: mcpStatusTone === 'warning'
? 'border-amber-500/40 text-amber-600 dark:text-amber-400'
: 'text-muted-foreground'
"
>
<Loader2 v-if="mcpStatusLoading" class="mr-1 h-3 w-3 animate-spin" />
<CheckCircle2 v-else-if="mcpStatusTone === 'ok'" class="mr-1 h-3 w-3" />
<AlertTriangle v-else-if="mcpStatusTone === 'warning'" class="mr-1 h-3 w-3" />
{{ mcpStatusLabel }}
</Badge>
</div>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div class="rounded-md border p-3">
<div class="text-xs font-medium uppercase text-muted-foreground">{{ t("settings.mcpCurrent") }}</div>
<div class="mt-2 font-mono text-sm">
{{ mcpStatus?.current_version ? `v${mcpStatus.current_version}` : t("settings.mcpVersionMissing") }}
</div>
</div>
<div class="rounded-md border p-3">
<div class="text-xs font-medium uppercase text-muted-foreground">{{ t("settings.mcpLatest") }}</div>
<div class="mt-2 font-mono text-sm">
{{ mcpStatus?.latest_version ? `v${mcpStatus.latest_version}` : t("settings.mcpVersionUnknown") }}
</div>
</div>
<div class="rounded-md border p-3">
<div class="text-xs font-medium uppercase text-muted-foreground">Node.js</div>
<div class="mt-2 font-mono text-sm">
{{ mcpStatus?.node_version || t("settings.mcpVersionUnknown") }}
</div>
</div>
<div class="rounded-md border p-3">
<div class="text-xs font-medium uppercase text-muted-foreground">npm</div>
<div class="mt-2 font-mono text-sm">
{{ mcpStatus?.npm_available ? t("settings.mcpAvailable") : t("settings.mcpUnavailable") }}
</div>
</div>
</div>
<div v-if="mcpStatus?.bin_path" class="space-y-2">
<Label>{{ t("settings.mcpBinPath") }}</Label>
<div class="rounded-md border bg-muted/20 px-3 py-2 font-mono text-xs text-muted-foreground">
{{ mcpStatus.bin_path }}
</div>
</div>
<div class="space-y-2">
<Label>{{
mcpStatus?.installed ? t("settings.mcpUpdateCommand") : t("settings.mcpInstallCommand")
}}</Label>
<div class="flex min-w-0 items-center gap-2">
<div class="min-w-0 flex-1 rounded-md border bg-background px-3 py-2 font-mono text-xs">
{{ mcpCommand }}
</div>
<Button
type="button"
variant="outline"
size="icon"
:title="t('common.copy')"
@click="copyMcpText('install', mcpCommand)"
>
<CheckCircle2 v-if="mcpCopied === 'install'" class="h-4 w-4 text-green-500" />
<Copy v-else class="h-4 w-4" />
</Button>
</div>
</div>
<div class="space-y-2">
<Label>{{ t("settings.mcpConfig") }}</Label>
<div class="relative rounded-md border bg-background p-3">
<pre
class="overflow-x-auto whitespace-pre text-xs leading-relaxed"
><code>{{ mcpRecommendedConfig }}</code></pre>
<Button
type="button"
variant="outline"
size="icon"
class="absolute right-2 top-2 h-7 w-7"
:title="t('common.copy')"
@click="copyMcpText('config', mcpRecommendedConfig)"
>
<CheckCircle2 v-if="mcpCopied === 'config'" class="h-3.5 w-3.5 text-green-500" />
<Copy v-else class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div
v-if="mcpStatus?.error || mcpStatusError"
class="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300"
>
{{ mcpStatusError || mcpStatus?.error }}
</div>
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<Terminal class="h-3.5 w-3.5" />
<span>{{ t("settings.mcpDetectionTiming") }} {{ t("settings.mcpNpmBoundary") }}</span>
</div>
</section>
<section v-else-if="activeSettingsTab === 'security' && isWeb" class="flex flex-col gap-5 py-2">
<div class="space-y-3">
<Label class="text-base">{{ t("auth.changePassword") }}</Label>
@ -2082,6 +2274,25 @@ watch(
</Button>
</DialogFooter>
<DialogFooter
v-else-if="activeSettingsTab === 'mcp' && !isWeb"
class="mx-0 mb-0 shrink-0 rounded-none border-t border-border/60 bg-transparent px-0 pb-0 pt-3"
>
<Button variant="outline" @click="emit('update:open', false)">
{{ t("common.close") }}
</Button>
<div class="flex-1" />
<Button variant="outline" :disabled="mcpStatusLoading" @click="refreshMcpStatus">
<Loader2 v-if="mcpStatusLoading" class="mr-1 h-3 w-3 animate-spin" />
<RefreshCw v-else class="mr-1 h-3 w-3" />
{{ t("settings.mcpRefresh") }}
</Button>
<Button variant="outline" @click="openExternalUrl('https://dbxio.com/cn/docs/mcp')">
<ExternalLink class="mr-1 h-3 w-3" />
{{ t("settings.mcpGuide") }}
</Button>
</DialogFooter>
<DialogFooter
v-else-if="activeSettingsTab === 'security' && isWeb"
class="mx-0 mb-0 shrink-0 rounded-none border-t border-border/60 bg-transparent px-0 pb-0 pt-3"

View File

@ -1496,6 +1496,7 @@ export default {
snippetsTab: "Snippets",
syncTab: "Sync",
aiTab: "AI",
mcpTab: "MCP",
jdbcTab: "JDBC Drivers",
securityTab: "Security",
aboutTab: "About",
@ -1651,6 +1652,28 @@ export default {
jdbcImportSuccess: "Imported {count} driver file(s)",
jdbcDeleteSuccess: "Driver removed",
jdbcNoDrivers: "No JDBC drivers imported yet.",
mcpTitle: "MCP Server",
mcpDescription: "Check the DBX MCP Server installation and version used by Claude Code, Cursor, and other agents.",
mcpChecking: "Checking",
mcpStatusUnknown: "Not checked",
mcpStatusError: "Check failed",
mcpReady: "Ready",
mcpNotInstalled: "Not installed",
mcpUpdateAvailable: "Update available",
mcpCurrent: "Current version",
mcpLatest: "Latest version",
mcpVersionMissing: "No global install detected",
mcpVersionUnknown: "Unknown",
mcpAvailable: "Available",
mcpUnavailable: "Unavailable",
mcpBinPath: "Command path",
mcpInstallCommand: "Install command",
mcpUpdateCommand: "Upgrade command",
mcpConfig: "Claude Code config",
mcpDetectionTiming: "DBX checks automatically when this page opens; use Check again to refresh.",
mcpNpmBoundary: "DBX only checks and explains MCP status; installation and upgrades still run through npm.",
mcpRefresh: "Check again",
mcpGuide: "MCP guide",
aboutDescription: "An open-source, lightweight database management tool.",
community: "Community",
qqGroup: "QQ Group",

View File

@ -1386,6 +1386,7 @@ export default {
snippetsTab: "Fragmentos",
syncTab: "Sync",
aiTab: "IA",
mcpTab: "MCP",
jdbcTab: "Drivers JDBC",
securityTab: "Seguridad",
aboutTab: "Acerca de",
@ -1540,6 +1541,28 @@ export default {
jdbcImportSuccess: "Se importaron {count} archivo(s) de driver",
jdbcDeleteSuccess: "Driver eliminado",
jdbcNoDrivers: "Aún no se han importado drivers JDBC.",
mcpTitle: "MCP Server",
mcpDescription: "Comprueba la instalación y versión de DBX MCP Server para Claude Code, Cursor y otros agentes.",
mcpChecking: "Comprobando",
mcpStatusUnknown: "Sin comprobar",
mcpStatusError: "Error al comprobar",
mcpReady: "Listo",
mcpNotInstalled: "No instalado",
mcpUpdateAvailable: "Actualización disponible",
mcpCurrent: "Versión actual",
mcpLatest: "Última versión",
mcpVersionMissing: "No se detectó instalación global",
mcpVersionUnknown: "Desconocida",
mcpAvailable: "Disponible",
mcpUnavailable: "No disponible",
mcpBinPath: "Ruta del comando",
mcpInstallCommand: "Comando de instalación",
mcpUpdateCommand: "Comando de actualización",
mcpConfig: "Configuración de Claude Code",
mcpDetectionTiming: "DBX comprueba automáticamente al abrir esta página; usa Comprobar de nuevo para actualizar.",
mcpNpmBoundary: "DBX solo comprueba y explica el estado de MCP; la instalación y actualización siguen usando npm.",
mcpRefresh: "Comprobar de nuevo",
mcpGuide: "Guía MCP",
aboutDescription: "Una herramienta de administración de bases de datos liviana y de código abierto.",
community: "Comunidad",
qqGroup: "Grupo QQ",

View File

@ -1469,6 +1469,7 @@ export default {
snippetsTab: "代码片段",
syncTab: "同步",
aiTab: "AI",
mcpTab: "MCP",
jdbcTab: "JDBC 驱动",
securityTab: "安全",
aboutTab: "关于我们",
@ -1612,6 +1613,28 @@ export default {
jdbcImportSuccess: "已导入 {count} 个驱动文件",
jdbcDeleteSuccess: "驱动已删除",
jdbcNoDrivers: "还没有导入 JDBC 驱动。",
mcpTitle: "MCP Server",
mcpDescription: "检查 Claude Code、Cursor 等编程助手使用的 DBX MCP Server 安装与版本状态。",
mcpChecking: "检查中",
mcpStatusUnknown: "未检查",
mcpStatusError: "检查失败",
mcpReady: "可用",
mcpNotInstalled: "未安装",
mcpUpdateAvailable: "有更新",
mcpCurrent: "当前版本",
mcpLatest: "最新版本",
mcpVersionMissing: "未检测到全局安装",
mcpVersionUnknown: "未知",
mcpAvailable: "可用",
mcpUnavailable: "不可用",
mcpBinPath: "命令路径",
mcpInstallCommand: "安装命令",
mcpUpdateCommand: "升级命令",
mcpConfig: "Claude Code 配置",
mcpDetectionTiming: "打开此页时会自动检测,点击重新检查可刷新。",
mcpNpmBoundary: "DBX 只检测和提示 MCP 状态;安装与升级仍由 npm 完成。",
mcpRefresh: "重新检查",
mcpGuide: "MCP 指南",
aboutDescription: "开源、轻量的数据库管理工具。",
community: "社区",
qqGroup: "QQ 群",

View File

@ -227,6 +227,7 @@ export const clearHistory = forward("clearHistory");
export const deleteHistoryEntry = forward("deleteHistoryEntry");
// Updates
export const checkMcpServerStatus = forward("checkMcpServerStatus");
export const checkForUpdates = forward("checkForUpdates");
export const getSystemProxyUrl = forward("getSystemProxyUrl");
export const getAppVersion = forward("getAppVersion");
@ -256,6 +257,7 @@ export type {
WebDavPasswordStatus,
WebDavSyncSummary,
WebDavDownloadResult,
McpServerStatus,
UpdateInfo,
RedisDatabaseInfo,
RedisKeyInfo,

View File

@ -1381,6 +1381,21 @@ export async function checkForUpdates(): Promise<UpdateInfo> {
return get("/api/update/check");
}
export async function checkMcpServerStatus(): Promise<import("./tauri").McpServerStatus> {
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<string | null> {
return null;
}

View File

@ -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<McpServerStatus> {
return invoke("check_mcp_server_status");
}
export async function checkForUpdates(): Promise<UpdateInfo> {
return invoke("check_for_updates");
}

View File

@ -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<String>,
pub current_version: Option<String>,
pub latest_version: Option<String>,
pub update_available: bool,
pub bin_path: Option<String>,
pub install_command: String,
pub update_command: String,
pub error: Option<String>,
}
#[derive(Debug, Deserialize)]
struct NpmLatestPackage {
version: String,
}
#[tauri::command]
pub async fn check_mcp_server_status() -> Result<McpServerStatus, String> {
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<String, String> {
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::<NpmLatestPackage>()
.await
.map_err(|e| format!("Failed to parse MCP Server update response: {e}"))?;
Ok(package.version)
}
fn installed_mcp_version() -> Option<String> {
let stdout = command_stdout("npm", &["list", "-g", MCP_PACKAGE_NAME, "--json", "--depth=0"]).ok()?;
let value = serde_json::from_str::<serde_json::Value>(&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<String> {
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<String, String> {
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<String> {
value.lines().map(str::trim).find(|line| !line.is_empty()).map(ToOwned::to_owned)
}

View File

@ -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;

View File

@ -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,