diff --git a/Cargo.lock b/Cargo.lock index 16de5320c6..77784b4482 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2011,6 +2011,7 @@ dependencies = [ "chrono", "csv", "dbx-core", + "dbx-mcp", "deadpool-postgres", "font-kit", "futures", @@ -2158,8 +2159,10 @@ name = "dbx-mcp" version = "0.4.76" dependencies = [ "async-trait", + "axum", "dbx-core", "dirs", + "log", "reqwest 0.12.28", "rmcp", "rustls 0.23.43", @@ -2170,6 +2173,8 @@ dependencies = [ "tempfile", "tokio", "tokio-rustls 0.26.4", + "tokio-util", + "tower-http", "url", "uuid", ] @@ -2195,6 +2200,7 @@ dependencies = [ "base64 0.22.1", "chrono", "dbx-core", + "dbx-mcp", "futures", "log", "pbkdf2 0.12.2", @@ -6999,19 +7005,27 @@ checksum = "14db48ee17a9ba61810ab1a9c1beb7d06d8136ae39ac25a1137f10d357af01af" dependencies = [ "async-trait", "base64 0.22.1", + "bytes", "chrono", "futures", + "http 1.5.0", + "http-body 1.1.0", + "http-body-util", "pastey", "pin-project-lite", + "rand 0.10.2", "rmcp-macros", "schemars 1.2.2", "serde", "serde_json", + "sse-stream", "thiserror 2.0.19", "tokio", "tokio-stream", "tokio-util", + "tower-service", "tracing", + "uuid", ] [[package]] @@ -8206,6 +8220,19 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "sse-stream" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c123f296ade4ec4b8b0f6162116e6629f5146922ca5ab40ca9d3c2e73ab4761e" +dependencies = [ + "bytes", + "futures-util", + "http-body 1.1.0", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "ssfmt" version = "0.1.2" diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index a66cb0aeb4..43783b5eef 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -10,6 +10,8 @@ import { Check, CheckCircle2, ChevronDown, + ChevronLeft, + ChevronRight, ChevronUp, CircleHelp, Cloud, @@ -106,6 +108,11 @@ import { checkMcpServerStatus, installMcpServer, uninstallMcpServer, + loadMcpHttpServerSettings, + saveMcpHttpServerSettings, + mcpHttpServerStatus, + rotateMcpHttpServerToken, + loadWebMcpHttpStatus, forgetSnippetSavedToken, forgetWebdavSyncSecretsPassphrase, forgetWebdavSavedPassword, @@ -130,6 +137,9 @@ import { webdavSyncTest, webdavSyncUpload, type AppSupportInfo, + type McpHttpServerSettings, + type McpHttpServerStatus, + type WebMcpHttpStatus, type McpServerStatus, type SnippetProvider, type SnippetSyncConfig, @@ -163,6 +173,8 @@ import AiProviderLogo from "@/components/icons/AiProviderLogo.vue"; import AppLogo from "@/components/icons/AppLogo.vue"; import ChangelogPanel from "@/components/settings/ChangelogPanel.vue"; import McpConnectionScopePicker from "@/components/settings/McpConnectionScopePicker.vue"; +import McpDatabaseScopePicker from "@/components/settings/McpDatabaseScopePicker.vue"; +import McpAuthorizationStepper from "@/components/settings/McpAuthorizationStepper.vue"; import ScheduledDatabaseBackupSettings from "@/components/backup/ScheduledDatabaseBackupSettings.vue"; import SqlFormatterSettingsPanel from "./SqlFormatterSettingsPanel.vue"; import { APP_CUSTOM_UI_COLOR_DEFS, APP_THEME_PALETTES, type AppCornerStyle, type AppCustomUiColors, type AppThemeAppearance, type AppThemeMode, type AppThemePalette } from "@/lib/app/appTheme"; @@ -2034,13 +2046,32 @@ async function exportDebugLogs() { // ---------- MCP Server ---------- type McpConfigTab = "claude" | "cursor" | "codebuddy" | "zcode" | "trae" | "vscode" | "windsurf" | "codex" | "deepseek-harness" | "opencode" | "pi" | "cherry-studio"; -type McpCopyKind = "install" | "uninstall" | `${McpConfigTab}-config`; +type McpCopyKind = "install" | "uninstall" | "http-endpoint" | "http-token" | "http-config" | `${McpConfigTab}-config`; +type McpTransportTab = "stdio" | "http"; const mcpStatus = ref(null); const mcpStatusLoading = ref(false); const mcpStatusError = ref(""); const mcpCopied = ref<"" | McpCopyKind>(""); const mcpConfigTab = ref("claude"); +const mcpTransportTab = ref("stdio"); +const mcpHttpSettings = ref({ + enabled: false, + host: "127.0.0.1", + port: 5225, + path: "/mcp", + allowRemote: false, + allowedHosts: [], + allowedOrigins: [], +}); +const mcpHttpStatus = ref(null); +const webMcpHttpStatus = ref(null); +const mcpHttpLoading = ref(false); +const mcpHttpSaving = ref(false); +const mcpHttpError = ref(""); +const mcpHttpAllowedHostsText = ref(""); +const mcpHttpAllowedOriginsText = ref(""); +const mcpHttpPersistedSettings = ref(null); const MCP_READONLY_STORAGE_KEY = "dbx-mcp-config-readonly"; const MCP_SCOPE_CONNECTION_STORAGE_KEY = "dbx-mcp-config-scope-connection"; const mcpPolicyLoading = ref(false); @@ -2087,7 +2118,51 @@ const mcpPolicyControlsDisabled = computed(() => }), ); -async function saveMcpPolicy(partial: { readOnly?: boolean; allowDangerousSql?: boolean; allowedConnectionIds?: string[] | null; queryTimeoutSecs?: number | null }) { +const mcpHttpClientConfig = computed(() => { + if (!mcpHttpStatus.value?.endpoint || !mcpHttpStatus.value.accessToken) return ""; + return JSON.stringify( + { + type: "http", + url: mcpHttpStatus.value.endpoint, + headers: { Authorization: `Bearer ${mcpHttpStatus.value.accessToken}` }, + }, + null, + 2, + ); +}); + +const mcpHttpDraftValidationError = computed(() => { + const host = mcpHttpSettings.value.host.trim(); + const port = Number(mcpHttpSettings.value.port); + const path = mcpHttpSettings.value.path.trim(); + if (!mcpHttpSettings.value.enabled) return ""; + if (!host) return t("settings.mcpHttpValidationHostRequired"); + if (!Number.isInteger(port) || port < 1 || port > 65535) return t("settings.mcpHttpValidationPortRange"); + if (!path.startsWith("/")) return t("settings.mcpHttpValidationPathPrefix"); + + const isLoopback = host === "127.0.0.1" || host === "::1"; + if (isLoopback) return ""; + if (!mcpHttpSettings.value.allowRemote) return t("settings.mcpHttpValidationRemoteRequired"); + if (!mcpHttpList(mcpHttpAllowedHostsText.value).length) return t("settings.mcpHttpValidationHostsRequired"); + if (!mcpHttpList(mcpHttpAllowedOriginsText.value).length) return t("settings.mcpHttpValidationOriginsRequired"); + return ""; +}); + +const mcpHttpHasUnsavedChanges = computed(() => { + if (!mcpHttpPersistedSettings.value) return false; + return JSON.stringify(mcpHttpDraftSettings()) !== JSON.stringify(mcpHttpPersistedSettings.value); +}); + +const webMcpEndpoint = computed(() => (webMcpHttpStatus.value ? `${window.location.origin}${webMcpHttpStatus.value.endpointPath}` : "")); + +async function saveMcpPolicy(partial: { + readOnly?: boolean; + allowDangerousSql?: boolean; + allowedConnectionIds?: string[] | null; + allowedToolNames?: string[] | null; + connectionPolicies?: { connectionId: string; readOnly: boolean; allowDangerousSql: boolean; executionModeConfigured: boolean; databaseScope: "all" | "selected" | "none"; allowedDatabases: string[] }[]; + queryTimeoutSecs?: number | null; +}) { if (mcpPolicyControlsDisabled.value) return; mcpPolicySaving.value = true; try { @@ -2131,6 +2206,184 @@ function onMcpAllowedConnectionIdsChange(allowedConnectionIds: string[] | null) void saveMcpPolicy({ allowedConnectionIds }); } +type McpConnectionExecutionMode = "read_only" | "safe_write" | "high_risk_write"; + +const mcpToolOptions = [ + { name: "dbx_list_connections", labelKey: "settings.mcpToolListConnections" }, + { name: "dbx_list_databases", labelKey: "settings.mcpToolListDatabases" }, + { name: "dbx_list_tables", labelKey: "settings.mcpToolListTables" }, + { name: "dbx_describe_table", labelKey: "settings.mcpToolDescribeTable" }, + { name: "dbx_get_schema_context", labelKey: "settings.mcpToolGetSchemaContext" }, + { name: "dbx_execute_query", labelKey: "settings.mcpToolExecuteQuery" }, + { name: "dbx_open_session", labelKey: "settings.mcpToolOpenSession" }, + { name: "dbx_close_session", labelKey: "settings.mcpToolCloseSession" }, + { name: "dbx_execute_redis_command", labelKey: "settings.mcpToolExecuteRedisCommand" }, + { name: "dbx_send_message", labelKey: "settings.mcpToolSendMessage" }, + { name: "dbx_add_connection", labelKey: "settings.mcpToolAddConnection" }, + { name: "dbx_duplicate_connection", labelKey: "settings.mcpToolDuplicateConnection" }, + { name: "dbx_remove_connection", labelKey: "settings.mcpToolRemoveConnection" }, + { name: "dbx_open_table", labelKey: "settings.mcpToolOpenTable" }, + { name: "dbx_execute_and_show", labelKey: "settings.mcpToolExecuteAndShow" }, +] as const; + +const mcpAllowedToolNames = computed(() => settingsStore.mcpGlobalPolicy.allowedToolNames); + +function mcpToolAllowed(name: string): boolean { + return mcpAllowedToolNames.value === null || mcpAllowedToolNames.value.includes(name); +} + +function onMcpToolAllowedChange(name: string, allowed: boolean) { + const current = mcpAllowedToolNames.value ?? mcpToolOptions.map((tool) => tool.name); + const next = allowed ? [...new Set([...current, name])] : current.filter((tool) => tool !== name); + void saveMcpPolicy({ allowedToolNames: next }); +} + +const mcpConnectionPolicyConnections = computed(() => { + const allowed = mcpAllowedConnectionIds.value; + return allowed === null ? mcpSelectableConnections.value : mcpSelectableConnections.value.filter((connection) => allowed.includes(connection.id)); +}); +const MCP_CONNECTION_POLICY_PAGE_SIZE_OPTIONS = [6, 10, 20] as const; +const mcpConnectionPolicyPageSize = ref(MCP_CONNECTION_POLICY_PAGE_SIZE_OPTIONS[0]); +const mcpConnectionPolicyPage = ref(1); +const mcpConnectionPolicyPageCount = computed(() => Math.max(1, Math.ceil(mcpConnectionPolicyConnections.value.length / mcpConnectionPolicyPageSize.value))); +const pagedMcpConnectionPolicyConnections = computed(() => { + const start = (mcpConnectionPolicyPage.value - 1) * mcpConnectionPolicyPageSize.value; + return mcpConnectionPolicyConnections.value.slice(start, start + mcpConnectionPolicyPageSize.value); +}); + +function setMcpConnectionPolicyPage(page: number) { + mcpConnectionPolicyPage.value = Math.min(Math.max(1, page), mcpConnectionPolicyPageCount.value); +} + +watch(mcpConnectionPolicyPageSize, () => { + mcpConnectionPolicyPage.value = 1; +}); + +watch(mcpConnectionPolicyPageCount, () => setMcpConnectionPolicyPage(mcpConnectionPolicyPage.value)); + +function mcpConnectionExecutionMode(connectionId: string): McpConnectionExecutionMode | "inherit" { + const rule = settingsStore.mcpGlobalPolicy.connectionPolicies.find((item) => item.connectionId === connectionId); + if (!rule || !rule.executionModeConfigured) return "inherit"; + if (rule.readOnly) return "read_only"; + return rule.allowDangerousSql ? "high_risk_write" : "safe_write"; +} + +function onMcpConnectionExecutionModeChange(connectionId: string, mode: McpConnectionExecutionMode | "inherit") { + const existing = settingsStore.mcpGlobalPolicy.connectionPolicies.find((item) => item.connectionId === connectionId); + const rules = settingsStore.mcpGlobalPolicy.connectionPolicies.filter((item) => item.connectionId !== connectionId); + const next = { + connectionId, + readOnly: mode === "read_only", + allowDangerousSql: mode === "high_risk_write", + executionModeConfigured: mode !== "inherit", + databaseScope: existing?.databaseScope ?? ("all" as const), + allowedDatabases: existing?.allowedDatabases ?? [], + }; + if (next.executionModeConfigured || next.databaseScope !== "all") rules.push(next); + void saveMcpPolicy({ connectionPolicies: rules }); +} + +function onMcpConnectionPoliciesChange(connectionPolicies: typeof settingsStore.mcpGlobalPolicy.connectionPolicies) { + void saveMcpPolicy({ connectionPolicies }); +} + +function mcpHttpList(value: string): string[] { + return [ + ...new Set( + value + .split(/[,\n]/) + .map((item) => item.trim()) + .filter(Boolean), + ), + ]; +} + +function normalizeMcpHttpSettings(settings: McpHttpServerSettings): McpHttpServerSettings { + return { + ...settings, + host: settings.host.trim(), + path: settings.path.trim(), + allowedHosts: mcpHttpList(settings.allowedHosts.join("\n")), + allowedOrigins: mcpHttpList(settings.allowedOrigins.join("\n")), + }; +} + +function mcpHttpDraftSettings(): McpHttpServerSettings { + return normalizeMcpHttpSettings({ + ...mcpHttpSettings.value, + allowedHosts: mcpHttpList(mcpHttpAllowedHostsText.value), + allowedOrigins: mcpHttpList(mcpHttpAllowedOriginsText.value), + }); +} + +function formatMcpHttpError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + if (message.includes("remote MCP HTTP binding requires allowed hosts and allowed origins")) { + return t("settings.mcpHttpErrorRemoteHostsAndOrigins"); + } + if (message.includes("remote MCP HTTP binding requires")) { + return t("settings.mcpHttpErrorRemoteBinding"); + } + if (message.includes("Address already in use")) { + return t("settings.mcpHttpErrorPortInUse"); + } + return message; +} + +async function loadMcpHttpSettings() { + if (mcpHttpLoading.value) return; + mcpHttpLoading.value = true; + mcpHttpError.value = ""; + try { + if (isWeb) { + webMcpHttpStatus.value = await loadWebMcpHttpStatus(); + return; + } + const [settings, status] = await Promise.all([loadMcpHttpServerSettings(), mcpHttpServerStatus()]); + mcpHttpSettings.value = normalizeMcpHttpSettings(settings); + mcpHttpPersistedSettings.value = normalizeMcpHttpSettings(settings); + mcpHttpStatus.value = status; + mcpHttpAllowedHostsText.value = settings.allowedHosts.join("\n"); + mcpHttpAllowedOriginsText.value = settings.allowedOrigins.join("\n"); + } catch (error: unknown) { + mcpHttpError.value = formatMcpHttpError(error); + } finally { + mcpHttpLoading.value = false; + } +} + +async function saveMcpHttpSettings() { + if (mcpHttpSaving.value) return; + mcpHttpSaving.value = true; + mcpHttpError.value = ""; + const settings = mcpHttpDraftSettings(); + try { + const status = await saveMcpHttpServerSettings(settings); + mcpHttpSettings.value = settings; + mcpHttpPersistedSettings.value = normalizeMcpHttpSettings(settings); + mcpHttpStatus.value = status; + mcpHttpAllowedHostsText.value = settings.allowedHosts.join("\n"); + mcpHttpAllowedOriginsText.value = settings.allowedOrigins.join("\n"); + } catch (error: unknown) { + mcpHttpError.value = formatMcpHttpError(error); + } finally { + mcpHttpSaving.value = false; + } +} + +async function rotateMcpHttpToken() { + if (mcpHttpSaving.value || !window.confirm(t("settings.mcpHttpRotateTokenConfirm"))) return; + mcpHttpSaving.value = true; + mcpHttpError.value = ""; + try { + mcpHttpStatus.value = await rotateMcpHttpServerToken(); + } catch (error: unknown) { + mcpHttpError.value = formatMcpHttpError(error); + } finally { + mcpHttpSaving.value = false; + } +} + const mcpLaunchConfig = computed(() => { if (isWeb) { return { @@ -2674,6 +2927,7 @@ watch( confirmNewPassword.value = ""; try { await settingsStore.initMcpGlobalPolicy(true); + await loadMcpHttpSettings(); if (!settingsStore.mcpGlobalPolicy.configured && localStorage.getItem(MCP_READONLY_STORAGE_KEY) === "true") { await settingsStore.updateMcpGlobalPolicy({ readOnly: true }); } @@ -7054,422 +7308,675 @@ onUnmounted(() => { -
-
-
- {{ 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") }} + + + {{ t("settings.mcpTransportLocalStdio") }} + {{ t("settings.mcpTransportHttpService") }} + + + +
+
+
+ + {{ webMcpHttpStatus?.enabled ? t("settings.mcpHttpStatusLabelEnabled") : t("settings.mcpHttpStatusLabelDisabled") }}
+

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

+ +
-
- -
- {{ mcpStatus.bin_path }} -
-
- -
- -
-
- {{ mcpCommand }} +
+
+
+
+
+ + + {{ mcpHttpStatus?.running ? t("settings.mcpHttpStatusLabelRunning") : mcpHttpSettings.enabled ? t("settings.mcpHttpStatusLabelPending") : t("settings.mcpHttpStatusLabelDisabled") }} + +
+

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

+
+
- -
-
- {{ mcpInstallMessage }} -
-
-
- -
-
- {{ mcpUninstallCommand }} +