feat(mcp): centralize connection access policies
This commit is contained in:
parent
29f493f7da
commit
d642d01eac
|
|
@ -156,6 +156,10 @@ Add to your `.mcp.json`:
|
|||
}
|
||||
```
|
||||
|
||||
Manage the connection allowlist and the **Read only**, **Data read/write**, and **Full access** modes in **DBX Settings → MCP**. The machine-readable values remain `read_only`, `safe_write`, and `high_risk_write`; client configs do not need permission or connection-scope environment variables.
|
||||
|
||||
For upgrade compatibility, an existing `DBX_MCP_ALLOW_WRITES=0` (or `false`) remains a read-only restriction only until a central MCP policy is saved for the first time; it can never enable writes or override a saved policy.
|
||||
|
||||
Windows portable builds need `DBX_DATA_DIR` in the MCP config, pointing to the `data` directory next to `DBX.exe` (the folder that contains `dbx.db`).
|
||||
|
||||
For DBX Web or Docker deployments, point the MCP server at the Web backend API. If the Web login page requires a password, set `DBX_WEB_PASSWORD` to the same password used there:
|
||||
|
|
|
|||
|
|
@ -156,6 +156,10 @@ npx @dbx-app/mcp-server
|
|||
}
|
||||
```
|
||||
|
||||
连接 allowlist 和“只读 / 数据读写 / 完全访问”三档执行权限统一在 DBX 的“设置 → MCP”中管理。机器可读值仍为 `read_only`、`safe_write`、`high_risk_write`;客户端配置无需声明权限或连接范围环境变量。
|
||||
|
||||
为兼容升级,旧配置中的 `DBX_MCP_ALLOW_WRITES=0`(或 `false`)仅在中央 MCP 策略首次保存前继续作为只读限制;它不能开启写入,也不能覆盖已经保存的中央策略。
|
||||
|
||||
Windows 便携版需要在 MCP 配置中设置 `DBX_DATA_DIR`,指向 `DBX.exe` 同级的 `data` 目录(即包含 `dbx.db` 的文件夹)。
|
||||
|
||||
如果连接的是 DBX Web 或 Docker 部署,请让 MCP Server 指向 Web 后端 API。如果 Web 登录页需要密码,`DBX_WEB_PASSWORD` 填写同一个 Web 登录密码:
|
||||
|
|
|
|||
|
|
@ -95,7 +95,8 @@ import { currentExecutableStatementRange, type SqlTextRange } from "@/lib/sql/sq
|
|||
import { executableStatementRangeCacheForDoc, executableStatementRangeStartingAt, type ExecutableStatementRangeCache } from "@/lib/sql/executableStatementRangeCache";
|
||||
import { EMPTY_TABLE_COLUMN_TEMPLATE_DATA_TYPE, parseTableColumnTemplateFields, TABLE_COLUMN_TEMPLATE_DATABASE_TYPES } from "@/lib/table/tableColumnTemplates";
|
||||
import { DEFAULT_SQL_VARIABLE_SYNTAX_TOGGLES, normalizeSqlVariableSyntaxOverrides, SQL_VARIABLE_SYNTAX_DATABASE_TYPES, SQL_VARIABLE_SYNTAX_KEYS, SQL_VARIABLE_SYNTAX_TOKENS, type SqlVariableSyntaxOverrides, type SqlVariableSyntaxToggles } from "@/lib/sql/sqlVariableSyntax";
|
||||
import { buildMcpCodexConfig, buildMcpJsonConfig, buildMcpOpenCodeConfig, buildMcpVsCodeConfig, type McpEnvEntry, type McpLaunchConfig } from "@/lib/mcp/mcpConfigTemplates";
|
||||
import { buildMcpCherryStudioConfig, buildMcpCodexConfig, buildMcpJsonConfig, buildMcpOpenCodeConfig, buildMcpVsCodeConfig, mcpWebBackendUrl, type McpLaunchConfig } from "@/lib/mcp/mcpConfigTemplates";
|
||||
import { isMcpPolicyMutationBlocked, MCP_CAPABILITY_ROWS, MCP_EXECUTION_MODE_COLUMNS, mcpExecutionModeFromPolicy, mcpPolicyFieldsForExecutionMode, type McpExecutionMode } from "@/lib/mcp/mcpPolicySelection";
|
||||
import { isMacOS } from "@/lib/backend/platform";
|
||||
import { combineDataTypeForDatabase, dataTypeLengthInputValue, getDataTypeOptions, getDefaultLengthForType, isDataTypeLengthDisabled, splitDataType } from "@/lib/table/tableStructureEditorState";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
|
|
@ -105,6 +106,7 @@ import { DEFAULT_SQL_SNIPPETS } from "@/lib/sql/sqlCompletion";
|
|||
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 ScheduledDatabaseBackupSettings from "@/components/backup/ScheduledDatabaseBackupSettings.vue";
|
||||
import SqlFormatterSettingsPanel from "./SqlFormatterSettingsPanel.vue";
|
||||
import { APP_THEME_PALETTES, type AppThemeAppearance, type AppThemeMode, type AppThemePalette } from "@/lib/app/appTheme";
|
||||
|
|
@ -1324,7 +1326,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") }]),
|
||||
{ value: "mcp" as const, label: t("settings.mcpTab") },
|
||||
...(isWeb ? [{ value: "security" as const, label: t("settings.securityTab") }] : []),
|
||||
{ value: "about", label: t("settings.aboutTab") },
|
||||
]);
|
||||
|
|
@ -1411,7 +1413,7 @@ async function exportDebugLogs() {
|
|||
}
|
||||
|
||||
// ---------- MCP Server ----------
|
||||
type McpConfigTab = "claude" | "cursor" | "trae" | "vscode" | "windsurf" | "codex" | "opencode";
|
||||
type McpConfigTab = "claude" | "cursor" | "trae" | "vscode" | "windsurf" | "codex" | "opencode" | "cherry-studio";
|
||||
type McpCopyKind = "install" | `${McpConfigTab}-config`;
|
||||
|
||||
const mcpStatus = ref<McpServerStatus | null>(null);
|
||||
|
|
@ -1420,39 +1422,80 @@ const mcpStatusError = ref("");
|
|||
const mcpCopied = ref<"" | McpCopyKind>("");
|
||||
const mcpConfigTab = ref<McpConfigTab>("claude");
|
||||
const MCP_READONLY_STORAGE_KEY = "dbx-mcp-config-readonly";
|
||||
const MCP_ALLOW_DANGEROUS_STORAGE_KEY = "dbx-mcp-config-allow-dangerous";
|
||||
const mcpReadonlyMode = ref(localStorage.getItem(MCP_READONLY_STORAGE_KEY) === "true");
|
||||
const mcpAllowDangerous = ref(localStorage.getItem(MCP_ALLOW_DANGEROUS_STORAGE_KEY) === "true");
|
||||
const MCP_SCOPE_CONNECTION_STORAGE_KEY = "dbx-mcp-config-scope-connection";
|
||||
const mcpPolicyLoading = ref(false);
|
||||
const mcpPolicySaving = ref(false);
|
||||
const mcpPolicyLoadError = ref("");
|
||||
const mcpInstalling = ref(false);
|
||||
const mcpInstallMessage = ref("");
|
||||
const mcpInstallError = ref(false);
|
||||
const mcpExecutionMode = computed(() => mcpExecutionModeFromPolicy(settingsStore.mcpGlobalPolicy));
|
||||
const mcpAllowedConnectionIds = computed(() => settingsStore.mcpGlobalPolicy.allowedConnectionIds);
|
||||
const mcpSelectableConnections = computed(() => connectionStore.connections);
|
||||
const mcpPolicyControlsDisabled = computed(() =>
|
||||
isMcpPolicyMutationBlocked({
|
||||
loading: mcpPolicyLoading.value,
|
||||
saving: mcpPolicySaving.value,
|
||||
loadError: mcpPolicyLoadError.value,
|
||||
}),
|
||||
);
|
||||
|
||||
const mcpEnvEntries = computed<McpEnvEntry[]>(() => {
|
||||
const entries: McpEnvEntry[] = [];
|
||||
if (mcpReadonlyMode.value) {
|
||||
entries.push(["DBX_MCP_ALLOW_WRITES", "0"]);
|
||||
async function saveMcpPolicy(partial: { readOnly?: boolean; allowDangerousSql?: boolean; allowedConnectionIds?: string[] | null }) {
|
||||
if (mcpPolicyControlsDisabled.value) return;
|
||||
mcpPolicySaving.value = true;
|
||||
try {
|
||||
await settingsStore.updateMcpGlobalPolicy(partial);
|
||||
} catch (e: any) {
|
||||
toast(t("settings.mcpPolicySaveFailed", { error: e?.message || String(e) }), 5000);
|
||||
} finally {
|
||||
mcpPolicySaving.value = false;
|
||||
}
|
||||
if (!mcpReadonlyMode.value && mcpAllowDangerous.value) {
|
||||
entries.push(["DBX_MCP_ALLOW_DANGEROUS_SQL", "1"]);
|
||||
}
|
||||
|
||||
function onMcpExecutionModeChange(event: Event, mode: McpExecutionMode) {
|
||||
if (mode === mcpExecutionMode.value) return;
|
||||
if (mode === "high_risk_write" && !window.confirm(t("settings.mcpExecutionModeHighRiskConfirm"))) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
return entries;
|
||||
});
|
||||
void saveMcpPolicy(mcpPolicyFieldsForExecutionMode(mode));
|
||||
}
|
||||
|
||||
function onMcpAllowedConnectionIdsChange(allowedConnectionIds: string[] | null) {
|
||||
void saveMcpPolicy({ allowedConnectionIds });
|
||||
}
|
||||
|
||||
const mcpLaunchConfig = computed<McpLaunchConfig | undefined>(() => {
|
||||
if (!mcpStatus.value?.node_path || !mcpStatus.value.script_path) return undefined;
|
||||
return {
|
||||
command: mcpStatus.value.node_path,
|
||||
args: [mcpStatus.value.script_path],
|
||||
};
|
||||
if (isWeb) {
|
||||
return {
|
||||
command: "dbx-mcp-server",
|
||||
env: {
|
||||
DBX_WEB_URL: mcpWebBackendUrl(window.location.origin, apiUrl("/api")),
|
||||
DBX_WEB_PASSWORD: "your-web-login-password",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (mcpStatus.value?.node_path && mcpStatus.value.script_path) {
|
||||
return {
|
||||
command: mcpStatus.value.node_path,
|
||||
args: [mcpStatus.value.script_path],
|
||||
};
|
||||
}
|
||||
if (mcpStatus.value?.bin_path) {
|
||||
return { command: mcpStatus.value.bin_path };
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const mcpJsonRecommendedConfig = computed(() => buildMcpJsonConfig(mcpEnvEntries.value, mcpLaunchConfig.value));
|
||||
const mcpJsonRecommendedConfig = computed(() => buildMcpJsonConfig(mcpLaunchConfig.value));
|
||||
|
||||
const mcpVsCodeRecommendedConfig = computed(() => buildMcpVsCodeConfig(mcpEnvEntries.value, mcpLaunchConfig.value));
|
||||
const mcpVsCodeRecommendedConfig = computed(() => buildMcpVsCodeConfig(mcpLaunchConfig.value));
|
||||
|
||||
const mcpCodexRecommendedConfig = computed(() => buildMcpCodexConfig(mcpEnvEntries.value, mcpLaunchConfig.value));
|
||||
const mcpCherryStudioRecommendedConfig = computed(() => buildMcpCherryStudioConfig(mcpLaunchConfig.value));
|
||||
|
||||
const mcpOpenCodeRecommendedConfig = computed(() => buildMcpOpenCodeConfig(mcpEnvEntries.value, mcpLaunchConfig.value));
|
||||
const mcpCodexRecommendedConfig = computed(() => buildMcpCodexConfig(mcpLaunchConfig.value));
|
||||
|
||||
const mcpOpenCodeRecommendedConfig = computed(() => buildMcpOpenCodeConfig(mcpLaunchConfig.value));
|
||||
|
||||
const mcpStatusTone = computed<"ok" | "warning" | "muted">(() => {
|
||||
if (!mcpStatus.value) return "muted";
|
||||
|
|
@ -1474,15 +1517,6 @@ const mcpCommand = computed(() => {
|
|||
return mcpStatus.value.installed ? mcpStatus.value.update_command : mcpStatus.value.install_command;
|
||||
});
|
||||
|
||||
watch(mcpReadonlyMode, (value) => {
|
||||
localStorage.setItem(MCP_READONLY_STORAGE_KEY, String(value));
|
||||
if (value) mcpAllowDangerous.value = false;
|
||||
});
|
||||
|
||||
watch(mcpAllowDangerous, (value) => {
|
||||
localStorage.setItem(MCP_ALLOW_DANGEROUS_STORAGE_KEY, String(value));
|
||||
});
|
||||
|
||||
async function refreshMcpStatus() {
|
||||
if (mcpStatusLoading.value) return;
|
||||
mcpStatusLoading.value = true;
|
||||
|
|
@ -1821,6 +1855,8 @@ watch(
|
|||
() => settingsVisible.value,
|
||||
async (open) => {
|
||||
if (open) {
|
||||
mcpPolicyLoading.value = true;
|
||||
mcpPolicyLoadError.value = "";
|
||||
aiConfigListMode.value = "list";
|
||||
aiEditConfigId.value = null;
|
||||
activeSettingsTab.value = props.initialTab || "appearance";
|
||||
|
|
@ -1828,6 +1864,19 @@ watch(
|
|||
oldPassword.value = "";
|
||||
newPassword.value = "";
|
||||
confirmNewPassword.value = "";
|
||||
try {
|
||||
await settingsStore.initMcpGlobalPolicy(true);
|
||||
if (!settingsStore.mcpGlobalPolicy.configured && localStorage.getItem(MCP_READONLY_STORAGE_KEY) === "true") {
|
||||
await settingsStore.updateMcpGlobalPolicy({ readOnly: true });
|
||||
}
|
||||
if (settingsStore.mcpGlobalPolicy.configured) localStorage.removeItem(MCP_READONLY_STORAGE_KEY);
|
||||
localStorage.removeItem(MCP_SCOPE_CONNECTION_STORAGE_KEY);
|
||||
} catch (e: any) {
|
||||
mcpPolicyLoadError.value = e?.message || String(e);
|
||||
toast(t("settings.mcpPolicyLoadFailed", { error: mcpPolicyLoadError.value }), 5000);
|
||||
} finally {
|
||||
mcpPolicyLoading.value = false;
|
||||
}
|
||||
await settingsStore.initAiConfigs();
|
||||
await settingsStore.initDesktopSettings();
|
||||
editShowTrayIcon.value = settingsStore.desktopSettings.show_tray_icon;
|
||||
|
|
@ -4751,7 +4800,7 @@ onUnmounted(cleanupPreviewEditor);
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeSettingsTab === 'mcp' && !isWeb" class="flex flex-col gap-5 py-2">
|
||||
<section v-else-if="activeSettingsTab === 'mcp'" 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">
|
||||
|
|
@ -4763,7 +4812,7 @@ onUnmounted(cleanupPreviewEditor);
|
|||
</HelpTooltip>
|
||||
</div>
|
||||
</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'">
|
||||
<Badge v-if="!isWeb" 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" />
|
||||
|
|
@ -4772,7 +4821,7 @@ onUnmounted(cleanupPreviewEditor);
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div v-if="!isWeb" 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">
|
||||
|
|
@ -4806,7 +4855,7 @@ onUnmounted(cleanupPreviewEditor);
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div v-if="!isWeb" 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 overflow-x-auto rounded-md border bg-background px-3 py-2 font-mono text-xs whitespace-nowrap">
|
||||
|
|
@ -4832,36 +4881,101 @@ onUnmounted(cleanupPreviewEditor);
|
|||
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs text-muted-foreground">{{ t("settings.mcpConfigOptionsHint") }}</p>
|
||||
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<p v-if="mcpPolicyLoadError" class="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-600 dark:text-red-400">{{ t("settings.mcpPolicyLoadFailed", { error: mcpPolicyLoadError }) }}</p>
|
||||
<McpConnectionScopePicker :connections="mcpSelectableConnections" :allowed-connection-ids="mcpAllowedConnectionIds" :disabled="mcpPolicyControlsDisabled" :busy="mcpPolicyLoading || mcpPolicySaving" @update:allowed-connection-ids="onMcpAllowedConnectionIdsChange" />
|
||||
<div class="space-y-3 rounded-md border bg-muted/20 p-3">
|
||||
<div class="space-y-1">
|
||||
<Label for="mcp-readonly-mode">{{ t("settings.mcpReadonlyMode") }}</Label>
|
||||
<p class="text-xs text-muted-foreground">{{ t("settings.mcpReadonlyModeDescription") }}</p>
|
||||
<Label id="mcp-execution-mode-label">{{ t("settings.mcpExecutionMode") }}</Label>
|
||||
<p class="text-xs text-muted-foreground">{{ t("settings.mcpExecutionModeDescription") }}</p>
|
||||
</div>
|
||||
<Switch id="mcp-readonly-mode" v-model="mcpReadonlyMode" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="mcp-allow-dangerous">{{ t("settings.mcpAllowDangerous") }}</Label>
|
||||
<p class="text-xs text-muted-foreground">{{ t("settings.mcpAllowDangerousDescription") }}</p>
|
||||
<fieldset :disabled="mcpPolicyControlsDisabled" :aria-busy="mcpPolicyLoading" aria-labelledby="mcp-execution-mode-label">
|
||||
<legend class="sr-only">{{ t("settings.mcpExecutionMode") }}</legend>
|
||||
<div class="grid grid-cols-1 rounded-md bg-muted p-1 sm:grid-cols-3">
|
||||
<label
|
||||
:class="[
|
||||
'flex min-h-10 items-center justify-center rounded px-3 py-2 text-center text-sm font-medium transition-colors has-[:focus-visible]:ring-[3px] has-[:focus-visible]:ring-ring/50',
|
||||
mcpExecutionMode === 'read_only' ? 'bg-background text-foreground shadow-sm dark:bg-input/30' : 'text-muted-foreground',
|
||||
mcpPolicyControlsDisabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:text-foreground',
|
||||
]"
|
||||
>
|
||||
<input class="sr-only" type="radio" name="mcp-execution-mode" value="read_only" :checked="mcpExecutionMode === 'read_only'" @click="onMcpExecutionModeChange($event, 'read_only')" />
|
||||
<span>{{ t("settings.mcpExecutionModeReadOnly") }}</span>
|
||||
</label>
|
||||
<label
|
||||
:class="[
|
||||
'flex min-h-10 items-center justify-center gap-1.5 rounded px-3 py-2 text-center text-sm font-medium transition-colors has-[:focus-visible]:ring-[3px] has-[:focus-visible]:ring-ring/50',
|
||||
mcpExecutionMode === 'safe_write' ? 'bg-background text-foreground shadow-sm dark:bg-input/30' : 'text-muted-foreground',
|
||||
mcpPolicyControlsDisabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:text-foreground',
|
||||
]"
|
||||
>
|
||||
<input class="sr-only" type="radio" name="mcp-execution-mode" value="safe_write" :checked="mcpExecutionMode === 'safe_write'" @click="onMcpExecutionModeChange($event, 'safe_write')" />
|
||||
<span>{{ t("settings.mcpExecutionModeSafeWrite") }}</span>
|
||||
<span class="text-[10px] font-normal text-green-600 dark:text-green-400">{{ t("settings.mcpExecutionModeRecommended") }}</span>
|
||||
</label>
|
||||
<label
|
||||
:class="[
|
||||
'flex min-h-10 items-center justify-center rounded px-3 py-2 text-center text-sm font-medium transition-colors has-[:focus-visible]:ring-[3px] has-[:focus-visible]:ring-ring/50',
|
||||
mcpExecutionMode === 'high_risk_write' ? 'bg-background text-foreground shadow-sm dark:bg-input/30' : 'text-muted-foreground',
|
||||
mcpPolicyControlsDisabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:text-foreground',
|
||||
]"
|
||||
>
|
||||
<input class="sr-only" type="radio" name="mcp-execution-mode" value="high_risk_write" :checked="mcpExecutionMode === 'high_risk_write'" @click="onMcpExecutionModeChange($event, 'high_risk_write')" />
|
||||
<span>{{ t("settings.mcpExecutionModeHighRiskWrite") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<p class="flex items-start gap-1.5 text-xs" :class="mcpExecutionMode === 'high_risk_write' ? 'text-amber-600 dark:text-amber-400' : 'text-muted-foreground'">
|
||||
<AlertTriangle v-if="mcpExecutionMode === 'high_risk_write'" class="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span v-if="mcpExecutionMode === 'read_only'">{{ t("settings.mcpExecutionModeReadOnlyDescription") }}</span>
|
||||
<span v-else-if="mcpExecutionMode === 'safe_write'">{{ t("settings.mcpExecutionModeSafeWriteDescription") }}</span>
|
||||
<span v-else>{{ t("settings.mcpExecutionModeHighRiskWriteDescription") }}</span>
|
||||
</p>
|
||||
<div class="space-y-1.5">
|
||||
<div class="space-y-0.5">
|
||||
<p class="text-xs font-medium">{{ t("settings.mcpCapabilityTitle") }}</p>
|
||||
<p class="text-[11px] text-muted-foreground">{{ t("settings.mcpCapabilityDescription") }}</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto rounded-md border bg-background">
|
||||
<table class="w-full min-w-[36rem] table-fixed text-xs">
|
||||
<thead class="bg-muted/50 text-muted-foreground">
|
||||
<tr>
|
||||
<th scope="col" class="w-[46%] px-3 py-2 text-left font-medium">{{ t("settings.mcpCapabilityOperation") }}</th>
|
||||
<th v-for="column in MCP_EXECUTION_MODE_COLUMNS" :key="column.mode" scope="col" class="px-2 py-2 text-center font-medium">
|
||||
{{ t(column.labelKey) }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y">
|
||||
<tr v-for="row in MCP_CAPABILITY_ROWS" :key="row.labelKey">
|
||||
<th scope="row" class="px-3 py-2 text-left font-normal leading-relaxed">{{ t(row.labelKey) }}</th>
|
||||
<td v-for="column in MCP_EXECUTION_MODE_COLUMNS" :key="column.mode" class="px-2 py-2 text-center">
|
||||
<span class="inline-flex items-center justify-center" :class="row[column.mode] ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground/60'">
|
||||
<Check v-if="row[column.mode]" class="h-4 w-4" aria-hidden="true" />
|
||||
<X v-else class="h-4 w-4" aria-hidden="true" />
|
||||
<span class="sr-only">{{ t(row[column.mode] ? "settings.mcpCapabilityAllowed" : "settings.mcpCapabilityBlocked") }}</span>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="text-[11px] leading-relaxed text-muted-foreground">{{ t("settings.mcpCapabilityAlwaysEnforced") }}</p>
|
||||
</div>
|
||||
<Switch id="mcp-allow-dangerous" v-model="mcpAllowDangerous" :disabled="mcpReadonlyMode" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t("settings.mcpConfig") }}</Label>
|
||||
<Tabs v-model="mcpConfigTab" class="space-y-3">
|
||||
<TabsList class="max-w-full overflow-x-auto">
|
||||
<TabsTrigger value="claude">Claude Code</TabsTrigger>
|
||||
<TabsTrigger value="cursor">Cursor</TabsTrigger>
|
||||
<TabsTrigger value="trae">TRAE</TabsTrigger>
|
||||
<TabsTrigger value="vscode">VS Code</TabsTrigger>
|
||||
<TabsTrigger value="windsurf">Windsurf</TabsTrigger>
|
||||
<TabsTrigger value="codex">Codex</TabsTrigger>
|
||||
<TabsTrigger value="opencode">OpenCode</TabsTrigger>
|
||||
<TabsList class="grid h-auto w-full grid-cols-2 gap-1 overflow-visible group-data-horizontal/tabs:h-auto sm:grid-cols-4 xl:grid-cols-8">
|
||||
<TabsTrigger value="claude" class="h-8 min-w-0 px-2">Claude Code</TabsTrigger>
|
||||
<TabsTrigger value="cursor" class="h-8 min-w-0 px-2">Cursor</TabsTrigger>
|
||||
<TabsTrigger value="trae" class="h-8 min-w-0 px-2">TRAE</TabsTrigger>
|
||||
<TabsTrigger value="vscode" class="h-8 min-w-0 px-2">VS Code</TabsTrigger>
|
||||
<TabsTrigger value="windsurf" class="h-8 min-w-0 px-2">Windsurf</TabsTrigger>
|
||||
<TabsTrigger value="codex" class="h-8 min-w-0 px-2">Codex</TabsTrigger>
|
||||
<TabsTrigger value="opencode" class="h-8 min-w-0 px-2">OpenCode</TabsTrigger>
|
||||
<TabsTrigger value="cherry-studio" class="h-8 min-w-0 px-2">Cherry Studio</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="claude" class="m-0">
|
||||
|
|
@ -4963,6 +5077,21 @@ onUnmounted(cleanupPreviewEditor);
|
|||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="cherry-studio" class="m-0">
|
||||
<div class="space-y-2">
|
||||
<div class="rounded-md border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
{{ t("settings.mcpCherryStudioConfigPath") }}
|
||||
</div>
|
||||
<div class="relative rounded-md border bg-background p-3">
|
||||
<pre class="overflow-x-auto whitespace-pre text-xs leading-relaxed"><code>{{ mcpCherryStudioRecommendedConfig }}</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('cherry-studio-config', mcpCherryStudioRecommendedConfig)">
|
||||
<CheckCircle2 v-if="mcpCopied === 'cherry-studio-config'" class="h-3.5 w-3.5 text-green-500" />
|
||||
<Copy v-else class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
|
|
@ -5165,12 +5294,12 @@ onUnmounted(cleanupPreviewEditor);
|
|||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
<DialogFooter v-else-if="activeSettingsTab === 'mcp' && !isWeb" class="mx-0 mb-0 flex-row flex-wrap items-center justify-end gap-2 rounded-none border-t border-border/60 bg-transparent px-0 pb-0 pt-3 sm:flex-row sm:gap-2 [&>button]:w-auto [&>button]:shrink-0">
|
||||
<DialogFooter v-else-if="activeSettingsTab === 'mcp'" class="mx-0 mb-0 flex-row flex-wrap items-center justify-end gap-2 rounded-none border-t border-border/60 bg-transparent px-0 pb-0 pt-3 sm:flex-row sm:gap-2 [&>button]:w-auto [&>button]:shrink-0">
|
||||
<Button variant="outline" @click="closeSettings">
|
||||
{{ t("common.close") }}
|
||||
</Button>
|
||||
<div class="flex-1" />
|
||||
<Button variant="outline" :disabled="mcpStatusLoading" @click="refreshMcpStatus">
|
||||
<Button v-if="!isWeb" 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") }}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,380 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, useId, watch } from "vue";
|
||||
import { AlertTriangle, Minus, Plus, Search } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import TruncatedTextTooltip from "@/components/ui/TruncatedTextTooltip.vue";
|
||||
import { groupMcpScopeConnections, matchesMcpSearchQuery, updateMcpAllowedConnectionIds } from "@/lib/mcp/mcpPolicySelection";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
|
||||
type ScopePane = "available" | "allowed";
|
||||
type ScopeMode = "all" | "selected";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
connections: readonly ConnectionConfig[];
|
||||
allowedConnectionIds: readonly string[] | null;
|
||||
disabled?: boolean;
|
||||
busy?: boolean;
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
busy: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:allowedConnectionIds": [value: string[] | null];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const pickerId = useId();
|
||||
const rootRef = ref<HTMLElement>();
|
||||
const searchQuery = ref("");
|
||||
const compactPane = ref<ScopePane>("allowed");
|
||||
const announcement = ref("");
|
||||
const pendingFocus = ref<{ pane: ScopePane; index: number } | null>(null);
|
||||
|
||||
const connectionIds = computed(() => props.connections.map((connection) => connection.id));
|
||||
const groups = computed(() => groupMcpScopeConnections(props.connections, props.allowedConnectionIds));
|
||||
const scopeMode = computed<ScopeMode>(() => (props.allowedConnectionIds === null ? "all" : "selected"));
|
||||
const searchActive = computed(() => searchQuery.value.trim().length > 0);
|
||||
|
||||
function connectionMatchesSearch(connection: ConnectionConfig): boolean {
|
||||
return matchesMcpSearchQuery(searchQuery.value, [connection.name, connection.db_type, connection.host, connection.port, connection.database, connection.id]);
|
||||
}
|
||||
|
||||
const filteredAvailableConnections = computed(() => groups.value.available.filter(connectionMatchesSearch));
|
||||
const filteredAllowedConnections = computed(() => groups.value.allowed.filter(connectionMatchesSearch));
|
||||
const filteredUnavailableAllowedIds = computed(() => groups.value.unavailableAllowedIds.filter((id) => matchesMcpSearchQuery(searchQuery.value, [id, t("settings.mcpScopeConnectionUnavailable")])));
|
||||
const allowedVisibleCount = computed(() => filteredAllowedConnections.value.length + filteredUnavailableAllowedIds.value.length);
|
||||
const allowedTotalCount = computed(() => groups.value.allowed.length + groups.value.unavailableAllowedIds.length);
|
||||
const availableVisibleCount = computed(() => filteredAvailableConnections.value.length);
|
||||
const availableTotalCount = computed(() => groups.value.available.length);
|
||||
const allowedSummary = computed(() => (props.allowedConnectionIds === null ? t("settings.mcpScopeAllSummary", { count: props.connections.length }) : t("settings.mcpScopeSelectedSummary", { selected: groups.value.allowed.length, total: props.connections.length })));
|
||||
const policyKey = computed(() => (props.allowedConnectionIds === null ? "*" : props.allowedConnectionIds.join("\u0000")));
|
||||
|
||||
function paneCount(visible: number, total: number): string {
|
||||
return searchActive.value ? `${visible}/${total}` : String(total);
|
||||
}
|
||||
|
||||
function connectionAddress(connection: ConnectionConfig): string {
|
||||
const address = connection.host ? `${connection.host}:${connection.port}` : "";
|
||||
return [address, connection.database].filter(Boolean).join(" · ") || connection.id;
|
||||
}
|
||||
|
||||
function emitAllowedConnectionIds(value: string[] | null) {
|
||||
if (props.disabled) return;
|
||||
emit("update:allowedConnectionIds", value);
|
||||
}
|
||||
|
||||
function setScopeMode(mode: ScopeMode) {
|
||||
if (mode === scopeMode.value || props.disabled) return;
|
||||
emitAllowedConnectionIds(mode === "all" ? null : [...connectionIds.value]);
|
||||
}
|
||||
|
||||
function restorePendingFocus() {
|
||||
const pending = pendingFocus.value;
|
||||
if (!pending || props.busy) return;
|
||||
void nextTick(() => {
|
||||
if (props.busy) return;
|
||||
const pane = rootRef.value?.querySelector<HTMLElement>(`[data-scope-pane="${pending.pane}"]`);
|
||||
const actions = [...(pane?.querySelectorAll<HTMLButtonElement>("[data-scope-action]") ?? [])];
|
||||
const target = actions[Math.min(pending.index, Math.max(0, actions.length - 1))];
|
||||
if (target) target.focus();
|
||||
else rootRef.value?.focus();
|
||||
pendingFocus.value = null;
|
||||
});
|
||||
}
|
||||
|
||||
function updateConnections(connectionIdsToUpdate: readonly string[], allowed: boolean, event?: MouseEvent) {
|
||||
if (props.disabled || connectionIdsToUpdate.length === 0) return;
|
||||
const sourcePane: ScopePane = allowed ? "available" : "allowed";
|
||||
if (event?.currentTarget instanceof HTMLElement) {
|
||||
const pane = event.currentTarget.closest<HTMLElement>("[data-scope-pane]");
|
||||
const actions = [...(pane?.querySelectorAll<HTMLButtonElement>("[data-scope-action]") ?? [])];
|
||||
pendingFocus.value = { pane: sourcePane, index: Math.max(0, actions.indexOf(event.currentTarget as HTMLButtonElement)) };
|
||||
}
|
||||
emitAllowedConnectionIds(updateMcpAllowedConnectionIds(props.allowedConnectionIds, connectionIds.value, connectionIdsToUpdate, allowed));
|
||||
restorePendingFocus();
|
||||
}
|
||||
|
||||
function addVisibleConnections() {
|
||||
updateConnections(
|
||||
filteredAvailableConnections.value.map((connection) => connection.id),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
function removeVisibleConnections() {
|
||||
updateConnections([...filteredAllowedConnections.value.map((connection) => connection.id), ...filteredUnavailableAllowedIds.value], false);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.busy,
|
||||
(busy) => {
|
||||
if (!busy) restorePendingFocus();
|
||||
},
|
||||
);
|
||||
|
||||
watch([policyKey, () => groups.value.allowed.length], () => {
|
||||
announcement.value = t("settings.mcpScopeUpdatedAnnouncement", { selected: groups.value.allowed.length, total: props.connections.length });
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="rootRef" tabindex="-1" class="mcp-scope-picker space-y-3 rounded-md border bg-muted/20 p-3 outline-none" :aria-busy="busy">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="text-sm font-medium">{{ t("settings.mcpScopeConnection") }}</div>
|
||||
<Badge variant="outline" class="rounded-md font-normal">{{ allowedSummary }}</Badge>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">{{ t("settings.mcpScopeConnectionDescription") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset :disabled="disabled" class="grid grid-cols-2 rounded-md bg-muted p-1" :aria-label="t('settings.mcpScopeMode')">
|
||||
<label
|
||||
:class="[
|
||||
'flex min-h-11 cursor-pointer flex-col items-center justify-center rounded px-2 py-1.5 text-center transition-colors has-[:focus-visible]:ring-[3px] has-[:focus-visible]:ring-ring/50',
|
||||
scopeMode === 'all' ? 'bg-background text-foreground shadow-sm dark:bg-input/30' : 'text-muted-foreground hover:text-foreground',
|
||||
disabled ? 'cursor-not-allowed opacity-50' : '',
|
||||
]"
|
||||
>
|
||||
<input class="sr-only" type="radio" name="mcp-scope-mode" value="all" :checked="scopeMode === 'all'" @change="setScopeMode('all')" />
|
||||
<span class="text-sm font-medium">{{ t("settings.mcpScopeModeAll") }}</span>
|
||||
<span class="mcp-scope-mode-description text-[11px] leading-tight text-muted-foreground">{{ t("settings.mcpScopeModeAllDescription") }}</span>
|
||||
</label>
|
||||
<label
|
||||
:class="[
|
||||
'flex min-h-11 cursor-pointer flex-col items-center justify-center rounded px-2 py-1.5 text-center transition-colors has-[:focus-visible]:ring-[3px] has-[:focus-visible]:ring-ring/50',
|
||||
scopeMode === 'selected' ? 'bg-background text-foreground shadow-sm dark:bg-input/30' : 'text-muted-foreground hover:text-foreground',
|
||||
disabled ? 'cursor-not-allowed opacity-50' : '',
|
||||
]"
|
||||
>
|
||||
<input class="sr-only" type="radio" name="mcp-scope-mode" value="selected" :checked="scopeMode === 'selected'" @change="setScopeMode('selected')" />
|
||||
<span class="text-sm font-medium">{{ t("settings.mcpScopeModeSelected") }}</span>
|
||||
<span class="mcp-scope-mode-description text-[11px] leading-tight text-muted-foreground">{{ t("settings.mcpScopeModeSelectedDescription") }}</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div class="relative">
|
||||
<Search class="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input v-model="searchQuery" class="h-9 pl-8" :disabled="disabled" :aria-label="t('settings.mcpConnectionSearchPlaceholder')" :placeholder="t('settings.mcpConnectionSearchPlaceholder')" />
|
||||
</div>
|
||||
|
||||
<div class="mcp-scope-mobile-tabs grid grid-cols-2 rounded-md bg-muted p-1" role="tablist" :aria-label="t('settings.mcpScopeConnection')">
|
||||
<button
|
||||
:id="`${pickerId}-allowed-tab`"
|
||||
type="button"
|
||||
role="tab"
|
||||
data-scope-tab="allowed"
|
||||
class="min-w-0 rounded px-2 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
:class="compactPane === 'allowed' ? 'bg-background text-foreground shadow-sm dark:bg-input/30' : 'text-muted-foreground'"
|
||||
:aria-selected="compactPane === 'allowed'"
|
||||
:aria-controls="`${pickerId}-allowed-pane`"
|
||||
@click="compactPane = 'allowed'"
|
||||
>
|
||||
{{ t("settings.mcpScopeAllowedPane") }} {{ allowedTotalCount }}
|
||||
</button>
|
||||
<button
|
||||
:id="`${pickerId}-available-tab`"
|
||||
type="button"
|
||||
role="tab"
|
||||
data-scope-tab="available"
|
||||
class="min-w-0 rounded px-2 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
:class="compactPane === 'available' ? 'bg-background text-foreground shadow-sm dark:bg-input/30' : 'text-muted-foreground'"
|
||||
:aria-selected="compactPane === 'available'"
|
||||
:aria-controls="`${pickerId}-available-pane`"
|
||||
@click="compactPane = 'available'"
|
||||
>
|
||||
{{ t("settings.mcpScopeAvailablePane") }} {{ availableTotalCount }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mcp-scope-transfer overflow-hidden rounded-md border bg-background">
|
||||
<section :id="`${pickerId}-available-pane`" data-scope-pane="available" class="mcp-scope-pane min-w-0 flex-col" :class="{ 'mcp-scope-pane--active': compactPane === 'available' }" role="region" :aria-label="t('settings.mcpScopeAvailablePane')">
|
||||
<div class="flex min-h-10 items-center justify-between gap-2 border-b px-2.5 py-1.5">
|
||||
<div class="flex min-w-0 items-center gap-2 text-sm font-medium">
|
||||
<span class="truncate">{{ t("settings.mcpScopeAvailablePane") }}</span>
|
||||
<Badge variant="secondary" class="rounded-md font-normal">{{ paneCount(availableVisibleCount, availableTotalCount) }}</Badge>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
data-scope-batch="available"
|
||||
:title="t(searchActive ? 'settings.mcpScopeAddMatches' : 'settings.mcpScopeAddAll', { count: availableVisibleCount })"
|
||||
:aria-label="t(searchActive ? 'settings.mcpScopeAddMatches' : 'settings.mcpScopeAddAll', { count: availableVisibleCount })"
|
||||
:disabled="disabled || availableVisibleCount === 0"
|
||||
@click="addVisibleConnections"
|
||||
>
|
||||
<Plus />
|
||||
<span class="mcp-scope-batch-label">{{ t(searchActive ? "settings.mcpScopeAddMatches" : "settings.mcpScopeAddAll", { count: availableVisibleCount }) }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="h-64 overflow-y-auto p-1.5">
|
||||
<div v-if="filteredAvailableConnections.length === 0" class="flex h-full items-center justify-center px-3 text-center text-sm text-muted-foreground">
|
||||
{{ searchActive ? t("settings.mcpConnectionSearchEmpty") : t("settings.mcpScopeAvailableEmpty") }}
|
||||
</div>
|
||||
<div v-for="connection in filteredAvailableConnections" :key="connection.id" class="mcp-scope-connection-row grid min-h-12 items-center gap-2 rounded px-2 py-1.5 hover:bg-muted/60">
|
||||
<div class="min-w-0">
|
||||
<TruncatedTextTooltip :text="connection.name" class="block text-sm font-medium" />
|
||||
<TruncatedTextTooltip :text="connectionAddress(connection)" class="mt-0.5 block font-mono text-[11px] text-muted-foreground" />
|
||||
</div>
|
||||
<div class="flex min-w-0 items-center justify-center">
|
||||
<Badge :title="connection.db_type.toUpperCase()" variant="outline" class="max-w-full justify-center rounded px-1.5 py-0 text-[10px] font-normal uppercase">
|
||||
<span class="truncate">{{ connection.db_type }}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
class="justify-self-end"
|
||||
data-scope-action
|
||||
:data-connection-id="connection.id"
|
||||
:title="t('settings.mcpScopeAddConnection', { name: connection.name })"
|
||||
:aria-label="t('settings.mcpScopeAddConnection', { name: connection.name })"
|
||||
:disabled="disabled"
|
||||
@click="updateConnections([connection.id], true, $event)"
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section :id="`${pickerId}-allowed-pane`" data-scope-pane="allowed" class="mcp-scope-pane min-w-0 flex-col" :class="{ 'mcp-scope-pane--active': compactPane === 'allowed' }" role="region" :aria-label="t('settings.mcpScopeAllowedPane')">
|
||||
<div class="flex min-h-10 items-center justify-between gap-2 border-b px-2.5 py-1.5">
|
||||
<div class="flex min-w-0 items-center gap-2 text-sm font-medium">
|
||||
<span class="truncate">{{ t("settings.mcpScopeAllowedPane") }}</span>
|
||||
<Badge variant="secondary" class="rounded-md font-normal">{{ paneCount(allowedVisibleCount, allowedTotalCount) }}</Badge>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
data-scope-batch="allowed"
|
||||
:title="t(searchActive ? 'settings.mcpScopeRemoveMatches' : 'settings.mcpScopeRemoveAll', { count: allowedVisibleCount })"
|
||||
:aria-label="t(searchActive ? 'settings.mcpScopeRemoveMatches' : 'settings.mcpScopeRemoveAll', { count: allowedVisibleCount })"
|
||||
:disabled="disabled || allowedVisibleCount === 0"
|
||||
@click="removeVisibleConnections"
|
||||
>
|
||||
<Minus />
|
||||
<span class="mcp-scope-batch-label">{{ t(searchActive ? "settings.mcpScopeRemoveMatches" : "settings.mcpScopeRemoveAll", { count: allowedVisibleCount }) }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="h-64 overflow-y-auto p-1.5">
|
||||
<div v-if="filteredAllowedConnections.length === 0 && filteredUnavailableAllowedIds.length === 0" class="flex h-full items-center justify-center px-3 text-center text-sm text-muted-foreground">
|
||||
{{ searchActive ? t("settings.mcpConnectionSearchEmpty") : t("settings.mcpScopeAllowedEmpty") }}
|
||||
</div>
|
||||
<div v-for="connection in filteredAllowedConnections" :key="connection.id" class="mcp-scope-connection-row grid min-h-12 items-center gap-2 rounded px-2 py-1.5 hover:bg-muted/60">
|
||||
<div class="min-w-0">
|
||||
<TruncatedTextTooltip :text="connection.name" class="block text-sm font-medium" />
|
||||
<TruncatedTextTooltip :text="connectionAddress(connection)" class="mt-0.5 block font-mono text-[11px] text-muted-foreground" />
|
||||
</div>
|
||||
<div class="flex min-w-0 items-center justify-center">
|
||||
<Badge :title="connection.db_type.toUpperCase()" variant="outline" class="max-w-full justify-center rounded px-1.5 py-0 text-[10px] font-normal uppercase">
|
||||
<span class="truncate">{{ connection.db_type }}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
class="justify-self-end"
|
||||
data-scope-action
|
||||
:data-connection-id="connection.id"
|
||||
:title="t('settings.mcpScopeRemoveConnection', { name: connection.name })"
|
||||
:aria-label="t('settings.mcpScopeRemoveConnection', { name: connection.name })"
|
||||
:disabled="disabled"
|
||||
@click="updateConnections([connection.id], false, $event)"
|
||||
>
|
||||
<Minus />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredUnavailableAllowedIds.length > 0" class="mt-1 border-t border-amber-500/20 pt-1">
|
||||
<div class="flex items-center gap-1.5 px-2 py-1 text-[11px] font-medium text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle class="h-3.5 w-3.5" />
|
||||
{{ t("settings.mcpScopeUnavailableGroup", { count: filteredUnavailableAllowedIds.length }) }}
|
||||
</div>
|
||||
<div v-for="connectionId in filteredUnavailableAllowedIds" :key="connectionId" class="flex min-h-10 items-center gap-2 rounded px-2 py-1.5 text-amber-600 hover:bg-muted/60 dark:text-amber-400">
|
||||
<TruncatedTextTooltip :text="connectionId" class="min-w-0 flex-1 font-mono text-xs" />
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
data-scope-action
|
||||
:data-connection-id="connectionId"
|
||||
:title="t('settings.mcpScopeRemoveUnavailableConnection', { id: connectionId })"
|
||||
:aria-label="t('settings.mcpScopeRemoveUnavailableConnection', { id: connectionId })"
|
||||
:disabled="disabled"
|
||||
@click="updateConnections([connectionId], false, $event)"
|
||||
>
|
||||
<Minus />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<p v-if="groups.unavailableAllowedIds.length > 0" class="text-xs text-amber-600 dark:text-amber-400">
|
||||
{{ t("settings.mcpScopeConnectionUnavailableDescription", { count: groups.unavailableAllowedIds.length }) }}
|
||||
</p>
|
||||
<span class="sr-only" aria-live="polite">{{ announcement }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mcp-scope-picker {
|
||||
container: mcp-scope / inline-size;
|
||||
}
|
||||
|
||||
.mcp-scope-pane {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mcp-scope-pane--active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.mcp-scope-connection-row {
|
||||
grid-template-columns: minmax(0, 1fr) 6.75rem 1.75rem;
|
||||
}
|
||||
|
||||
@container mcp-scope (min-width: 42rem) {
|
||||
.mcp-scope-mobile-tabs {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mcp-scope-transfer {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.mcp-scope-pane {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.mcp-scope-pane + .mcp-scope-pane {
|
||||
border-inline-start: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
@container mcp-scope (max-width: 28rem) {
|
||||
.mcp-scope-mode-description,
|
||||
.mcp-scope-batch-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mcp-scope-connection-row {
|
||||
grid-template-columns: minmax(0, 1fr) 5.5rem 1.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
89
apps/desktop/src/components/settings/__tests__/McpConnectionScopePicker.spec.ts
vendored
Normal file
89
apps/desktop/src/components/settings/__tests__/McpConnectionScopePicker.spec.ts
vendored
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { dispatch, findAll, findOne, mountComponent } from "@/components/grid/__tests__/vueHostHarness";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
|
||||
vi.mock("vue-i18n", () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string, values?: Record<string, unknown>) => `${key}${values ? ` ${JSON.stringify(values)}` : ""}`,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@lucide/vue", async () => {
|
||||
const { createPassthroughStub } = await import("@/components/grid/__tests__/vueHostHarness");
|
||||
const icon = createPassthroughStub("Icon", "i");
|
||||
return { AlertTriangle: icon, Minus: icon, Plus: icon, Search: icon };
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/badge", async () => ({ Badge: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("Badge", "span") }));
|
||||
vi.mock("@/components/ui/button", async () => ({ Button: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("Button", "button") }));
|
||||
vi.mock("@/components/ui/input", async () => ({ Input: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("Input", "input") }));
|
||||
vi.mock("@/components/ui/TruncatedTextTooltip.vue", async () => ({ default: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("TruncatedTextTooltip", "span") }));
|
||||
|
||||
import McpConnectionScopePicker from "@/components/settings/McpConnectionScopePicker.vue";
|
||||
|
||||
function connection(id: string): ConnectionConfig {
|
||||
return {
|
||||
id,
|
||||
name: `${id}-name`,
|
||||
db_type: "mysql",
|
||||
host: "127.0.0.1",
|
||||
port: 3306,
|
||||
username: "test",
|
||||
password: "",
|
||||
};
|
||||
}
|
||||
|
||||
describe("McpConnectionScopePicker", () => {
|
||||
it("separates allowed and available connections and emits direct moves", () => {
|
||||
const update = vi.fn();
|
||||
const mounted = mountComponent(McpConnectionScopePicker, {
|
||||
connections: [connection("one"), connection("two")],
|
||||
allowedConnectionIds: ["one"],
|
||||
"onUpdate:allowedConnectionIds": update,
|
||||
});
|
||||
|
||||
const allowedPane = findOne(mounted.root, (node) => node.props["data-scope-pane"] === "allowed");
|
||||
const availablePane = findOne(mounted.root, (node) => node.props["data-scope-pane"] === "available");
|
||||
expect(findAll(allowedPane, (node) => node.props["data-stub"] === "TruncatedTextTooltip").map((node) => node.props.text)).toContain("one-name");
|
||||
expect(findAll(allowedPane, (node) => node.props["data-stub"] === "TruncatedTextTooltip").map((node) => node.props.text)).not.toContain("two-name");
|
||||
expect(findAll(availablePane, (node) => node.props["data-stub"] === "TruncatedTextTooltip").map((node) => node.props.text)).toContain("two-name");
|
||||
|
||||
const add = findOne(availablePane, (node) => node.props["data-connection-id"] === "two");
|
||||
dispatch(add, "click");
|
||||
expect(update).toHaveBeenCalledWith(["one", "two"]);
|
||||
|
||||
const remove = findOne(allowedPane, (node) => node.props["data-connection-id"] === "one");
|
||||
dispatch(remove, "click");
|
||||
expect(update).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it("keeps dynamic allow-all distinct from an explicit current-connection list", () => {
|
||||
const update = vi.fn();
|
||||
const mounted = mountComponent(McpConnectionScopePicker, {
|
||||
connections: [connection("one"), connection("two")],
|
||||
allowedConnectionIds: null,
|
||||
"onUpdate:allowedConnectionIds": update,
|
||||
});
|
||||
const modeInputs = findAll(mounted.root, (node) => node.type === "input" && node.props.name === "mcp-scope-mode");
|
||||
|
||||
expect(modeInputs.find((input) => input.props.value === "all")?.props.checked).toBe(true);
|
||||
dispatch(
|
||||
findOne(mounted.root, (node) => node.type === "input" && node.props.value === "selected"),
|
||||
"change",
|
||||
);
|
||||
expect(update).toHaveBeenCalledWith(["one", "two"]);
|
||||
});
|
||||
|
||||
it("shows unavailable allowlist entries only in the allowed pane", () => {
|
||||
const mounted = mountComponent(McpConnectionScopePicker, {
|
||||
connections: [connection("one")],
|
||||
allowedConnectionIds: ["one", "missing-id"],
|
||||
});
|
||||
const allowedPane = findOne(mounted.root, (node) => node.props["data-scope-pane"] === "allowed");
|
||||
const availablePane = findOne(mounted.root, (node) => node.props["data-scope-pane"] === "available");
|
||||
expect(findAll(allowedPane, (node) => node.props["data-stub"] === "TruncatedTextTooltip").map((node) => node.props.text)).toContain("missing-id");
|
||||
expect(findAll(availablePane, (node) => node.props["data-stub"] === "TruncatedTextTooltip").map((node) => node.props.text)).not.toContain("missing-id");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import type { NavigationTarget } from "@/composables/useNavigationTargets";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
|
||||
export function useTauriEvents(deps: { openTableTarget: (target: NavigationTarget) => Promise<void>; openSqlFilePath: (path: string) => Promise<void>; openDbFilePath: (path: string) => Promise<void>; openConnectionDeepLink: (url: string) => Promise<void> }) {
|
||||
const connectionStore = useConnectionStore();
|
||||
|
|
@ -51,21 +52,15 @@ export function useTauriEvents(deps: { openTableTarget: (target: NavigationTarge
|
|||
connection_id: string;
|
||||
database: string;
|
||||
sql: string;
|
||||
allow_writes?: boolean;
|
||||
allow_dangerous?: boolean;
|
||||
results: QueryResult[];
|
||||
}>("mcp-execute-query", async (event) => {
|
||||
try {
|
||||
const { connection_id, database, sql, allow_writes, allow_dangerous } = event.payload;
|
||||
const { connection_id, database, sql, results } = event.payload;
|
||||
if (!connectionStore.connections.length) await connectionStore.initFromDisk();
|
||||
const config = connectionStore.getConfig(connection_id);
|
||||
if (!config) return;
|
||||
connectionStore.activeConnectionId = connection_id;
|
||||
await connectionStore.ensureConnected(connection_id);
|
||||
const tabId = queryStore.createTab(connection_id, database, undefined, "query");
|
||||
queryStore.updateSql(tabId, sql);
|
||||
await queryStore.executeTabSql(tabId, sql, {
|
||||
mongoSafety: { allowWrites: !!allow_writes, allowDangerous: !!allow_dangerous },
|
||||
});
|
||||
queryStore.showExecutedQueryResults(connection_id, database, sql, results);
|
||||
focusCurrentWindow();
|
||||
} catch (e) {
|
||||
console.error("[DBX] mcp-execute-query error:", e);
|
||||
|
|
|
|||
|
|
@ -3911,18 +3911,70 @@ export default {
|
|||
mcpInstallSuccess: "Installation successful",
|
||||
mcpInstallFailed: "Installation failed",
|
||||
mcpConfig: "MCP config",
|
||||
mcpConfigOptionsHint: "These options only update the generated config below. Copy it into your AI client and restart the MCP session to apply it.",
|
||||
mcpConfigOptionsHint: "DBX centrally controls connection access and execution permissions for every MCP client. Generated configs contain only the runtime settings needed to start the MCP server and connect to this DBX instance, with no permission or connection-scope variables.",
|
||||
mcpScopeConnection: "Connections allowed for MCP",
|
||||
mcpScopeConnectionDescription: "DBX exposes only the selected connections to MCP. Changes apply to every MCP client.",
|
||||
mcpScopeMode: "Connection scope mode",
|
||||
mcpScopeModeAll: "All connections",
|
||||
mcpScopeModeAllDescription: "Automatically includes future connections",
|
||||
mcpScopeModeSelected: "Specific connections",
|
||||
mcpScopeModeSelectedDescription: "Only explicitly added connections",
|
||||
mcpScopeAllowedPane: "Allowed",
|
||||
mcpScopeAvailablePane: "Available",
|
||||
mcpScopeAddAll: "Add all",
|
||||
mcpScopeRemoveAll: "Remove all",
|
||||
mcpScopeAddMatches: "Add matches ({count})",
|
||||
mcpScopeRemoveMatches: "Remove matches ({count})",
|
||||
mcpScopeAddConnection: "Allow MCP access to {name}",
|
||||
mcpScopeRemoveConnection: "Remove MCP access to {name}",
|
||||
mcpScopeAvailableEmpty: "No connections available to add",
|
||||
mcpScopeAllowedEmpty: "No connections are currently allowed",
|
||||
mcpScopeUnavailableGroup: "Unavailable connections ({count})",
|
||||
mcpScopeRemoveUnavailableConnection: "Remove unavailable connection {id} from the allowlist",
|
||||
mcpScopeUpdatedAnnouncement: "MCP currently allows {selected} of {total} available connections",
|
||||
mcpConnectionSearchPlaceholder: "Search name, type, address, database, or connection ID",
|
||||
mcpConnectionSearchEmpty: "No matching connections",
|
||||
mcpConnectionListEmpty: "No connections available",
|
||||
mcpScopeAllAction: "Allow all",
|
||||
mcpScopeNoneAction: "Clear all",
|
||||
mcpScopeAllSummary: "All {count} connections",
|
||||
mcpScopeSelectedSummary: "{selected}/{total} selected",
|
||||
mcpScopeAllConnections: "All connections",
|
||||
mcpScopeNoConnections: "No connections",
|
||||
mcpScopeConnectionUnavailable: "Previously selected connection (unavailable)",
|
||||
mcpScopeConnectionUnavailableDescription: "{count} selected connection(s) no longer exist. Their IDs remain in the allowlist, but no current connections are exposed for them.",
|
||||
mcpScopeSelectedCount: "{count} connection(s) selected",
|
||||
mcpCodexConfig: "Codex config",
|
||||
mcpCodexConfigPath: "Codex can use ~/.codex/config.toml or a project-level .codex/config.toml.",
|
||||
mcpCursorConfigPath: "Cursor can use .cursor/mcp.json in the project or ~/.cursor/mcp.json globally.",
|
||||
mcpTraeConfigPath: "TRAE: Settings > MCP > Add > Add Manually, then paste the JSON configuration.",
|
||||
mcpCherryStudioConfigPath: "Cherry Studio: Settings > MCP Servers > Add Server > Import JSON, then paste the configuration.",
|
||||
mcpVsCodeConfigPath: "VS Code/Copilot can use .vscode/mcp.json in the workspace or mcp.json in the user profile.",
|
||||
mcpWindsurfConfigPath: "Windsurf can use ~/.codeium/windsurf/mcp_config.json.",
|
||||
mcpOpenCodeConfigPath: "~/.config/opencode/opencode.json globally, or opencode.json at the project level.",
|
||||
mcpReadonlyMode: "Read-only mode",
|
||||
mcpReadonlyModeDescription: "Adds DBX_MCP_ALLOW_WRITES=0 to the sample config so the MCP session stays query-only.",
|
||||
mcpAllowDangerous: "Allow dangerous SQL",
|
||||
mcpAllowDangerousDescription: "Adds DBX_MCP_ALLOW_DANGEROUS_SQL=1 to the sample config so DROP, TRUNCATE, ALTER, and similar statements are allowed.",
|
||||
mcpExecutionMode: "MCP execution permission",
|
||||
mcpExecutionModeDescription: "Choose the permission level DBX enforces for every MCP client. Client-side read/write settings cannot change this level.",
|
||||
mcpExecutionModeReadOnly: "Read only",
|
||||
mcpExecutionModeSafeWrite: "Data read/write",
|
||||
mcpExecutionModeRecommended: "Recommended",
|
||||
mcpExecutionModeHighRiskWrite: "Full access",
|
||||
mcpExecutionModeReadOnlyDescription: "Allows only requests DBX classifies as reads; blocks recognized database writes and connection changes. Database-account permissions remain the final hard boundary.",
|
||||
mcpExecutionModeSafeWriteDescription: "Allows regular INSERT, filtered UPDATE/DELETE, verifiably scoped MongoDB changes, and ordinary Redis writes or explicit-key deletion; blocks table-wide and structural changes.",
|
||||
mcpExecutionModeHighRiskWriteDescription: "In addition to data read/write, allows table-wide changes, DDL, TRUNCATE, database clearing, and other high-risk operations. Production and connection read-only protections still apply.",
|
||||
mcpExecutionModeHighRiskConfirm: "Full access allows table-wide changes, DDL, TRUNCATE, database clearing, and other high-risk operations. Enable it?",
|
||||
mcpCapabilityTitle: "Capability comparison",
|
||||
mcpCapabilityDescription: "These are representative operations; requests DBX cannot classify reliably require full access.",
|
||||
mcpCapabilityOperation: "Capability",
|
||||
mcpCapabilityRead: "Queries and reads",
|
||||
mcpCapabilityScopedMutation: "Scoped data changes (INSERT, filtered UPDATE/DELETE, explicit-key writes/deletion)",
|
||||
mcpCapabilityBroadMutation: "Table-wide updates/deletes and data clearing",
|
||||
mcpCapabilitySchemaAdmin: "DDL, structural changes, and high-risk administration",
|
||||
mcpCapabilityConnectionManagement: "Add/remove MCP connections",
|
||||
mcpCapabilityAllowed: "Allowed",
|
||||
mcpCapabilityBlocked: "Blocked",
|
||||
mcpCapabilityAlwaysEnforced: "Every mode remains subject to the connection allowlist, connection read-only protection, production protection, and database-account permissions.",
|
||||
mcpPolicySaveFailed: "Failed to save the MCP access policy: {error}",
|
||||
mcpPolicyLoadFailed: "Failed to load the MCP access policy: {error}",
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -3665,18 +3665,71 @@ export default withEnglishFallback({
|
|||
mcpInstallSuccess: "Instalación exitosa",
|
||||
mcpInstallFailed: "Error de instalación",
|
||||
mcpConfig: "Configuración de MCP",
|
||||
mcpConfigOptionsHint: "Estas opciones solo actualizan la configuración generada. Cópiala al cliente de IA y reinicia la sesión MCP para aplicarla.",
|
||||
mcpConfigOptionsHint:
|
||||
"DBX controla de forma central el acceso a conexiones y los permisos de ejecución para todos los clientes MCP. La configuración generada solo contiene los parámetros de ejecución necesarios para iniciar el servidor MCP y conectarlo a esta instancia de DBX, sin variables de permisos ni de ámbito de conexión.",
|
||||
mcpScopeConnection: "Conexiones permitidas para MCP",
|
||||
mcpScopeConnectionDescription: "DBX solo expone a MCP las conexiones seleccionadas. Los cambios se aplican a todos los clientes MCP.",
|
||||
mcpScopeMode: "Modo de ámbito de conexiones",
|
||||
mcpScopeModeAll: "Todas las conexiones",
|
||||
mcpScopeModeAllDescription: "Incluye automáticamente las conexiones futuras",
|
||||
mcpScopeModeSelected: "Conexiones específicas",
|
||||
mcpScopeModeSelectedDescription: "Solo las conexiones añadidas explícitamente",
|
||||
mcpScopeAllowedPane: "Permitidas",
|
||||
mcpScopeAvailablePane: "Disponibles",
|
||||
mcpScopeAddAll: "Añadir todas",
|
||||
mcpScopeRemoveAll: "Quitar todas",
|
||||
mcpScopeAddMatches: "Añadir coincidencias ({count})",
|
||||
mcpScopeRemoveMatches: "Quitar coincidencias ({count})",
|
||||
mcpScopeAddConnection: "Permitir que MCP acceda a {name}",
|
||||
mcpScopeRemoveConnection: "Quitar el acceso MCP a {name}",
|
||||
mcpScopeAvailableEmpty: "No hay conexiones disponibles para añadir",
|
||||
mcpScopeAllowedEmpty: "Actualmente no hay conexiones permitidas",
|
||||
mcpScopeUnavailableGroup: "Conexiones no disponibles ({count})",
|
||||
mcpScopeRemoveUnavailableConnection: "Quitar la conexión no disponible {id} de la lista permitida",
|
||||
mcpScopeUpdatedAnnouncement: "MCP permite actualmente {selected} de {total} conexiones disponibles",
|
||||
mcpConnectionSearchPlaceholder: "Buscar por nombre, tipo, dirección, base de datos o ID",
|
||||
mcpConnectionSearchEmpty: "No hay conexiones coincidentes",
|
||||
mcpConnectionListEmpty: "No hay conexiones disponibles",
|
||||
mcpScopeAllAction: "Permitir todas",
|
||||
mcpScopeNoneAction: "Quitar todas",
|
||||
mcpScopeAllSummary: "Las {count} conexiones",
|
||||
mcpScopeSelectedSummary: "{selected}/{total} seleccionadas",
|
||||
mcpScopeAllConnections: "Todas las conexiones",
|
||||
mcpScopeNoConnections: "Ninguna conexión",
|
||||
mcpScopeConnectionUnavailable: "Conexión seleccionada anteriormente (no disponible)",
|
||||
mcpScopeConnectionUnavailableDescription: "Ya no existen {count} conexiones seleccionadas. Sus ID siguen en la lista de permitidas, pero actualmente no se expone ninguna conexión correspondiente.",
|
||||
mcpScopeSelectedCount: "{count} conexiones seleccionadas",
|
||||
mcpCodexConfig: "Configuración de Codex",
|
||||
mcpCodexConfigPath: "Codex puede usar ~/.codex/config.toml o .codex/config.toml dentro del proyecto.",
|
||||
mcpCursorConfigPath: "Cursor puede usar .cursor/mcp.json en el proyecto o ~/.cursor/mcp.json globalmente.",
|
||||
mcpTraeConfigPath: "TRAE: Configuración > MCP > Agregar > Agregar manualmente; luego pega la configuración JSON.",
|
||||
mcpCherryStudioConfigPath: "Cherry Studio: Configuración > Servidores MCP > Agregar servidor > Importar JSON; luego pega la configuración.",
|
||||
mcpVsCodeConfigPath: "VS Code/Copilot puede usar .vscode/mcp.json en el espacio de trabajo o mcp.json en el perfil de usuario.",
|
||||
mcpWindsurfConfigPath: "Windsurf puede usar ~/.codeium/windsurf/mcp_config.json.",
|
||||
mcpOpenCodeConfigPath: "~/.config/opencode/opencode.json global, o opencode.json a nivel de proyecto.",
|
||||
mcpReadonlyMode: "Modo solo lectura",
|
||||
mcpReadonlyModeDescription: "Añade DBX_MCP_ALLOW_WRITES=0 al ejemplo de configuración para que la sesión MCP solo permita consultas.",
|
||||
mcpAllowDangerous: "Permitir SQL peligroso",
|
||||
mcpAllowDangerousDescription: "Añade DBX_MCP_ALLOW_DANGEROUS_SQL=1 al ejemplo de configuración para permitir DROP, TRUNCATE, ALTER y sentencias similares.",
|
||||
mcpExecutionMode: "Permiso de ejecución MCP",
|
||||
mcpExecutionModeDescription: "Elige el nivel de permiso que DBX aplica a todos los clientes MCP. La configuración de lectura y escritura del cliente no puede cambiar este nivel.",
|
||||
mcpExecutionModeReadOnly: "Solo lectura",
|
||||
mcpExecutionModeSafeWrite: "Lectura y escritura de datos",
|
||||
mcpExecutionModeRecommended: "Recomendado",
|
||||
mcpExecutionModeHighRiskWrite: "Acceso completo",
|
||||
mcpExecutionModeReadOnlyDescription: "Solo permite solicitudes que DBX clasifica como lecturas; bloquea las escrituras reconocidas y los cambios de conexión. Los permisos de la cuenta de base de datos siguen siendo el límite de seguridad final.",
|
||||
mcpExecutionModeSafeWriteDescription: "Permite INSERT normales, UPDATE/DELETE con filtros efectivos, cambios MongoDB de alcance verificable y escrituras Redis normales o borrado de claves explícitas; bloquea cambios globales y estructurales.",
|
||||
mcpExecutionModeHighRiskWriteDescription: "Además de leer y escribir datos, permite cambios de tablas completas, DDL, TRUNCATE, vaciado de bases de datos y otras operaciones de alto riesgo. Las protecciones de producción y solo lectura siguen activas.",
|
||||
mcpExecutionModeHighRiskConfirm: "El acceso completo permite cambios de tablas completas, DDL, TRUNCATE, vaciado de bases de datos y otras operaciones de alto riesgo. ¿Quieres activarlo?",
|
||||
mcpCapabilityTitle: "Comparación de capacidades",
|
||||
mcpCapabilityDescription: "Estas son operaciones representativas; las solicitudes que DBX no puede clasificar de forma fiable requieren acceso completo.",
|
||||
mcpCapabilityOperation: "Capacidad",
|
||||
mcpCapabilityRead: "Consultas y lecturas",
|
||||
mcpCapabilityScopedMutation: "Cambios de datos acotados (INSERT, UPDATE/DELETE filtrados, escritura/borrado de claves explícitas)",
|
||||
mcpCapabilityBroadMutation: "Actualizaciones/borrados de tablas completas y vaciado de datos",
|
||||
mcpCapabilitySchemaAdmin: "DDL, cambios estructurales y administración de alto riesgo",
|
||||
mcpCapabilityConnectionManagement: "Agregar/eliminar conexiones MCP",
|
||||
mcpCapabilityAllowed: "Permitido",
|
||||
mcpCapabilityBlocked: "Bloqueado",
|
||||
mcpCapabilityAlwaysEnforced: "Todos los modos siguen sujetos a la lista de conexiones permitidas, la protección de solo lectura, la protección de producción y los permisos de la cuenta de base de datos.",
|
||||
mcpPolicySaveFailed: "No se pudo guardar la política de acceso MCP: {error}",
|
||||
mcpPolicyLoadFailed: "No se pudo cargar la política de acceso MCP: {error}",
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -3663,18 +3663,71 @@ export default withEnglishFallback({
|
|||
mcpInstallSuccess: "Installazione completata",
|
||||
mcpInstallFailed: "Installazione fallita",
|
||||
mcpConfig: "Configurazione MCP",
|
||||
mcpConfigOptionsHint: "Queste opzioni aggiornano solo la configurazione generata. Copiala nel client AI e riavvia la sessione MCP per applicarla.",
|
||||
mcpConfigOptionsHint:
|
||||
"DBX controlla centralmente l'accesso alle connessioni e i permessi di esecuzione per tutti i client MCP. La configurazione generata contiene solo i parametri di runtime necessari per avviare il server MCP e collegarlo a questa istanza DBX, senza variabili di autorizzazione o ambito connessione.",
|
||||
mcpScopeConnection: "Connessioni consentite per MCP",
|
||||
mcpScopeConnectionDescription: "DBX espone a MCP solo le connessioni selezionate. Le modifiche si applicano a tutti i client MCP.",
|
||||
mcpScopeMode: "Modalità ambito connessioni",
|
||||
mcpScopeModeAll: "Tutte le connessioni",
|
||||
mcpScopeModeAllDescription: "Include automaticamente le connessioni future",
|
||||
mcpScopeModeSelected: "Connessioni specifiche",
|
||||
mcpScopeModeSelectedDescription: "Solo le connessioni aggiunte esplicitamente",
|
||||
mcpScopeAllowedPane: "Consentite",
|
||||
mcpScopeAvailablePane: "Disponibili",
|
||||
mcpScopeAddAll: "Aggiungi tutte",
|
||||
mcpScopeRemoveAll: "Rimuovi tutte",
|
||||
mcpScopeAddMatches: "Aggiungi corrispondenze ({count})",
|
||||
mcpScopeRemoveMatches: "Rimuovi corrispondenze ({count})",
|
||||
mcpScopeAddConnection: "Consenti a MCP di accedere a {name}",
|
||||
mcpScopeRemoveConnection: "Rimuovi l'accesso MCP a {name}",
|
||||
mcpScopeAvailableEmpty: "Nessuna connessione disponibile da aggiungere",
|
||||
mcpScopeAllowedEmpty: "Nessuna connessione è attualmente consentita",
|
||||
mcpScopeUnavailableGroup: "Connessioni non disponibili ({count})",
|
||||
mcpScopeRemoveUnavailableConnection: "Rimuovi la connessione non disponibile {id} dall'elenco consentito",
|
||||
mcpScopeUpdatedAnnouncement: "MCP consente attualmente {selected} connessioni disponibili su {total}",
|
||||
mcpConnectionSearchPlaceholder: "Cerca nome, tipo, indirizzo, database o ID connessione",
|
||||
mcpConnectionSearchEmpty: "Nessuna connessione corrispondente",
|
||||
mcpConnectionListEmpty: "Nessuna connessione disponibile",
|
||||
mcpScopeAllAction: "Consenti tutte",
|
||||
mcpScopeNoneAction: "Deseleziona tutte",
|
||||
mcpScopeAllSummary: "Tutte le {count} connessioni",
|
||||
mcpScopeSelectedSummary: "{selected}/{total} selezionate",
|
||||
mcpScopeAllConnections: "Tutte le connessioni",
|
||||
mcpScopeNoConnections: "Nessuna connessione",
|
||||
mcpScopeConnectionUnavailable: "Connessione selezionata in precedenza (non disponibile)",
|
||||
mcpScopeConnectionUnavailableDescription: "{count} connessioni selezionate non esistono più. I relativi ID restano nell'elenco consentito, ma al momento non viene esposta alcuna connessione corrispondente.",
|
||||
mcpScopeSelectedCount: "{count} connessioni selezionate",
|
||||
mcpCodexConfig: "Configurazione Codex",
|
||||
mcpCodexConfigPath: "Codex può utilizzare ~/.codex/config.toml o un .codex/config.toml a livello di progetto.",
|
||||
mcpCursorConfigPath: "Cursor può usare .cursor/mcp.json nel progetto o ~/.cursor/mcp.json globalmente.",
|
||||
mcpTraeConfigPath: "TRAE: Impostazioni > MCP > Aggiungi > Aggiungi manualmente, poi incolla la configurazione JSON.",
|
||||
mcpCherryStudioConfigPath: "Cherry Studio: Impostazioni > Server MCP > Aggiungi server > Importa JSON, quindi incolla la configurazione.",
|
||||
mcpVsCodeConfigPath: "VS Code/Copilot può usare .vscode/mcp.json nell'area di lavoro o mcp.json nel profilo utente.",
|
||||
mcpWindsurfConfigPath: "Windsurf può usare ~/.codeium/windsurf/mcp_config.json.",
|
||||
mcpOpenCodeConfigPath: "~/.config/opencode/opencode.json globalmente, o opencode.json a livello di progetto.",
|
||||
mcpReadonlyMode: "Modalità sola lettura",
|
||||
mcpReadonlyModeDescription: "Aggiunge DBX_MCP_ALLOW_WRITES=0 alla configurazione di esempio in modo che la sessione MCP rimanga di sola query.",
|
||||
mcpAllowDangerous: "Consenti SQL pericoloso",
|
||||
mcpAllowDangerousDescription: "Aggiunge DBX_MCP_ALLOW_DANGEROUS_SQL=1 alla configurazione di esempio in modo che siano consentite istruzioni come DROP, TRUNCATE, ALTER e simili.",
|
||||
mcpExecutionMode: "Permesso di esecuzione MCP",
|
||||
mcpExecutionModeDescription: "Scegli il livello di autorizzazione applicato da DBX a tutti i client MCP. Le impostazioni di lettura e scrittura del client non possono modificare questo livello.",
|
||||
mcpExecutionModeReadOnly: "Sola lettura",
|
||||
mcpExecutionModeSafeWrite: "Lettura e scrittura dati",
|
||||
mcpExecutionModeRecommended: "Consigliato",
|
||||
mcpExecutionModeHighRiskWrite: "Accesso completo",
|
||||
mcpExecutionModeReadOnlyDescription: "Consente solo le richieste che DBX classifica come letture; blocca le scritture riconosciute e le modifiche alle connessioni. I permessi dell'account del database restano il limite di sicurezza finale.",
|
||||
mcpExecutionModeSafeWriteDescription: "Consente INSERT normali, UPDATE/DELETE con filtri effettivi, modifiche MongoDB con ambito verificabile e normali scritture Redis o eliminazione di chiavi esplicite; blocca modifiche globali e strutturali.",
|
||||
mcpExecutionModeHighRiskWriteDescription: "Oltre alla lettura e scrittura dati, consente modifiche a intere tabelle, DDL, TRUNCATE, svuotamento del database e altre operazioni ad alto rischio. Le protezioni di produzione e sola lettura restano attive.",
|
||||
mcpExecutionModeHighRiskConfirm: "L'accesso completo consente modifiche a intere tabelle, DDL, TRUNCATE, svuotamento del database e altre operazioni ad alto rischio. Abilitarlo?",
|
||||
mcpCapabilityTitle: "Confronto delle capacità",
|
||||
mcpCapabilityDescription: "Queste sono operazioni rappresentative; le richieste che DBX non può classificare in modo affidabile richiedono l'accesso completo.",
|
||||
mcpCapabilityOperation: "Capacità",
|
||||
mcpCapabilityRead: "Query e letture",
|
||||
mcpCapabilityScopedMutation: "Modifiche dati circoscritte (INSERT, UPDATE/DELETE filtrati, scrittura/eliminazione di chiavi esplicite)",
|
||||
mcpCapabilityBroadMutation: "Aggiornamenti/eliminazioni di intere tabelle e svuotamento dati",
|
||||
mcpCapabilitySchemaAdmin: "DDL, modifiche strutturali e amministrazione ad alto rischio",
|
||||
mcpCapabilityConnectionManagement: "Aggiunta/rimozione connessioni MCP",
|
||||
mcpCapabilityAllowed: "Consentito",
|
||||
mcpCapabilityBlocked: "Bloccato",
|
||||
mcpCapabilityAlwaysEnforced: "Tutte le modalità restano soggette all'elenco delle connessioni consentite, alla protezione di sola lettura, alla protezione di produzione e ai permessi dell'account database.",
|
||||
mcpPolicySaveFailed: "Impossibile salvare la policy di accesso MCP: {error}",
|
||||
mcpPolicyLoadFailed: "Impossibile caricare la policy di accesso MCP: {error}",
|
||||
mcpDetectionTiming: "DBX esegue la verifica automaticamente all'apertura di questa pagina; usa di nuovo Verifica per aggiornare.",
|
||||
mcpNpmBoundary: "DBX verifica e spiega solo lo stato di MCP; l'installazione e gli aggiornamenti vengono comunque eseguiti tramite npm.",
|
||||
mcpRefresh: "Verifica di nuovo",
|
||||
|
|
|
|||
|
|
@ -3647,18 +3647,70 @@ export default withEnglishFallback({
|
|||
mcpInstallSuccess: "インストール成功",
|
||||
mcpInstallFailed: "インストール失敗",
|
||||
mcpConfig: "MCP設定",
|
||||
mcpConfigOptionsHint: "これらのオプションは下の生成設定だけを更新します。AIクライアントにコピーした後、MCPセッションを再起動してください。",
|
||||
mcpConfigOptionsHint: "接続範囲と実行権限はDBXがすべてのMCPクライアントに一元適用します。生成される設定にはMCP Serverの起動と現在のDBXへの接続に必要な実行パラメーターのみが含まれ、権限や接続範囲の変数は含まれません。",
|
||||
mcpScopeConnection: "MCPに許可する接続",
|
||||
mcpScopeConnectionDescription: "DBXは選択した接続だけをMCPに公開します。変更はすべてのMCPクライアントに適用されます。",
|
||||
mcpScopeMode: "接続範囲モード",
|
||||
mcpScopeModeAll: "すべての接続",
|
||||
mcpScopeModeAllDescription: "今後追加される接続も自動的に含める",
|
||||
mcpScopeModeSelected: "指定した接続",
|
||||
mcpScopeModeSelectedDescription: "明示的に追加した接続だけを許可",
|
||||
mcpScopeAllowedPane: "許可済み",
|
||||
mcpScopeAvailablePane: "追加可能",
|
||||
mcpScopeAddAll: "すべて追加",
|
||||
mcpScopeRemoveAll: "すべて削除",
|
||||
mcpScopeAddMatches: "一致項目を追加({count})",
|
||||
mcpScopeRemoveMatches: "一致項目を削除({count})",
|
||||
mcpScopeAddConnection: "MCPによる{name}へのアクセスを許可",
|
||||
mcpScopeRemoveConnection: "MCPによる{name}へのアクセスを解除",
|
||||
mcpScopeAvailableEmpty: "追加できる接続がありません",
|
||||
mcpScopeAllowedEmpty: "現在許可されている接続はありません",
|
||||
mcpScopeUnavailableGroup: "利用できない接続({count})",
|
||||
mcpScopeRemoveUnavailableConnection: "利用できない接続{id}を許可リストから削除",
|
||||
mcpScopeUpdatedAnnouncement: "MCPは現在、利用可能な{total}件中{selected}件の接続を許可しています",
|
||||
mcpConnectionSearchPlaceholder: "名前、種類、アドレス、データベース、接続IDを検索",
|
||||
mcpConnectionSearchEmpty: "一致する接続がありません",
|
||||
mcpConnectionListEmpty: "利用可能な接続がありません",
|
||||
mcpScopeAllAction: "すべて許可",
|
||||
mcpScopeNoneAction: "すべて解除",
|
||||
mcpScopeAllSummary: "全{count}接続",
|
||||
mcpScopeSelectedSummary: "{selected}/{total}件選択",
|
||||
mcpScopeAllConnections: "すべての接続",
|
||||
mcpScopeNoConnections: "接続を許可しない",
|
||||
mcpScopeConnectionUnavailable: "以前選択した接続(利用不可)",
|
||||
mcpScopeConnectionUnavailableDescription: "選択済みの{count}件の接続は存在しません。IDは許可リストに残りますが、現在対応する接続は公開されません。",
|
||||
mcpScopeSelectedCount: "{count}件の接続を選択中",
|
||||
mcpCodexConfig: "Codex設定",
|
||||
mcpCodexConfigPath: "Codexは ~/.codex/config.toml またはプロジェクトレベルの .codex/config.toml を使用できます。",
|
||||
mcpCursorConfigPath: "Cursorはプロジェクトの .cursor/mcp.json またはグローバル ~/.cursor/mcp.json を使用できます。",
|
||||
mcpTraeConfigPath: "TRAE: 設定 > MCP > 追加 > 手動で追加 からJSON設定を貼り付けます。",
|
||||
mcpCherryStudioConfigPath: "Cherry Studio: 設定 > MCPサーバー > サーバーを追加 > JSONをインポート から設定を貼り付けます。",
|
||||
mcpVsCodeConfigPath: "VS Code/Copilotはワークスペースの .vscode/mcp.json またはユーザープロファイルの mcp.json を使用できます。",
|
||||
mcpWindsurfConfigPath: "Windsurfは ~/.codeium/windsurf/mcp_config.json を使用できます。",
|
||||
mcpOpenCodeConfigPath: "グローバル ~/.config/opencode/opencode.json、プロジェクト opencode.json。",
|
||||
mcpReadonlyMode: "読み取り専用モード",
|
||||
mcpReadonlyModeDescription: "サンプル設定にDBX_MCP_ALLOW_WRITES=0を追加し、MCPセッションをクエリのみに制限します。",
|
||||
mcpAllowDangerous: "危険なSQLを許可",
|
||||
mcpAllowDangerousDescription: "サンプル設定にDBX_MCP_ALLOW_DANGEROUS_SQL=1を追加し、DROP、TRUNCATE、ALTERなどの文を許可します。",
|
||||
mcpExecutionMode: "MCP実行権限",
|
||||
mcpExecutionModeDescription: "DBXがすべてのMCPクライアントに適用する権限レベルを選択します。クライアント側の読み書き権限設定では、このレベルを変更できません。",
|
||||
mcpExecutionModeReadOnly: "読み取り専用",
|
||||
mcpExecutionModeSafeWrite: "データ読み書き",
|
||||
mcpExecutionModeRecommended: "推奨",
|
||||
mcpExecutionModeHighRiskWrite: "フルアクセス",
|
||||
mcpExecutionModeReadOnlyDescription: "DBXが読み取りと判定したリクエストのみを許可し、識別できるデータベース書き込みと接続変更を拒否します。データベースアカウントの権限が最終的な安全境界です。",
|
||||
mcpExecutionModeSafeWriteDescription: "通常のINSERT、有効な条件付きUPDATE/DELETE、範囲を検証できるMongoDB変更、通常のRedis書き込みや明示キー削除を許可し、テーブル全体や構造の変更を拒否します。",
|
||||
mcpExecutionModeHighRiskWriteDescription: "データ読み書きに加えて、テーブル全体の変更、DDL、TRUNCATE、データベース消去などの高リスク操作を許可します。本番環境と接続の読み取り専用保護は引き続き適用されます。",
|
||||
mcpExecutionModeHighRiskConfirm: "フルアクセスでは、テーブル全体の変更、DDL、TRUNCATE、データベース消去などの高リスク操作を実行できます。有効にしますか?",
|
||||
mcpCapabilityTitle: "権限と機能の比較",
|
||||
mcpCapabilityDescription: "代表的な操作を示しています。DBXが確実に分類できないリクエストにはフルアクセスが必要です。",
|
||||
mcpCapabilityOperation: "機能",
|
||||
mcpCapabilityRead: "クエリと読み取り",
|
||||
mcpCapabilityScopedMutation: "範囲を限定したデータ変更(INSERT、条件付きUPDATE/DELETE、明示キーの書き込み/削除)",
|
||||
mcpCapabilityBroadMutation: "テーブル全体の更新/削除とデータ消去",
|
||||
mcpCapabilitySchemaAdmin: "DDL、構造変更、高リスク管理コマンド",
|
||||
mcpCapabilityConnectionManagement: "MCP接続の追加/削除",
|
||||
mcpCapabilityAllowed: "許可",
|
||||
mcpCapabilityBlocked: "禁止",
|
||||
mcpCapabilityAlwaysEnforced: "すべてのモードで、接続許可リスト、接続の読み取り専用保護、本番環境保護、データベースアカウント権限が引き続き適用されます。",
|
||||
mcpPolicySaveFailed: "MCPアクセスポリシーを保存できませんでした: {error}",
|
||||
mcpPolicyLoadFailed: "MCPアクセスポリシーを読み込めませんでした: {error}",
|
||||
mcpDetectionTiming: "このページを開くとDBXが自動的に確認します。更新するには再確認を使用してください。",
|
||||
mcpNpmBoundary: "DBXはMCPステータスの確認と説明のみを行います。インストールとアップグレードはnpmを通じて実行されます。",
|
||||
mcpRefresh: "再確認",
|
||||
|
|
|
|||
|
|
@ -3665,18 +3665,71 @@ export default withEnglishFallback({
|
|||
mcpInstallSuccess: "Instalação bem-sucedida",
|
||||
mcpInstallFailed: "Falha na instalação",
|
||||
mcpConfig: "Configuração do MCP",
|
||||
mcpConfigOptionsHint: "Estas opções atualizam apenas a configuração gerada. Copie-a para o cliente de IA e reinicie a sessão MCP para aplicá-la.",
|
||||
mcpConfigOptionsHint:
|
||||
"O DBX controla centralmente o acesso às conexões e as permissões de execução para todos os clientes MCP. A configuração gerada contém apenas os parâmetros de execução necessários para iniciar o servidor MCP e conectá-lo a esta instância do DBX, sem variáveis de permissão ou escopo de conexão.",
|
||||
mcpScopeConnection: "Conexões permitidas para MCP",
|
||||
mcpScopeConnectionDescription: "O DBX expõe ao MCP apenas as conexões selecionadas. As alterações se aplicam a todos os clientes MCP.",
|
||||
mcpScopeMode: "Modo de escopo das conexões",
|
||||
mcpScopeModeAll: "Todas as conexões",
|
||||
mcpScopeModeAllDescription: "Inclui automaticamente conexões futuras",
|
||||
mcpScopeModeSelected: "Conexões específicas",
|
||||
mcpScopeModeSelectedDescription: "Somente conexões adicionadas explicitamente",
|
||||
mcpScopeAllowedPane: "Permitidas",
|
||||
mcpScopeAvailablePane: "Disponíveis",
|
||||
mcpScopeAddAll: "Adicionar todas",
|
||||
mcpScopeRemoveAll: "Remover todas",
|
||||
mcpScopeAddMatches: "Adicionar correspondências ({count})",
|
||||
mcpScopeRemoveMatches: "Remover correspondências ({count})",
|
||||
mcpScopeAddConnection: "Permitir que o MCP acesse {name}",
|
||||
mcpScopeRemoveConnection: "Remover o acesso MCP a {name}",
|
||||
mcpScopeAvailableEmpty: "Não há conexões disponíveis para adicionar",
|
||||
mcpScopeAllowedEmpty: "Nenhuma conexão está permitida no momento",
|
||||
mcpScopeUnavailableGroup: "Conexões indisponíveis ({count})",
|
||||
mcpScopeRemoveUnavailableConnection: "Remover a conexão indisponível {id} da lista permitida",
|
||||
mcpScopeUpdatedAnnouncement: "O MCP permite atualmente {selected} de {total} conexões disponíveis",
|
||||
mcpConnectionSearchPlaceholder: "Buscar nome, tipo, endereço, banco ou ID da conexão",
|
||||
mcpConnectionSearchEmpty: "Nenhuma conexão correspondente",
|
||||
mcpConnectionListEmpty: "Nenhuma conexão disponível",
|
||||
mcpScopeAllAction: "Permitir todas",
|
||||
mcpScopeNoneAction: "Limpar todas",
|
||||
mcpScopeAllSummary: "Todas as {count} conexões",
|
||||
mcpScopeSelectedSummary: "{selected}/{total} selecionadas",
|
||||
mcpScopeAllConnections: "Todas as conexões",
|
||||
mcpScopeNoConnections: "Nenhuma conexão",
|
||||
mcpScopeConnectionUnavailable: "Conexão selecionada anteriormente (indisponível)",
|
||||
mcpScopeConnectionUnavailableDescription: "{count} conexões selecionadas não existem mais. Seus IDs permanecem na lista de permissões, mas nenhuma conexão correspondente é exposta no momento.",
|
||||
mcpScopeSelectedCount: "{count} conexões selecionadas",
|
||||
mcpCodexConfig: "Configuração do Codex",
|
||||
mcpCodexConfigPath: "O Codex pode usar ~/.codex/config.toml ou um .codex/config.toml no nível do projeto.",
|
||||
mcpCursorConfigPath: "O Cursor pode usar .cursor/mcp.json no projeto ou ~/.cursor/mcp.json globalmente.",
|
||||
mcpTraeConfigPath: "TRAE: Configurações > MCP > Adicionar > Adicionar manualmente; depois cole a configuração JSON.",
|
||||
mcpCherryStudioConfigPath: "Cherry Studio: Configurações > Servidores MCP > Adicionar servidor > Importar JSON; depois cole a configuração.",
|
||||
mcpVsCodeConfigPath: "O VS Code/Copilot pode usar .vscode/mcp.json no workspace ou mcp.json no perfil do usuário.",
|
||||
mcpWindsurfConfigPath: "O Windsurf pode usar ~/.codeium/windsurf/mcp_config.json.",
|
||||
mcpOpenCodeConfigPath: "~/.config/opencode/opencode.json globalmente, ou opencode.json no nível do projeto.",
|
||||
mcpReadonlyMode: "Modo somente leitura",
|
||||
mcpReadonlyModeDescription: "Adiciona DBX_MCP_ALLOW_WRITES=0 à configuração de exemplo para que a sessão MCP permaneça apenas para consultas.",
|
||||
mcpAllowDangerous: "Permitir SQL perigoso",
|
||||
mcpAllowDangerousDescription: "Adiciona DBX_MCP_ALLOW_DANGEROUS_SQL=1 à configuração de exemplo para que instruções como DROP, TRUNCATE, ALTER e similares sejam permitidas.",
|
||||
mcpExecutionMode: "Permissão de execução MCP",
|
||||
mcpExecutionModeDescription: "Escolha o nível de permissão que o DBX aplica a todos os clientes MCP. As configurações de leitura e gravação do cliente não podem alterar este nível.",
|
||||
mcpExecutionModeReadOnly: "Somente leitura",
|
||||
mcpExecutionModeSafeWrite: "Leitura e gravação de dados",
|
||||
mcpExecutionModeRecommended: "Recomendado",
|
||||
mcpExecutionModeHighRiskWrite: "Acesso completo",
|
||||
mcpExecutionModeReadOnlyDescription: "Permite apenas solicitações classificadas pelo DBX como leitura; bloqueia gravações reconhecidas e alterações de conexão. As permissões da conta do banco continuam sendo o limite de segurança final.",
|
||||
mcpExecutionModeSafeWriteDescription: "Permite INSERT normal, UPDATE/DELETE com filtros efetivos, alterações MongoDB de escopo verificável e gravações Redis comuns ou exclusão de chaves explícitas; bloqueia alterações globais e estruturais.",
|
||||
mcpExecutionModeHighRiskWriteDescription: "Além da leitura e gravação de dados, permite alterações na tabela inteira, DDL, TRUNCATE, limpeza do banco e outras operações de alto risco. As proteções de produção e somente leitura continuam ativas.",
|
||||
mcpExecutionModeHighRiskConfirm: "O acesso completo permite alterações na tabela inteira, DDL, TRUNCATE, limpeza do banco e outras operações de alto risco. Deseja ativá-lo?",
|
||||
mcpCapabilityTitle: "Comparação de recursos",
|
||||
mcpCapabilityDescription: "Estas são operações representativas; solicitações que o DBX não consegue classificar com segurança exigem acesso completo.",
|
||||
mcpCapabilityOperation: "Recurso",
|
||||
mcpCapabilityRead: "Consultas e leituras",
|
||||
mcpCapabilityScopedMutation: "Alterações de dados com escopo (INSERT, UPDATE/DELETE filtrados, gravação/exclusão de chaves explícitas)",
|
||||
mcpCapabilityBroadMutation: "Atualizações/exclusões de tabelas inteiras e limpeza de dados",
|
||||
mcpCapabilitySchemaAdmin: "DDL, alterações estruturais e administração de alto risco",
|
||||
mcpCapabilityConnectionManagement: "Adicionar/remover conexões MCP",
|
||||
mcpCapabilityAllowed: "Permitido",
|
||||
mcpCapabilityBlocked: "Bloqueado",
|
||||
mcpCapabilityAlwaysEnforced: "Todos os modos continuam sujeitos à lista de conexões permitidas, à proteção somente leitura, à proteção de produção e às permissões da conta do banco de dados.",
|
||||
mcpPolicySaveFailed: "Falha ao salvar a política de acesso MCP: {error}",
|
||||
mcpPolicyLoadFailed: "Falha ao carregar a política de acesso MCP: {error}",
|
||||
mcpDetectionTiming: "O DBX verifica automaticamente quando esta página é aberta; use Verificar novamente para atualizar.",
|
||||
mcpNpmBoundary: "O DBX apenas verifica e explica o status do MCP; a instalação e as atualizações ainda são feitas pelo npm.",
|
||||
mcpRefresh: "Verificar novamente",
|
||||
|
|
|
|||
|
|
@ -3904,18 +3904,70 @@ export default withEnglishFallback({
|
|||
mcpInstallSuccess: "安装成功",
|
||||
mcpInstallFailed: "安装失败",
|
||||
mcpConfig: "MCP 配置",
|
||||
mcpConfigOptionsHint: "以下选项只会更新下方生成的配置文本。复制到 AI 客户端配置后,请重启对应 MCP 会话使其生效。",
|
||||
mcpConfigOptionsHint: "连接范围和执行权限均由 DBX 统一控制并对所有 MCP 客户端立即生效;生成配置只包含启动 MCP Server 和连接当前 DBX 所需的运行参数,不包含权限或连接范围变量。",
|
||||
mcpScopeConnection: "允许 MCP 访问的连接",
|
||||
mcpScopeConnectionDescription: "DBX 只向 MCP 暴露选中的连接,修改后对所有 MCP 客户端生效。",
|
||||
mcpScopeMode: "连接范围模式",
|
||||
mcpScopeModeAll: "所有连接",
|
||||
mcpScopeModeAllDescription: "自动包含以后新增的连接",
|
||||
mcpScopeModeSelected: "指定连接",
|
||||
mcpScopeModeSelectedDescription: "只允许显式加入的连接",
|
||||
mcpScopeAllowedPane: "已允许",
|
||||
mcpScopeAvailablePane: "可添加",
|
||||
mcpScopeAddAll: "全部添加",
|
||||
mcpScopeRemoveAll: "全部移除",
|
||||
mcpScopeAddMatches: "添加匹配项({count})",
|
||||
mcpScopeRemoveMatches: "移除匹配项({count})",
|
||||
mcpScopeAddConnection: "允许 MCP 访问 {name}",
|
||||
mcpScopeRemoveConnection: "取消 MCP 对 {name} 的访问",
|
||||
mcpScopeAvailableEmpty: "没有可添加的连接",
|
||||
mcpScopeAllowedEmpty: "当前不允许任何连接",
|
||||
mcpScopeUnavailableGroup: "不可用连接({count})",
|
||||
mcpScopeRemoveUnavailableConnection: "从允许列表移除不可用连接 {id}",
|
||||
mcpScopeUpdatedAnnouncement: "MCP 当前允许 {selected}/{total} 个可用连接",
|
||||
mcpConnectionSearchPlaceholder: "搜索名称、类型、地址、数据库或连接 ID",
|
||||
mcpConnectionSearchEmpty: "没有匹配的连接",
|
||||
mcpConnectionListEmpty: "当前没有可用连接",
|
||||
mcpScopeAllAction: "允许全部",
|
||||
mcpScopeNoneAction: "全部取消",
|
||||
mcpScopeAllSummary: "全部 {count} 个连接",
|
||||
mcpScopeSelectedSummary: "已选 {selected}/{total}",
|
||||
mcpScopeAllConnections: "所有连接",
|
||||
mcpScopeNoConnections: "不允许任何连接",
|
||||
mcpScopeConnectionUnavailable: "之前选择的连接(不可用)",
|
||||
mcpScopeConnectionUnavailableDescription: "有 {count} 个已选连接已不存在;其 ID 仍保留在允许列表中,但当前不会暴露任何对应连接。",
|
||||
mcpScopeSelectedCount: "已选择 {count} 个连接",
|
||||
mcpCodexConfig: "Codex 配置",
|
||||
mcpCodexConfigPath: "Codex 可放在 ~/.codex/config.toml 或项目级 .codex/config.toml。",
|
||||
mcpCursorConfigPath: "Cursor 可放在项目级 .cursor/mcp.json 或全局 ~/.cursor/mcp.json。",
|
||||
mcpTraeConfigPath: "TRAE:设置 > MCP > 添加 > 手动添加,然后粘贴 JSON 配置。",
|
||||
mcpCherryStudioConfigPath: "Cherry Studio:设置 > MCP 服务器 > 添加服务器 > 导入 JSON,然后粘贴配置。",
|
||||
mcpVsCodeConfigPath: "VS Code/Copilot 可放在工作区 .vscode/mcp.json 或用户配置文件 mcp.json。",
|
||||
mcpWindsurfConfigPath: "Windsurf 可放在 ~/.codeium/windsurf/mcp_config.json。",
|
||||
mcpOpenCodeConfigPath: "全局 ~/.config/opencode/opencode.json,项目级 opencode.json。",
|
||||
mcpReadonlyMode: "只读模式",
|
||||
mcpReadonlyModeDescription: "开启后会为示例配置附加 DBX_MCP_ALLOW_WRITES=0,MCP 会话只允许查询。",
|
||||
mcpAllowDangerous: "允许危险 SQL",
|
||||
mcpAllowDangerousDescription: "开启后会为示例配置附加 DBX_MCP_ALLOW_DANGEROUS_SQL=1,允许 DROP、TRUNCATE、ALTER 等语句。",
|
||||
mcpExecutionMode: "MCP 执行权限",
|
||||
mcpExecutionModeDescription: "选择 DBX 对所有 MCP 客户端强制执行的权限级别。客户端的读写权限配置无法更改此级别。",
|
||||
mcpExecutionModeReadOnly: "只读",
|
||||
mcpExecutionModeSafeWrite: "数据读写",
|
||||
mcpExecutionModeRecommended: "推荐",
|
||||
mcpExecutionModeHighRiskWrite: "完全访问",
|
||||
mcpExecutionModeReadOnlyDescription: "仅允许 DBX 判定为读取的请求;禁止已识别的数据库写入和连接管理变更。数据库账号权限仍是最终硬边界。",
|
||||
mcpExecutionModeSafeWriteDescription: "允许普通 INSERT、带有效条件的 UPDATE/DELETE、MongoDB 范围可验证的变更,以及 Redis 普通写入和明确键删除;阻止全表修改和结构变更。",
|
||||
mcpExecutionModeHighRiskWriteDescription: "在数据读写基础上允许全表修改、DDL、TRUNCATE、清库及其他高风险操作;生产库和连接只读保护仍然生效。",
|
||||
mcpExecutionModeHighRiskConfirm: "完全访问允许全表修改、DDL、TRUNCATE、清库及其他高风险操作。确定要启用吗?",
|
||||
mcpCapabilityTitle: "权限能力对照",
|
||||
mcpCapabilityDescription: "以下为典型操作;无法可靠分类的请求需要完全访问。",
|
||||
mcpCapabilityOperation: "能力",
|
||||
mcpCapabilityRead: "查询与读取",
|
||||
mcpCapabilityScopedMutation: "范围可控的数据变更(INSERT、有效条件 UPDATE/DELETE、明确键写入/删除)",
|
||||
mcpCapabilityBroadMutation: "全表更新/删除与清空数据",
|
||||
mcpCapabilitySchemaAdmin: "DDL、结构变更与高风险管理命令",
|
||||
mcpCapabilityConnectionManagement: "MCP 连接添加/删除",
|
||||
mcpCapabilityAllowed: "允许",
|
||||
mcpCapabilityBlocked: "禁止",
|
||||
mcpCapabilityAlwaysEnforced: "所有模式仍受连接允许范围、连接只读、生产库保护和数据库账号权限约束。",
|
||||
mcpPolicySaveFailed: "保存 MCP 访问策略失败:{error}",
|
||||
mcpPolicyLoadFailed: "加载 MCP 访问策略失败:{error}",
|
||||
mcpDetectionTiming: "打开此页时会自动检测,点击重新检查可刷新。",
|
||||
mcpNpmBoundary: "DBX 只检测和提示 MCP 状态;安装与升级仍由 npm 完成。",
|
||||
mcpRefresh: "重新检查",
|
||||
|
|
|
|||
|
|
@ -3505,18 +3505,70 @@ export default withEnglishFallback({
|
|||
mcpInstallSuccess: "安裝成功",
|
||||
mcpInstallFailed: "安裝失敗",
|
||||
mcpConfig: "MCP 設定",
|
||||
mcpConfigOptionsHint: "以下選項只會更新下方產生的設定文字。複製到 AI 用戶端設定後,請重新啟動對應的 MCP 工作階段。",
|
||||
mcpConfigOptionsHint: "連線範圍與執行權限均由 DBX 統一控制,並對所有 MCP 用戶端立即生效;產生的設定只包含啟動 MCP Server 與連線目前 DBX 所需的執行參數,不包含權限或連線範圍變數。",
|
||||
mcpScopeConnection: "允許 MCP 存取的連線",
|
||||
mcpScopeConnectionDescription: "DBX 只向 MCP 公開選取的連線,變更後對所有 MCP 用戶端生效。",
|
||||
mcpScopeMode: "連線範圍模式",
|
||||
mcpScopeModeAll: "所有連線",
|
||||
mcpScopeModeAllDescription: "自動包含之後新增的連線",
|
||||
mcpScopeModeSelected: "指定連線",
|
||||
mcpScopeModeSelectedDescription: "只允許明確加入的連線",
|
||||
mcpScopeAllowedPane: "已允許",
|
||||
mcpScopeAvailablePane: "可加入",
|
||||
mcpScopeAddAll: "全部加入",
|
||||
mcpScopeRemoveAll: "全部移除",
|
||||
mcpScopeAddMatches: "加入符合項目({count})",
|
||||
mcpScopeRemoveMatches: "移除符合項目({count})",
|
||||
mcpScopeAddConnection: "允許 MCP 存取 {name}",
|
||||
mcpScopeRemoveConnection: "取消 MCP 對 {name} 的存取",
|
||||
mcpScopeAvailableEmpty: "沒有可加入的連線",
|
||||
mcpScopeAllowedEmpty: "目前不允許任何連線",
|
||||
mcpScopeUnavailableGroup: "無法使用的連線({count})",
|
||||
mcpScopeRemoveUnavailableConnection: "從允許清單移除無法使用的連線 {id}",
|
||||
mcpScopeUpdatedAnnouncement: "MCP 目前允許 {selected}/{total} 個可用連線",
|
||||
mcpConnectionSearchPlaceholder: "搜尋名稱、類型、位址、資料庫或連線 ID",
|
||||
mcpConnectionSearchEmpty: "沒有符合的連線",
|
||||
mcpConnectionListEmpty: "目前沒有可用連線",
|
||||
mcpScopeAllAction: "允許全部",
|
||||
mcpScopeNoneAction: "全部取消",
|
||||
mcpScopeAllSummary: "全部 {count} 個連線",
|
||||
mcpScopeSelectedSummary: "已選 {selected}/{total}",
|
||||
mcpScopeAllConnections: "所有連線",
|
||||
mcpScopeNoConnections: "不允許任何連線",
|
||||
mcpScopeConnectionUnavailable: "先前選取的連線(無法使用)",
|
||||
mcpScopeConnectionUnavailableDescription: "有 {count} 個已選連線已不存在;其 ID 仍保留在允許清單中,但目前不會公開任何對應連線。",
|
||||
mcpScopeSelectedCount: "已選取 {count} 個連線",
|
||||
mcpCodexConfig: "Codex 設定",
|
||||
mcpCodexConfigPath: "Codex 可放在 ~/.codex/config.toml 或專案級 .codex/config.toml。",
|
||||
mcpCursorConfigPath: "Cursor 可放在專案級 .cursor/mcp.json 或全域 ~/.cursor/mcp.json。",
|
||||
mcpTraeConfigPath: "TRAE:設定 > MCP > 新增 > 手動新增,然後貼上 JSON 設定。",
|
||||
mcpCherryStudioConfigPath: "Cherry Studio:設定 > MCP 伺服器 > 新增伺服器 > 匯入 JSON,然後貼上設定。",
|
||||
mcpVsCodeConfigPath: "VS Code/Copilot 可放在工作區 .vscode/mcp.json 或使用者設定檔 mcp.json。",
|
||||
mcpWindsurfConfigPath: "Windsurf 可放在 ~/.codeium/windsurf/mcp_config.json。",
|
||||
mcpOpenCodeConfigPath: "全域 ~/.config/opencode/opencode.json,專案級 opencode.json。",
|
||||
mcpReadonlyMode: "唯讀模式",
|
||||
mcpReadonlyModeDescription: "開啟後會為示例配置附加 DBX_MCP_ALLOW_WRITES=0,MCP 會話只允許查詢。",
|
||||
mcpAllowDangerous: "允許危險 SQL",
|
||||
mcpAllowDangerousDescription: "開啟後會為示例配置附加 DBX_MCP_ALLOW_DANGEROUS_SQL=1,允許 DROP、TRUNCATE、ALTER 等語句。",
|
||||
mcpExecutionMode: "MCP 執行權限",
|
||||
mcpExecutionModeDescription: "選擇 DBX 對所有 MCP 用戶端強制執行的權限等級。用戶端的讀寫權限設定無法變更此等級。",
|
||||
mcpExecutionModeReadOnly: "唯讀",
|
||||
mcpExecutionModeSafeWrite: "資料讀寫",
|
||||
mcpExecutionModeRecommended: "推薦",
|
||||
mcpExecutionModeHighRiskWrite: "完整存取",
|
||||
mcpExecutionModeReadOnlyDescription: "僅允許 DBX 判定為讀取的請求;禁止已識別的資料庫寫入與連線管理變更。資料庫帳號權限仍是最終硬邊界。",
|
||||
mcpExecutionModeSafeWriteDescription: "允許一般 INSERT、帶有效條件的 UPDATE/DELETE、MongoDB 可驗證範圍的變更,以及 Redis 一般寫入和明確鍵刪除;阻止全表修改與結構變更。",
|
||||
mcpExecutionModeHighRiskWriteDescription: "在資料讀寫基礎上允許全表修改、DDL、TRUNCATE、清空資料庫及其他高風險操作;生產庫與連線唯讀保護仍然生效。",
|
||||
mcpExecutionModeHighRiskConfirm: "完整存取允許全表修改、DDL、TRUNCATE、清空資料庫及其他高風險操作。確定要啟用嗎?",
|
||||
mcpCapabilityTitle: "權限能力對照",
|
||||
mcpCapabilityDescription: "以下為典型操作;無法可靠分類的請求需要完整存取。",
|
||||
mcpCapabilityOperation: "能力",
|
||||
mcpCapabilityRead: "查詢與讀取",
|
||||
mcpCapabilityScopedMutation: "範圍可控的資料變更(INSERT、有效條件 UPDATE/DELETE、明確鍵寫入/刪除)",
|
||||
mcpCapabilityBroadMutation: "全表更新/刪除與清空資料",
|
||||
mcpCapabilitySchemaAdmin: "DDL、結構變更與高風險管理命令",
|
||||
mcpCapabilityConnectionManagement: "MCP 連線新增/刪除",
|
||||
mcpCapabilityAllowed: "允許",
|
||||
mcpCapabilityBlocked: "禁止",
|
||||
mcpCapabilityAlwaysEnforced: "所有模式仍受連線允許範圍、連線唯讀、生產庫保護和資料庫帳號權限約束。",
|
||||
mcpPolicySaveFailed: "儲存 MCP 存取策略失敗:{error}",
|
||||
mcpPolicyLoadFailed: "載入 MCP 存取策略失敗:{error}",
|
||||
mcpDetectionTiming: "打開此頁時會自動檢測,點擊重新檢查可刷新。",
|
||||
mcpNpmBoundary: "DBX 只檢測和提示 MCP 狀態;安裝與升級仍由 npm 完成。",
|
||||
mcpRefresh: "重新檢查",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { buildMcpCodexConfig, buildMcpJsonConfig, buildMcpOpenCodeConfig, buildMcpVsCodeConfig, type McpEnvEntry } from "@/lib/mcp/mcpConfigTemplates";
|
||||
import { buildMcpCherryStudioConfig, buildMcpCodexConfig, buildMcpJsonConfig, buildMcpOpenCodeConfig, buildMcpVsCodeConfig, mcpWebBackendUrl } from "@/lib/mcp/mcpConfigTemplates";
|
||||
|
||||
describe("MCP config templates", () => {
|
||||
it("builds the standard mcpServers JSON used by Claude, Cursor, TRAE, and Windsurf", () => {
|
||||
|
|
@ -14,21 +14,8 @@ describe("MCP config templates", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("adds DBX MCP env entries to standard JSON configs", () => {
|
||||
const env: McpEnvEntry[] = [
|
||||
["DBX_MCP_ALLOW_WRITES", "0"],
|
||||
["DBX_MCP_ALLOW_DANGEROUS_SQL", "1"],
|
||||
];
|
||||
const config = JSON.parse(buildMcpJsonConfig(env));
|
||||
|
||||
expect(config.mcpServers.dbx.env).toEqual({
|
||||
DBX_MCP_ALLOW_WRITES: "0",
|
||||
DBX_MCP_ALLOW_DANGEROUS_SQL: "1",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds standard JSON configs with a direct node launch command", () => {
|
||||
const config = JSON.parse(buildMcpJsonConfig([], { command: "C:\\Program Files\\nodejs\\node.exe", args: ["C:\\Users\\zhiyo\\AppData\\Roaming\\npm\\node_modules\\@dbx-app\\mcp-server\\dist\\index.js"] }));
|
||||
const config = JSON.parse(buildMcpJsonConfig({ command: "C:\\Program Files\\nodejs\\node.exe", args: ["C:\\Users\\zhiyo\\AppData\\Roaming\\npm\\node_modules\\@dbx-app\\mcp-server\\dist\\index.js"] }));
|
||||
|
||||
expect(config).toEqual({
|
||||
mcpServers: {
|
||||
|
|
@ -40,24 +27,42 @@ describe("MCP config templates", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("builds VS Code MCP config with the servers root", () => {
|
||||
const config = JSON.parse(buildMcpVsCodeConfig([["DBX_MCP_ALLOW_WRITES", "0"]]));
|
||||
it("includes Web runtime settings without restoring permission environment variables", () => {
|
||||
const launch = {
|
||||
command: "dbx-mcp-server",
|
||||
env: {
|
||||
DBX_WEB_URL: "https://dbx.example.com/tools/dbx",
|
||||
DBX_WEB_PASSWORD: "your-web-login-password",
|
||||
},
|
||||
};
|
||||
|
||||
expect(JSON.parse(buildMcpJsonConfig(launch))).toEqual({
|
||||
mcpServers: { dbx: { command: "dbx-mcp-server", env: launch.env } },
|
||||
});
|
||||
expect(buildMcpCodexConfig(launch)).toContain('[mcp_servers.dbx.env]\nDBX_WEB_URL = "https://dbx.example.com/tools/dbx"');
|
||||
expect(JSON.parse(buildMcpOpenCodeConfig(launch)).mcp.dbx.environment).toEqual(launch.env);
|
||||
expect(buildMcpJsonConfig(launch)).not.toContain("DBX_MCP_ALLOW_WRITES");
|
||||
});
|
||||
|
||||
it("keeps a deployed Web base path in DBX_WEB_URL", () => {
|
||||
expect(mcpWebBackendUrl("https://dbx.example.com", "/tools/dbx/api")).toBe("https://dbx.example.com/tools/dbx");
|
||||
});
|
||||
|
||||
it("builds VS Code MCP config with the servers root and no policy environment", () => {
|
||||
const config = JSON.parse(buildMcpVsCodeConfig());
|
||||
|
||||
expect(config).toEqual({
|
||||
servers: {
|
||||
dbx: {
|
||||
type: "stdio",
|
||||
command: "dbx-mcp-server",
|
||||
env: {
|
||||
DBX_MCP_ALLOW_WRITES: "0",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("builds VS Code config with a direct node launch command", () => {
|
||||
const config = JSON.parse(buildMcpVsCodeConfig([["DBX_MCP_ALLOW_WRITES", "0"]], { command: "node", args: ["C:\\dbx\\mcp\\dist\\index.js"] }));
|
||||
const config = JSON.parse(buildMcpVsCodeConfig({ command: "node", args: ["C:\\dbx\\mcp\\dist\\index.js"] }));
|
||||
|
||||
expect(config).toEqual({
|
||||
servers: {
|
||||
|
|
@ -65,40 +70,59 @@ describe("MCP config templates", () => {
|
|||
type: "stdio",
|
||||
command: "node",
|
||||
args: ["C:\\dbx\\mcp\\dist\\index.js"],
|
||||
env: {
|
||||
DBX_MCP_ALLOW_WRITES: "0",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("builds Codex TOML config with env entries", () => {
|
||||
expect(buildMcpCodexConfig([["DBX_MCP_ALLOW_WRITES", "0"]])).toBe(["[mcp_servers.dbx]", 'command = "dbx-mcp-server"', "", "[mcp_servers.dbx.env]", 'DBX_MCP_ALLOW_WRITES = "0"'].join("\n"));
|
||||
it("builds the Cherry Studio stdio configuration", () => {
|
||||
const config = JSON.parse(
|
||||
buildMcpCherryStudioConfig({
|
||||
command: "/opt/homebrew/bin/node",
|
||||
args: ["/opt/dbx/mcp-server/dist/index.js"],
|
||||
env: { DBX_WEB_URL: "https://dbx.example.com" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(config).toEqual({
|
||||
mcpServers: {
|
||||
dbx: {
|
||||
name: "dbx",
|
||||
description: "",
|
||||
baseUrl: "",
|
||||
command: "/opt/homebrew/bin/node",
|
||||
args: ["/opt/dbx/mcp-server/dist/index.js"],
|
||||
env: { DBX_WEB_URL: "https://dbx.example.com" },
|
||||
isActive: true,
|
||||
type: "stdio",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("builds Codex TOML config without policy environment", () => {
|
||||
expect(buildMcpCodexConfig()).toBe(["[mcp_servers.dbx]", 'command = "dbx-mcp-server"'].join("\n"));
|
||||
});
|
||||
|
||||
it("builds Codex TOML config with a direct node launch command", () => {
|
||||
expect(buildMcpCodexConfig([["DBX_MCP_ALLOW_WRITES", "0"]], { command: "node", args: ["C:\\dbx\\mcp\\dist\\index.js"] })).toBe(["[mcp_servers.dbx]", 'command = "node"', 'args = ["C:\\\\dbx\\\\mcp\\\\dist\\\\index.js"]', "", "[mcp_servers.dbx.env]", 'DBX_MCP_ALLOW_WRITES = "0"'].join("\n"));
|
||||
expect(buildMcpCodexConfig({ command: "node", args: ["C:\\dbx\\mcp\\dist\\index.js"] })).toBe(["[mcp_servers.dbx]", 'command = "node"', 'args = ["C:\\\\dbx\\\\mcp\\\\dist\\\\index.js"]'].join("\n"));
|
||||
});
|
||||
|
||||
it("builds OpenCode config using environment entries", () => {
|
||||
const config = JSON.parse(buildMcpOpenCodeConfig([["DBX_MCP_ALLOW_DANGEROUS_SQL", "1"]]));
|
||||
it("builds OpenCode config without policy environment", () => {
|
||||
const config = JSON.parse(buildMcpOpenCodeConfig());
|
||||
|
||||
expect(config).toEqual({
|
||||
mcp: {
|
||||
dbx: {
|
||||
type: "local",
|
||||
command: ["dbx-mcp-server"],
|
||||
environment: {
|
||||
DBX_MCP_ALLOW_DANGEROUS_SQL: "1",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("builds OpenCode config with a direct node launch command", () => {
|
||||
const config = JSON.parse(buildMcpOpenCodeConfig([], { command: "node", args: ["C:\\dbx\\mcp\\dist\\index.js"] }));
|
||||
const config = JSON.parse(buildMcpOpenCodeConfig({ command: "node", args: ["C:\\dbx\\mcp\\dist\\index.js"] }));
|
||||
|
||||
expect(config).toEqual({
|
||||
mcp: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { groupMcpScopeConnections, isMcpPolicyMutationBlocked, matchesMcpSearchQuery, MCP_CAPABILITY_ROWS, MCP_EXECUTION_MODE_COLUMNS, mcpExecutionModeFromPolicy, mcpPolicyFieldsForExecutionMode, toggleMcpAllowedConnectionId, updateMcpAllowedConnectionIds } from "@/lib/mcp/mcpPolicySelection";
|
||||
|
||||
const settingsDialogSource = readFileSync(new URL("../../../components/editor/EditorSettingsDialog.vue", import.meta.url), "utf8");
|
||||
const scopePickerSource = readFileSync(new URL("../../../components/settings/McpConnectionScopePicker.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("MCP execution permission selection", () => {
|
||||
it("maps the persisted policy to the three UI modes", () => {
|
||||
expect(mcpExecutionModeFromPolicy({ readOnly: true, allowDangerousSql: false })).toBe("read_only");
|
||||
expect(mcpExecutionModeFromPolicy({ readOnly: false, allowDangerousSql: false })).toBe("safe_write");
|
||||
expect(mcpExecutionModeFromPolicy({ readOnly: false, allowDangerousSql: true })).toBe("high_risk_write");
|
||||
});
|
||||
|
||||
it("treats read-only as authoritative when legacy state also allows dangerous SQL", () => {
|
||||
expect(mcpExecutionModeFromPolicy({ readOnly: true, allowDangerousSql: true })).toBe("read_only");
|
||||
});
|
||||
|
||||
it("maps every UI mode to a complete atomic policy update", () => {
|
||||
expect(mcpPolicyFieldsForExecutionMode("read_only")).toEqual({ readOnly: true, allowDangerousSql: false });
|
||||
expect(mcpPolicyFieldsForExecutionMode("safe_write")).toEqual({ readOnly: false, allowDangerousSql: false });
|
||||
expect(mcpPolicyFieldsForExecutionMode("high_risk_write")).toEqual({ readOnly: false, allowDangerousSql: true });
|
||||
});
|
||||
|
||||
it("presents the stable internal modes as three user-facing columns", () => {
|
||||
expect(MCP_EXECUTION_MODE_COLUMNS.map((column) => column.mode)).toEqual(["read_only", "safe_write", "high_risk_write"]);
|
||||
});
|
||||
|
||||
it("shows the risk-based capability boundary without changing enforcement semantics", () => {
|
||||
expect(MCP_CAPABILITY_ROWS).toEqual([
|
||||
{ labelKey: "settings.mcpCapabilityRead", read_only: true, safe_write: true, high_risk_write: true },
|
||||
{ labelKey: "settings.mcpCapabilityScopedMutation", read_only: false, safe_write: true, high_risk_write: true },
|
||||
{ labelKey: "settings.mcpCapabilityBroadMutation", read_only: false, safe_write: false, high_risk_write: true },
|
||||
{ labelKey: "settings.mcpCapabilitySchemaAdmin", read_only: false, safe_write: false, high_risk_write: true },
|
||||
{ labelKey: "settings.mcpCapabilityConnectionManagement", read_only: false, safe_write: true, high_risk_write: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCP policy connection selection", () => {
|
||||
it("turns allow-all into an explicit list when one connection is removed", () => {
|
||||
expect(toggleMcpAllowedConnectionId(null, ["one", "two", "three"], "two", false)).toEqual(["one", "three"]);
|
||||
});
|
||||
|
||||
it("updates an existing explicit allowlist", () => {
|
||||
expect(toggleMcpAllowedConnectionId(["one"], ["one", "two"], "two", true)).toEqual(["one", "two"]);
|
||||
expect(toggleMcpAllowedConnectionId(["one", "two"], ["one", "two"], "one", false)).toEqual(["two"]);
|
||||
});
|
||||
|
||||
it("groups live and unavailable connections without losing source order", () => {
|
||||
const one = { id: "one", name: "One" };
|
||||
const two = { id: "two", name: "Two" };
|
||||
const three = { id: "three", name: "Three" };
|
||||
expect(groupMcpScopeConnections([one, two, three], ["three", "missing", "one"])).toEqual({
|
||||
allowed: [one, three],
|
||||
available: [two],
|
||||
unavailableAllowedIds: ["missing"],
|
||||
});
|
||||
expect(groupMcpScopeConnections([one, two], null)).toEqual({
|
||||
allowed: [one, two],
|
||||
available: [],
|
||||
unavailableAllowedIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("applies batch additions and removals while preserving unavailable IDs", () => {
|
||||
expect(updateMcpAllowedConnectionIds(["one", "missing"], ["one", "two", "three"], ["two", "three"], true)).toEqual(["one", "missing", "two", "three"]);
|
||||
expect(updateMcpAllowedConnectionIds(null, ["one", "two", "three"], ["one", "three"], false)).toEqual(["two"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCP policy settings state", () => {
|
||||
it("blocks mutations while loading, saving, or displaying a load error", () => {
|
||||
expect(isMcpPolicyMutationBlocked({ loading: true, saving: false, loadError: "" })).toBe(true);
|
||||
expect(isMcpPolicyMutationBlocked({ loading: false, saving: true, loadError: "" })).toBe(true);
|
||||
expect(isMcpPolicyMutationBlocked({ loading: false, saving: false, loadError: "unavailable" })).toBe(true);
|
||||
expect(isMcpPolicyMutationBlocked({ loading: false, saving: false, loadError: "" })).toBe(false);
|
||||
});
|
||||
|
||||
it("guards the mutation entry point and wires the shared disabled state to policy controls", () => {
|
||||
expect(settingsDialogSource).toContain("if (mcpPolicyControlsDisabled.value) return;");
|
||||
expect(settingsDialogSource).toContain(':disabled="mcpPolicyControlsDisabled"');
|
||||
expect(settingsDialogSource).toContain('@update:allowed-connection-ids="onMcpAllowedConnectionIdsChange"');
|
||||
expect(settingsDialogSource).toContain('<fieldset :disabled="mcpPolicyControlsDisabled"');
|
||||
|
||||
const loadingStart = settingsDialogSource.indexOf("mcpPolicyLoading.value = true;");
|
||||
const policyLoad = settingsDialogSource.indexOf("await settingsStore.initMcpGlobalPolicy(true);");
|
||||
const loadingEnd = settingsDialogSource.indexOf("mcpPolicyLoading.value = false;", policyLoad);
|
||||
expect(loadingStart).toBeGreaterThan(-1);
|
||||
expect(loadingStart).toBeLessThan(policyLoad);
|
||||
expect(loadingEnd).toBeGreaterThan(policyLoad);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCP connection search", () => {
|
||||
it("matches case-insensitively across text, numeric fields, and connection IDs", () => {
|
||||
const values = ["MySQL Local", "mysql", "127.0.0.1", 3306, "app_db", "connection-ABC"];
|
||||
expect(matchesMcpSearchQuery(" mysql ", values)).toBe(true);
|
||||
expect(matchesMcpSearchQuery("3306", values)).toBe(true);
|
||||
expect(matchesMcpSearchQuery("connection-abc", values)).toBe(true);
|
||||
expect(matchesMcpSearchQuery("postgres", values)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats an empty query as a match and can search unavailable IDs", () => {
|
||||
expect(matchesMcpSearchQuery(" ", [null, undefined])).toBe(true);
|
||||
expect(matchesMcpSearchQuery("missing-id", ["missing-id-123", "Previously selected connection (unavailable)"])).toBe(true);
|
||||
});
|
||||
|
||||
it("uses responsive allowed and available panes with an allowed-first compact view", () => {
|
||||
expect(scopePickerSource).toContain('const compactPane = ref<ScopePane>("allowed")');
|
||||
expect(scopePickerSource).toContain('data-scope-pane="available"');
|
||||
expect(scopePickerSource).toContain('data-scope-pane="allowed"');
|
||||
expect(scopePickerSource).toContain("@container mcp-scope (min-width: 42rem)");
|
||||
expect(scopePickerSource).toContain("filteredUnavailableAllowedIds");
|
||||
});
|
||||
});
|
||||
|
|
@ -251,6 +251,8 @@ export const saveAiProviderConfig = forward("saveAiProviderConfig");
|
|||
export const loadAiProviderConfigs = forward("loadAiProviderConfigs");
|
||||
export const loadDesktopSettings = forward("loadDesktopSettings");
|
||||
export const saveDesktopSettings = forward("saveDesktopSettings");
|
||||
export const loadMcpGlobalPolicy = forward("loadMcpGlobalPolicy");
|
||||
export const saveMcpGlobalPolicy = forward("saveMcpGlobalPolicy");
|
||||
export const completeAppClose = forward("completeAppClose");
|
||||
export const requestAppClose = forward("requestAppClose");
|
||||
export const setDriverStoreDir = forward("setDriverStoreDir");
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ import type {
|
|||
UpgradeAllAgentDriversResult,
|
||||
AgentUpdateBlocker,
|
||||
DesktopSettings,
|
||||
McpGlobalPolicy,
|
||||
SavedSqlSyncRequest,
|
||||
DriverInstallProgress,
|
||||
JavaRuntimeConfig,
|
||||
|
|
@ -1167,6 +1168,19 @@ export async function saveDesktopSettings(settings: DesktopSettings): Promise<vo
|
|||
safeLocalStorageSet(DESKTOP_SETTINGS_STORAGE_KEY, JSON.stringify({ ...DEFAULT_DESKTOP_SETTINGS, ...settings }));
|
||||
}
|
||||
|
||||
export async function loadMcpGlobalPolicy(): Promise<McpGlobalPolicy> {
|
||||
return get("/api/app-settings/mcp-policy");
|
||||
}
|
||||
|
||||
export async function saveMcpGlobalPolicy(policy: Omit<McpGlobalPolicy, "configured">): Promise<void> {
|
||||
const res = await fetch(apiUrl("/api/app-settings/mcp-policy"), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(policy),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
}
|
||||
|
||||
export interface OpenTabsStatePayload {
|
||||
tabs: unknown[];
|
||||
activeTabId: string | null;
|
||||
|
|
|
|||
|
|
@ -166,6 +166,13 @@ export interface DesktopSettings {
|
|||
sidebar_table_page_size?: number | null;
|
||||
}
|
||||
|
||||
export interface McpGlobalPolicy {
|
||||
readOnly: boolean;
|
||||
allowDangerousSql: boolean;
|
||||
allowedConnectionIds: string[] | null;
|
||||
configured: boolean;
|
||||
}
|
||||
|
||||
export interface SavedSqlSyncEntry {
|
||||
folderName?: string;
|
||||
fileName: string;
|
||||
|
|
@ -448,6 +455,14 @@ export async function saveDesktopSettings(settings: DesktopSettings): Promise<vo
|
|||
return invoke("save_desktop_settings", { settings });
|
||||
}
|
||||
|
||||
export async function loadMcpGlobalPolicy(): Promise<McpGlobalPolicy> {
|
||||
return invoke("load_mcp_global_policy");
|
||||
}
|
||||
|
||||
export async function saveMcpGlobalPolicy(policy: Omit<McpGlobalPolicy, "configured">): Promise<void> {
|
||||
return invoke("save_mcp_global_policy", { policy });
|
||||
}
|
||||
|
||||
export interface OpenTabsStatePayload {
|
||||
tabs: unknown[];
|
||||
activeTabId: string | null;
|
||||
|
|
|
|||
|
|
@ -1,18 +1,13 @@
|
|||
export type McpEnvEntry = readonly [key: string, value: string];
|
||||
|
||||
export interface McpLaunchConfig {
|
||||
command: string;
|
||||
args?: readonly string[];
|
||||
env?: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
const DEFAULT_MCP_LAUNCH_CONFIG: McpLaunchConfig = {
|
||||
command: "dbx-mcp-server",
|
||||
};
|
||||
|
||||
function envObject(envEntries: readonly McpEnvEntry[]): Record<string, string> {
|
||||
return Object.fromEntries(envEntries);
|
||||
}
|
||||
|
||||
function launchConfig(config?: McpLaunchConfig): McpLaunchConfig {
|
||||
return config ?? DEFAULT_MCP_LAUNCH_CONFIG;
|
||||
}
|
||||
|
|
@ -23,6 +18,9 @@ function withLaunchConfig(dbx: Record<string, unknown>, config?: McpLaunchConfig
|
|||
if (launch.args && launch.args.length > 0) {
|
||||
dbx.args = [...launch.args];
|
||||
}
|
||||
if (launch.env && Object.keys(launch.env).length > 0) {
|
||||
dbx.env = { ...launch.env };
|
||||
}
|
||||
return dbx;
|
||||
}
|
||||
|
||||
|
|
@ -30,43 +28,53 @@ function tomlStringArray(values: readonly string[]): string {
|
|||
return `[${values.map((value) => JSON.stringify(value)).join(", ")}]`;
|
||||
}
|
||||
|
||||
export function buildMcpJsonConfig(envEntries: readonly McpEnvEntry[] = [], config?: McpLaunchConfig): string {
|
||||
export function mcpWebBackendUrl(origin: string, apiPath: string): string {
|
||||
return new URL(apiPath, origin).toString().replace(/\/api\/?$/, "");
|
||||
}
|
||||
|
||||
export function buildMcpJsonConfig(config?: McpLaunchConfig): string {
|
||||
const dbx: Record<string, unknown> = {
|
||||
...withLaunchConfig({}, config),
|
||||
};
|
||||
|
||||
if (envEntries.length > 0) {
|
||||
dbx.env = envObject(envEntries);
|
||||
}
|
||||
|
||||
return JSON.stringify({ mcpServers: { dbx } }, null, 2);
|
||||
}
|
||||
|
||||
export function buildMcpVsCodeConfig(envEntries: readonly McpEnvEntry[] = [], config?: McpLaunchConfig): string {
|
||||
export function buildMcpVsCodeConfig(config?: McpLaunchConfig): string {
|
||||
const dbx: Record<string, unknown> = {
|
||||
type: "stdio",
|
||||
...withLaunchConfig({}, config),
|
||||
};
|
||||
|
||||
if (envEntries.length > 0) {
|
||||
dbx.env = envObject(envEntries);
|
||||
}
|
||||
|
||||
return JSON.stringify({ servers: { dbx } }, null, 2);
|
||||
}
|
||||
|
||||
export function buildMcpCodexConfig(envEntries: readonly McpEnvEntry[] = [], config?: McpLaunchConfig): string {
|
||||
export function buildMcpCherryStudioConfig(config?: McpLaunchConfig): string {
|
||||
const launch = launchConfig(config);
|
||||
const dbx: Record<string, unknown> = {
|
||||
name: "dbx",
|
||||
description: "",
|
||||
baseUrl: "",
|
||||
command: launch.command,
|
||||
args: [...(launch.args ?? [])],
|
||||
env: { ...launch.env },
|
||||
isActive: true,
|
||||
type: "stdio",
|
||||
};
|
||||
|
||||
return JSON.stringify({ mcpServers: { dbx } }, null, 2);
|
||||
}
|
||||
|
||||
export function buildMcpCodexConfig(config?: McpLaunchConfig): string {
|
||||
const launch = launchConfig(config);
|
||||
const lines = ["[mcp_servers.dbx]", `command = ${JSON.stringify(launch.command)}`];
|
||||
|
||||
if (launch.args && launch.args.length > 0) {
|
||||
lines.push(`args = ${tomlStringArray(launch.args)}`);
|
||||
}
|
||||
|
||||
if (envEntries.length > 0) {
|
||||
lines.push("");
|
||||
lines.push("[mcp_servers.dbx.env]");
|
||||
for (const [key, value] of envEntries) {
|
||||
if (launch.env && Object.keys(launch.env).length > 0) {
|
||||
lines.push("", "[mcp_servers.dbx.env]");
|
||||
for (const [key, value] of Object.entries(launch.env)) {
|
||||
lines.push(`${key} = ${JSON.stringify(value)}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -74,15 +82,14 @@ export function buildMcpCodexConfig(envEntries: readonly McpEnvEntry[] = [], con
|
|||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function buildMcpOpenCodeConfig(envEntries: readonly McpEnvEntry[] = [], config?: McpLaunchConfig): string {
|
||||
export function buildMcpOpenCodeConfig(config?: McpLaunchConfig): string {
|
||||
const launch = launchConfig(config);
|
||||
const dbx: Record<string, unknown> = {
|
||||
type: "local",
|
||||
command: [launch.command, ...(launch.args ?? [])],
|
||||
};
|
||||
|
||||
if (envEntries.length > 0) {
|
||||
dbx.environment = envObject(envEntries);
|
||||
if (launch.env && Object.keys(launch.env).length > 0) {
|
||||
dbx.environment = { ...launch.env };
|
||||
}
|
||||
|
||||
return JSON.stringify({ mcp: { dbx } }, null, 2);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
export type McpExecutionMode = "read_only" | "safe_write" | "high_risk_write";
|
||||
|
||||
export interface McpCapabilityRow {
|
||||
labelKey: string;
|
||||
read_only: boolean;
|
||||
safe_write: boolean;
|
||||
high_risk_write: boolean;
|
||||
}
|
||||
|
||||
export const MCP_EXECUTION_MODE_COLUMNS: ReadonlyArray<{ mode: McpExecutionMode; labelKey: string }> = [
|
||||
{ mode: "read_only", labelKey: "settings.mcpExecutionModeReadOnly" },
|
||||
{ mode: "safe_write", labelKey: "settings.mcpExecutionModeSafeWrite" },
|
||||
{ mode: "high_risk_write", labelKey: "settings.mcpExecutionModeHighRiskWrite" },
|
||||
];
|
||||
|
||||
export const MCP_CAPABILITY_ROWS: readonly McpCapabilityRow[] = [
|
||||
{ labelKey: "settings.mcpCapabilityRead", read_only: true, safe_write: true, high_risk_write: true },
|
||||
{ labelKey: "settings.mcpCapabilityScopedMutation", read_only: false, safe_write: true, high_risk_write: true },
|
||||
{ labelKey: "settings.mcpCapabilityBroadMutation", read_only: false, safe_write: false, high_risk_write: true },
|
||||
{ labelKey: "settings.mcpCapabilitySchemaAdmin", read_only: false, safe_write: false, high_risk_write: true },
|
||||
{ labelKey: "settings.mcpCapabilityConnectionManagement", read_only: false, safe_write: true, high_risk_write: true },
|
||||
];
|
||||
|
||||
export interface McpExecutionPolicyFields {
|
||||
readOnly: boolean;
|
||||
allowDangerousSql: boolean;
|
||||
}
|
||||
|
||||
export interface McpPolicyMutationState {
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
loadError: string;
|
||||
}
|
||||
|
||||
export interface McpScopeConnectionLike {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface McpScopeConnectionGroups<T extends McpScopeConnectionLike> {
|
||||
allowed: T[];
|
||||
available: T[];
|
||||
unavailableAllowedIds: string[];
|
||||
}
|
||||
|
||||
export function isMcpPolicyMutationBlocked(state: McpPolicyMutationState): boolean {
|
||||
return state.loading || state.saving || state.loadError.length > 0;
|
||||
}
|
||||
|
||||
export function matchesMcpSearchQuery(rawQuery: string, values: readonly unknown[]): boolean {
|
||||
const query = rawQuery.trim().toLocaleLowerCase();
|
||||
if (!query) return true;
|
||||
return values.some((value) => value !== null && value !== undefined && String(value).toLocaleLowerCase().includes(query));
|
||||
}
|
||||
|
||||
export function groupMcpScopeConnections<T extends McpScopeConnectionLike>(connections: readonly T[], allowedConnectionIds: readonly string[] | null): McpScopeConnectionGroups<T> {
|
||||
if (allowedConnectionIds === null) {
|
||||
return {
|
||||
allowed: [...connections],
|
||||
available: [],
|
||||
unavailableAllowedIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
const allowedIds = new Set(allowedConnectionIds);
|
||||
const connectionIds = new Set(connections.map((connection) => connection.id));
|
||||
return {
|
||||
allowed: connections.filter((connection) => allowedIds.has(connection.id)),
|
||||
available: connections.filter((connection) => !allowedIds.has(connection.id)),
|
||||
unavailableAllowedIds: allowedConnectionIds.filter((id) => !connectionIds.has(id)),
|
||||
};
|
||||
}
|
||||
|
||||
export function mcpExecutionModeFromPolicy(policy: McpExecutionPolicyFields): McpExecutionMode {
|
||||
if (policy.readOnly) return "read_only";
|
||||
return policy.allowDangerousSql ? "high_risk_write" : "safe_write";
|
||||
}
|
||||
|
||||
export function mcpPolicyFieldsForExecutionMode(mode: McpExecutionMode): McpExecutionPolicyFields {
|
||||
switch (mode) {
|
||||
case "read_only":
|
||||
return { readOnly: true, allowDangerousSql: false };
|
||||
case "safe_write":
|
||||
return { readOnly: false, allowDangerousSql: false };
|
||||
case "high_risk_write":
|
||||
return { readOnly: false, allowDangerousSql: true };
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleMcpAllowedConnectionId(current: readonly string[] | null, availableConnectionIds: readonly string[], connectionId: string, allowed: boolean): string[] {
|
||||
return updateMcpAllowedConnectionIds(current, availableConnectionIds, [connectionId], allowed);
|
||||
}
|
||||
|
||||
export function updateMcpAllowedConnectionIds(current: readonly string[] | null, availableConnectionIds: readonly string[], connectionIds: readonly string[], allowed: boolean): string[] {
|
||||
const selected = new Set(current === null ? availableConnectionIds : current);
|
||||
for (const connectionId of connectionIds) {
|
||||
if (allowed) selected.add(connectionId);
|
||||
else selected.delete(connectionId);
|
||||
}
|
||||
return [...selected];
|
||||
}
|
||||
|
|
@ -598,25 +598,15 @@ export function evaluateMongoWriteSafety(command: MongoWriteCommand, options: Mo
|
|||
if (!options.allowWrites) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "MCP MongoDB execution is read-only by default. Set DBX_MCP_ALLOW_WRITES=1 to allow write commands.",
|
||||
reason: "MCP MongoDB execution is read-only under the current DBX policy.",
|
||||
};
|
||||
}
|
||||
if (!options.allowDangerous && (command.kind === "update" || command.kind === "delete" || command.kind === "findOneAndUpdate" || command.kind === "findOneAndReplace" || command.kind === "findOneAndDelete") && isEmptyJsonObject(command.filter)) {
|
||||
const filter = mongoWriteFilter(command);
|
||||
const highRisk = filter !== null ? mongoFilterIsEffectivelyUnbounded(filter) : command.kind !== "insert";
|
||||
if (!options.allowDangerous && highRisk) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "MongoDB update/delete commands must include a non-empty filter unless DBX_MCP_ALLOW_DANGEROUS_SQL=1 is set.",
|
||||
};
|
||||
}
|
||||
if (!options.allowDangerous && mongoDropIndexesRequiresDangerous(command)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "MongoDB dropIndexes() without a specific single index requires DBX_MCP_ALLOW_DANGEROUS_SQL=1.",
|
||||
};
|
||||
}
|
||||
if (!options.allowDangerous && command.kind === "dropCollection") {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "MongoDB drop() requires DBX_MCP_ALLOW_DANGEROUS_SQL=1.",
|
||||
reason: `MongoDB ${command.kind} requires high-risk operations to be enabled in DBX MCP settings.`,
|
||||
};
|
||||
}
|
||||
return { allowed: true };
|
||||
|
|
@ -643,13 +633,13 @@ export function evaluateMongoAggregateSafety(command: MongoAggregateCommand, opt
|
|||
if (!options.allowWrites) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `MongoDB aggregate stage "${writeStage}" writes data. Set DBX_MCP_ALLOW_WRITES=1 to allow write commands.`,
|
||||
reason: `MongoDB aggregate stage "${writeStage}" is blocked by the current DBX MCP read-only policy.`,
|
||||
};
|
||||
}
|
||||
if (!options.allowDangerous) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `MongoDB aggregate stage "${writeStage}" is dangerous. Set DBX_MCP_ALLOW_DANGEROUS_SQL=1 to allow it.`,
|
||||
reason: `MongoDB aggregate stage "${writeStage}" requires high-risk operations to be enabled in DBX MCP settings.`,
|
||||
};
|
||||
}
|
||||
return { allowed: true };
|
||||
|
|
@ -1179,17 +1169,164 @@ function isNonEmptyRecord(value: unknown): value is Record<string, unknown> {
|
|||
return isRecord(value) && Object.keys(value).length > 0;
|
||||
}
|
||||
|
||||
function isEmptyJsonObject(json: string): boolean {
|
||||
const parsed = parseNormalizedJson(json);
|
||||
return isRecord(parsed) && Object.keys(parsed).length === 0;
|
||||
function mongoWriteFilter(command: MongoWriteCommand): string | null {
|
||||
switch (command.kind) {
|
||||
case "update":
|
||||
case "delete":
|
||||
case "findOneAndUpdate":
|
||||
case "findOneAndReplace":
|
||||
case "findOneAndDelete":
|
||||
return command.filter;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function mongoDropIndexesRequiresDangerous(command: MongoWriteCommand): boolean {
|
||||
if (command.kind !== "dropIndexes") return false;
|
||||
if (!command.indexes) return true;
|
||||
const parsed = parseNormalizedJson(command.indexes);
|
||||
if (parsed === "*") return true;
|
||||
return Array.isArray(parsed) && parsed.length > 1;
|
||||
function mongoFilterIsEffectivelyUnbounded(json: string): boolean {
|
||||
const parsed = parseNormalizedJson(json);
|
||||
return !isRecord(parsed) || mongoFilterContainsOpaqueLogic(parsed) || mongoFilterObjectIsUnbounded(parsed);
|
||||
}
|
||||
|
||||
function mongoFilterContainsOpaqueLogic(filter: Record<string, unknown>): boolean {
|
||||
return Object.entries(filter).some(([key, value]) => {
|
||||
if (key === "$comment") return false;
|
||||
if (key === "$where" || key === "$expr" || key === "$nor") return true;
|
||||
if (key === "$and" || key === "$or") {
|
||||
if (!Array.isArray(value) || value.length === 0 || value.some((clause) => !isRecord(clause))) return true;
|
||||
if (value.some((clause) => mongoFilterContainsOpaqueLogic(clause as Record<string, unknown>))) return true;
|
||||
if (key === "$or" && value.some((clause) => isRecord(clause) && Object.prototype.hasOwnProperty.call(clause, "$and"))) return true;
|
||||
return key === "$or" && mongoOrHasComplementaryFieldClauses(value);
|
||||
}
|
||||
return key.startsWith("$") || mongoFieldPredicateContainsOpaqueLogic(value);
|
||||
});
|
||||
}
|
||||
|
||||
const MONGO_SAFE_FIELD_OPERATORS = new Set(["$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$in", "$nin", "$exists"]);
|
||||
|
||||
function mongoFieldPredicateContainsOpaqueLogic(value: unknown): boolean {
|
||||
if (!isRecord(value)) return false;
|
||||
if (mongoExtendedJsonScalarLiteralIsValid(value)) return false;
|
||||
const keys = Object.keys(value);
|
||||
if (!keys.some((key) => key.startsWith("$"))) return false;
|
||||
return keys.some((key) => !key.startsWith("$") || !MONGO_SAFE_FIELD_OPERATORS.has(key));
|
||||
}
|
||||
|
||||
interface MongoPureFieldPredicate {
|
||||
field: string;
|
||||
operator: string;
|
||||
operand: unknown;
|
||||
}
|
||||
|
||||
function mongoOrHasComplementaryFieldClauses(clauses: unknown[]): boolean {
|
||||
const predicates = clauses.map(mongoPureFieldPredicate).filter((value): value is MongoPureFieldPredicate => value !== null);
|
||||
return predicates.some((predicate, index) => predicates.slice(index + 1).some((other) => mongoFieldPredicatesAreComplementary(predicate, other)));
|
||||
}
|
||||
|
||||
function mongoPureFieldPredicate(value: unknown): MongoPureFieldPredicate | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const entries = Object.entries(value).filter(([key]) => key !== "$comment");
|
||||
if (entries.length !== 1) return null;
|
||||
const [field, predicate] = entries[0]!;
|
||||
if (field === "$and" && Array.isArray(predicate)) {
|
||||
const boundedClauses = predicate.filter((clause) => isRecord(clause) && !mongoFilterObjectIsUnbounded(clause));
|
||||
return boundedClauses.length === 1 ? mongoPureFieldPredicate(boundedClauses[0]) : null;
|
||||
}
|
||||
if (field === "$or" && Array.isArray(predicate) && predicate.length === 1) {
|
||||
return mongoPureFieldPredicate(predicate[0]);
|
||||
}
|
||||
if (field.startsWith("$")) return null;
|
||||
if (!isRecord(predicate) || mongoExtendedJsonScalarLiteralIsValid(predicate) || !Object.keys(predicate).some((key) => key.startsWith("$"))) {
|
||||
return { field, operator: "$eq", operand: predicate };
|
||||
}
|
||||
const operators = Object.entries(predicate);
|
||||
if (operators.length !== 1 || !MONGO_SAFE_FIELD_OPERATORS.has(operators[0]![0])) return null;
|
||||
return { field, operator: operators[0]![0], operand: operators[0]![1] };
|
||||
}
|
||||
|
||||
function mongoFieldPredicatesAreComplementary(left: MongoPureFieldPredicate, right: MongoPureFieldPredicate): boolean {
|
||||
if (left.field !== right.field) return false;
|
||||
if (left.operator === "$exists" && right.operator === "$exists") {
|
||||
return typeof left.operand === "boolean" && typeof right.operand === "boolean" && left.operand !== right.operand;
|
||||
}
|
||||
const pair = `${left.operator}/${right.operator}`;
|
||||
if (pair === "$in/$nin" || pair === "$nin/$in") return mongoJsonSetsEqual(left.operand, right.operand);
|
||||
if (!["$eq/$ne", "$ne/$eq", "$gt/$lte", "$lte/$gt", "$gte/$lt", "$lt/$gte"].includes(pair)) return false;
|
||||
return mongoJsonValuesEqual(left.operand, right.operand);
|
||||
}
|
||||
|
||||
function mongoJsonSetsEqual(left: unknown, right: unknown): boolean {
|
||||
if (!Array.isArray(left) || !Array.isArray(right)) return false;
|
||||
return left.every((value) => right.some((other) => mongoJsonValuesEqual(value, other))) && right.every((value) => left.some((other) => mongoJsonValuesEqual(value, other)));
|
||||
}
|
||||
|
||||
function mongoJsonValuesEqual(left: unknown, right: unknown): boolean {
|
||||
if (Object.is(left, right)) return true;
|
||||
if (Array.isArray(left) || Array.isArray(right)) {
|
||||
return Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((value, index) => mongoJsonValuesEqual(value, right[index]));
|
||||
}
|
||||
if (!isRecord(left) || !isRecord(right)) return false;
|
||||
const leftKeys = Object.keys(left).sort();
|
||||
const rightKeys = Object.keys(right).sort();
|
||||
return leftKeys.length === rightKeys.length && leftKeys.every((key, index) => key === rightKeys[index] && mongoJsonValuesEqual(left[key], right[key]));
|
||||
}
|
||||
|
||||
function mongoExtendedJsonScalarLiteralIsValid(value: Record<string, unknown>): boolean {
|
||||
const entries = Object.entries(value);
|
||||
if (entries.length !== 1) return false;
|
||||
const [key, scalar] = entries[0]!;
|
||||
if (key === "$oid") return typeof scalar === "string" && /^[0-9a-fA-F]{24}$/.test(scalar);
|
||||
if (key === "$numberLong") return typeof scalar === "string" && mongoInt64StringIsValid(scalar);
|
||||
return key === "$date" && typeof scalar === "string" && mongoRfc3339DateIsValid(scalar);
|
||||
}
|
||||
|
||||
function mongoInt64StringIsValid(value: string): boolean {
|
||||
if (!/^-?\d+$/.test(value)) return false;
|
||||
try {
|
||||
const parsed = BigInt(value);
|
||||
return parsed >= -9223372036854775808n && parsed <= 9223372036854775807n;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function mongoRfc3339DateIsValid(value: string): boolean {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-](\d{2}):(\d{2}))$/.exec(value);
|
||||
if (!match) return false;
|
||||
const [, yearText, monthText, dayText, hourText, minuteText, secondText, offsetHourText, offsetMinuteText] = match;
|
||||
const year = Number(yearText);
|
||||
const month = Number(monthText);
|
||||
const day = Number(dayText);
|
||||
const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
||||
const daysInMonth = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1] ?? 0;
|
||||
return day >= 1 && day <= daysInMonth && Number(hourText) <= 23 && Number(minuteText) <= 59 && Number(secondText) <= 59 && (offsetHourText === undefined || (Number(offsetHourText) <= 23 && Number(offsetMinuteText) <= 59));
|
||||
}
|
||||
|
||||
function mongoFilterObjectIsUnbounded(filter: Record<string, unknown>): boolean {
|
||||
const entries = Object.entries(filter);
|
||||
if (entries.length === 0) return true;
|
||||
if (entries.some(([key]) => key === "$where" || key === "$expr")) return true;
|
||||
|
||||
return entries.every(([key, value]) => {
|
||||
if (key === "$comment") return true;
|
||||
if (key === "$and") {
|
||||
return !Array.isArray(value) || value.every((clause) => !isRecord(clause) || mongoFilterObjectIsUnbounded(clause));
|
||||
}
|
||||
if (key === "$or") {
|
||||
return !Array.isArray(value) || value.length === 0 || value.some((clause) => !isRecord(clause) || mongoFilterObjectIsUnbounded(clause));
|
||||
}
|
||||
if (key === "$nor") return true;
|
||||
if (mongoFieldPredicateIsEmptyNin(value)) return true;
|
||||
if (key === "_id" && mongoFieldPredicateIsExistsTrue(value)) return true;
|
||||
return key.startsWith("$");
|
||||
});
|
||||
}
|
||||
|
||||
function mongoFieldPredicateIsEmptyNin(value: unknown): boolean {
|
||||
return isRecord(value) && Object.keys(value).length === 1 && Array.isArray(value.$nin) && value.$nin.length === 0;
|
||||
}
|
||||
|
||||
function mongoFieldPredicateIsExistsTrue(value: unknown): boolean {
|
||||
return isRecord(value) && Object.keys(value).length === 1 && value.$exists === true;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EXECUTE_MODE_CURRENT_DEFAULT_VERSION, normalizeDesktopSettings, normalizeEditorSettings } from "@/stores/settingsStore";
|
||||
import { EXECUTE_MODE_CURRENT_DEFAULT_VERSION, normalizeDesktopSettings, normalizeEditorSettings, normalizeMcpGlobalPolicy } from "@/stores/settingsStore";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import type { AiConfigItem } from "@/types/ai";
|
||||
|
||||
|
|
@ -135,6 +135,37 @@ describe("normalizeDesktopSettings", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("normalizeMcpGlobalPolicy", () => {
|
||||
it("defaults to all connections with writes allowed", () => {
|
||||
expect(normalizeMcpGlobalPolicy(undefined)).toEqual({
|
||||
readOnly: false,
|
||||
allowDangerousSql: false,
|
||||
allowedConnectionIds: null,
|
||||
configured: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes and deduplicates an explicit connection allowlist", () => {
|
||||
expect(
|
||||
normalizeMcpGlobalPolicy({
|
||||
readOnly: true,
|
||||
allowDangerousSql: true,
|
||||
allowedConnectionIds: [" connection-1 ", "connection-1", "", "connection-2"],
|
||||
configured: true,
|
||||
}),
|
||||
).toEqual({
|
||||
readOnly: true,
|
||||
allowDangerousSql: true,
|
||||
allowedConnectionIds: ["connection-1", "connection-2"],
|
||||
configured: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an empty allowlist as deny all", () => {
|
||||
expect(normalizeMcpGlobalPolicy({ allowedConnectionIds: [] }).allowedConnectionIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeEditorSettings - continueOnErrorOnBatch", () => {
|
||||
it("defaults continueOnErrorOnBatch to false", () => {
|
||||
expect(normalizeEditorSettings({}).continueOnErrorOnBatch).toBe(false);
|
||||
|
|
@ -186,6 +217,46 @@ function makeTestConfig(overrides: Partial<AiConfigItem> & { id: string }): AiCo
|
|||
} as AiConfigItem;
|
||||
}
|
||||
|
||||
describe("settingsStore MCP policy persistence", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it("rolls an optimistic policy update back when persistence fails", async () => {
|
||||
let rejectSave!: (reason?: unknown) => void;
|
||||
const saveMcpGlobalPolicy = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((_resolve, reject) => {
|
||||
rejectSave = reject;
|
||||
}),
|
||||
);
|
||||
vi.doMock("@/lib/backend/api", () => ({ saveMcpGlobalPolicy }));
|
||||
|
||||
const { useSettingsStore } = await import("@/stores/settingsStore");
|
||||
const store = useSettingsStore();
|
||||
const previous = {
|
||||
readOnly: true,
|
||||
allowDangerousSql: false,
|
||||
allowedConnectionIds: ["connection-1"],
|
||||
configured: true,
|
||||
};
|
||||
store.mcpGlobalPolicy = previous;
|
||||
|
||||
const update = store.updateMcpGlobalPolicy({ readOnly: false, allowedConnectionIds: [] });
|
||||
expect(store.mcpGlobalPolicy).toEqual({
|
||||
readOnly: false,
|
||||
allowDangerousSql: false,
|
||||
allowedConnectionIds: [],
|
||||
configured: true,
|
||||
});
|
||||
|
||||
rejectSave(new Error("save failed"));
|
||||
await expect(update).rejects.toThrow("save failed");
|
||||
expect(store.mcpGlobalPolicy).toEqual(previous);
|
||||
});
|
||||
});
|
||||
|
||||
// --- activeModel lifecycle tests ---
|
||||
|
||||
describe("settingsStore activeModel lifecycle", () => {
|
||||
|
|
|
|||
|
|
@ -1054,6 +1054,27 @@ export const useQueryStore = defineStore("query", () => {
|
|||
return id;
|
||||
}
|
||||
|
||||
function showExecutedQueryResults(connectionId: string, database: string, sql: string, queryResults: QueryResult[]) {
|
||||
const id = createTab(connectionId, database, undefined, "query", undefined, sql);
|
||||
const tab = tabs.value.find((item) => item.id === id);
|
||||
if (!tab) return id;
|
||||
|
||||
const results = markQueryResultsRowsRaw(queryResults);
|
||||
const firstDataResult = results.findIndex((result) => result.columns.length > 0);
|
||||
const activeIndex = firstDataResult >= 0 ? firstDataResult : 0;
|
||||
tab.lastExecutedSql = sql;
|
||||
tab.resultBaseSql = sql;
|
||||
tab.results = results.length > 1 ? results : undefined;
|
||||
tab.activeResultIndex = results.length > 1 ? activeIndex : undefined;
|
||||
tab.result = results[activeIndex];
|
||||
tab.isExecuting = false;
|
||||
tab.isCancelling = false;
|
||||
tab.executionId = undefined;
|
||||
tab.queryExecutionStartedAt = undefined;
|
||||
if (tab.result) touchResult(tab);
|
||||
return id;
|
||||
}
|
||||
|
||||
function refreshExternalSqlFileTitles() {
|
||||
const externalTabs = tabs.value.filter((tab) => tab.mode === "query" && tab.externalSqlPath);
|
||||
const titles = externalSqlFileDisplayTitles(externalTabs.map((tab) => tab.externalSqlPath!));
|
||||
|
|
@ -4338,6 +4359,7 @@ export const useQueryStore = defineStore("query", () => {
|
|||
hasDirtyTabs,
|
||||
isConfirmingAppClose,
|
||||
createTab,
|
||||
showExecutedQueryResults,
|
||||
switchTab,
|
||||
closeTab,
|
||||
forceClosePendingTab,
|
||||
|
|
|
|||
|
|
@ -34,6 +34,13 @@ export interface DesktopSettings {
|
|||
sidebar_table_page_size?: number | null;
|
||||
}
|
||||
|
||||
export interface McpGlobalPolicy {
|
||||
readOnly: boolean;
|
||||
allowDangerousSql: boolean;
|
||||
allowedConnectionIds: string[] | null;
|
||||
configured: boolean;
|
||||
}
|
||||
|
||||
export type DesktopIconTheme = "default" | "black";
|
||||
|
||||
export type InterfaceLayout = "separated" | "classic";
|
||||
|
|
@ -63,6 +70,23 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
|
|||
sidebar_table_page_size: DEFAULT_SIDEBAR_TABLE_PAGE_SIZE,
|
||||
};
|
||||
|
||||
export const DEFAULT_MCP_GLOBAL_POLICY: McpGlobalPolicy = {
|
||||
readOnly: false,
|
||||
allowDangerousSql: false,
|
||||
allowedConnectionIds: null,
|
||||
configured: false,
|
||||
};
|
||||
|
||||
export function normalizeMcpGlobalPolicy(policy: Partial<McpGlobalPolicy> | null | undefined): McpGlobalPolicy {
|
||||
const allowedConnectionIds = policy?.allowedConnectionIds === null || policy?.allowedConnectionIds === undefined ? null : [...new Set(policy.allowedConnectionIds.filter((id): id is string => typeof id === "string" && id.trim().length > 0).map((id) => id.trim()))];
|
||||
return {
|
||||
readOnly: policy?.readOnly === true,
|
||||
allowDangerousSql: policy?.allowDangerousSql === true,
|
||||
allowedConnectionIds,
|
||||
configured: policy?.configured === true,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeDesktopSettings(settings: Partial<DesktopSettings> | null | undefined): DesktopSettings {
|
||||
const iconTheme = settings?.icon_theme === "black" ? "black" : DEFAULT_DESKTOP_SETTINGS.icon_theme;
|
||||
const sidebarTablePageSize = typeof settings?.sidebar_table_page_size === "number" && settings.sidebar_table_page_size > 0 ? settings.sidebar_table_page_size : DEFAULT_DESKTOP_SETTINGS.sidebar_table_page_size;
|
||||
|
|
@ -878,7 +902,9 @@ export const useSettingsStore = defineStore("settings", () => {
|
|||
const isAiConfigLoaded = ref(false);
|
||||
const aiConfigs = ref<AiConfigItem[]>([]);
|
||||
const desktopSettings = ref<DesktopSettings>({ ...DEFAULT_DESKTOP_SETTINGS });
|
||||
const mcpGlobalPolicy = ref<McpGlobalPolicy>({ ...DEFAULT_MCP_GLOBAL_POLICY });
|
||||
const isDesktopSettingsLoaded = ref(false);
|
||||
const isMcpGlobalPolicyLoaded = ref(false);
|
||||
const isEditorSettingsLoaded = ref(false);
|
||||
|
||||
const editorSettings = ref<EditorSettings>(normalizeEditorSettings({}));
|
||||
|
|
@ -951,6 +977,32 @@ export const useSettingsStore = defineStore("settings", () => {
|
|||
}
|
||||
}
|
||||
|
||||
async function initMcpGlobalPolicy(force = false) {
|
||||
if (isMcpGlobalPolicyLoaded.value && !force) return;
|
||||
mcpGlobalPolicy.value = normalizeMcpGlobalPolicy(await api.loadMcpGlobalPolicy());
|
||||
isMcpGlobalPolicyLoaded.value = true;
|
||||
}
|
||||
|
||||
async function updateMcpGlobalPolicy(partial: Partial<Omit<McpGlobalPolicy, "configured">>) {
|
||||
const previous = mcpGlobalPolicy.value;
|
||||
const next = normalizeMcpGlobalPolicy({
|
||||
...previous,
|
||||
...partial,
|
||||
configured: true,
|
||||
});
|
||||
mcpGlobalPolicy.value = next;
|
||||
try {
|
||||
await api.saveMcpGlobalPolicy({
|
||||
readOnly: next.readOnly,
|
||||
allowDangerousSql: next.allowDangerousSql,
|
||||
allowedConnectionIds: next.allowedConnectionIds,
|
||||
});
|
||||
} catch (error) {
|
||||
mcpGlobalPolicy.value = previous;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function initAiConfigs(): Promise<void> {
|
||||
if (isAiConfigLoaded.value) return;
|
||||
|
||||
|
|
@ -1219,10 +1271,13 @@ export const useSettingsStore = defineStore("settings", () => {
|
|||
isEditorSettingsLoaded,
|
||||
editorSettings,
|
||||
desktopSettings,
|
||||
mcpGlobalPolicy,
|
||||
initEditorSettings,
|
||||
updateEditorSettings,
|
||||
initDesktopSettings,
|
||||
updateDesktopSettings,
|
||||
initMcpGlobalPolicy,
|
||||
updateMcpGlobalPolicy,
|
||||
updateColumnFormatter,
|
||||
upsertCustomColumnFormatter,
|
||||
deleteCustomColumnFormatter,
|
||||
|
|
|
|||
|
|
@ -838,7 +838,11 @@ fn usage() -> &'static str {
|
|||
mod tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use dbx_core::{agent_events::ToolResult, agent_tools::AgentSqlPermissions, storage::Storage};
|
||||
use dbx_core::{
|
||||
agent_events::ToolResult,
|
||||
agent_tools::AgentSqlPermissions,
|
||||
storage::{McpGlobalPolicy, Storage},
|
||||
};
|
||||
use dbx_mcp::{backend::new_connection_config, mongo::MongoCommand};
|
||||
|
||||
struct MongoBackend {
|
||||
|
|
@ -867,6 +871,10 @@ mod tests {
|
|||
|
||||
#[async_trait]
|
||||
impl DbxBackend for MongoBackend {
|
||||
async fn load_mcp_global_policy(&self) -> Result<McpGlobalPolicy, String> {
|
||||
Ok(McpGlobalPolicy::default())
|
||||
}
|
||||
|
||||
async fn load_connections(&self) -> Result<Vec<ConnectionConfig>, String> {
|
||||
Ok(vec![self.connection.clone()])
|
||||
}
|
||||
|
|
@ -902,8 +910,12 @@ mod tests {
|
|||
})
|
||||
}
|
||||
|
||||
async fn save_connections(&self, _connections: &[ConnectionConfig]) -> Result<(), String> {
|
||||
Ok(())
|
||||
async fn add_connection_for_mcp(&self, config: ConnectionConfig) -> Result<ConnectionConfig, String> {
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
async fn remove_connection_for_mcp(&self, _connection_id: &str) -> Result<bool, String> {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1330,20 +1330,161 @@ pub fn parse_command_argv(command_text: &str) -> Result<Vec<String>, String> {
|
|||
}
|
||||
|
||||
pub fn classify_command(command: &str) -> RedisCommandSafety {
|
||||
// Read access is an explicit allowlist. Commands added by a newer Redis
|
||||
// version, module, or proxy remain high-risk until DBX reviews them.
|
||||
match command.to_ascii_uppercase().as_str() {
|
||||
"KEYS" | "FLUSHALL" | "SHUTDOWN" | "CONFIG" | "SAVE" | "BGSAVE" | "SLAVEOF" | "REPLICAOF" | "MIGRATE"
|
||||
| "MODULE" | "SCRIPT" | "EVAL" | "EVALSHA" => RedisCommandSafety::Blocked,
|
||||
"BITCOUNT"
|
||||
| "BITFIELD_RO"
|
||||
| "BITPOS"
|
||||
| "COMMAND"
|
||||
| "DBSIZE"
|
||||
| "DUMP"
|
||||
| "ECHO"
|
||||
| "EXISTS"
|
||||
| "EXPIRETIME"
|
||||
| "GEODIST"
|
||||
| "GEOHASH"
|
||||
| "GEOPOS"
|
||||
| "GEORADIUS_RO"
|
||||
| "GEORADIUSBYMEMBER_RO"
|
||||
| "GEOSEARCH"
|
||||
| "GET"
|
||||
| "GETBIT"
|
||||
| "GETRANGE"
|
||||
| "HEXISTS"
|
||||
| "HGET"
|
||||
| "HGETALL"
|
||||
| "HKEYS"
|
||||
| "HLEN"
|
||||
| "HMGET"
|
||||
| "HRANDFIELD"
|
||||
| "HSCAN"
|
||||
| "HSTRLEN"
|
||||
| "HVALS"
|
||||
| "INFO"
|
||||
| "LASTSAVE"
|
||||
| "LCS"
|
||||
| "LINDEX"
|
||||
| "LLEN"
|
||||
| "LPOS"
|
||||
| "LRANGE"
|
||||
| "MGET"
|
||||
| "OBJECT"
|
||||
| "PEXPIRETIME"
|
||||
| "PFCOUNT"
|
||||
| "PING"
|
||||
| "PTTL"
|
||||
| "PUBSUB"
|
||||
| "RANDOMKEY"
|
||||
| "ROLE"
|
||||
| "SCAN"
|
||||
| "SCARD"
|
||||
| "SDIFF"
|
||||
| "SINTER"
|
||||
| "SINTERCARD"
|
||||
| "SISMEMBER"
|
||||
| "SMEMBERS"
|
||||
| "SMISMEMBER"
|
||||
| "SORT_RO"
|
||||
| "SRANDMEMBER"
|
||||
| "SSCAN"
|
||||
| "STRLEN"
|
||||
| "SUNION"
|
||||
| "TIME"
|
||||
| "TTL"
|
||||
| "TYPE"
|
||||
| "WAIT"
|
||||
| "WAITAOF"
|
||||
| "XINFO"
|
||||
| "XLEN"
|
||||
| "XPENDING"
|
||||
| "XRANGE"
|
||||
| "XREAD"
|
||||
| "XREVRANGE"
|
||||
| "ZCARD"
|
||||
| "ZCOUNT"
|
||||
| "ZDIFF"
|
||||
| "ZINTER"
|
||||
| "ZINTERCARD"
|
||||
| "ZLEXCOUNT"
|
||||
| "ZMSCORE"
|
||||
| "ZRANDMEMBER"
|
||||
| "ZRANGE"
|
||||
| "ZRANGEBYLEX"
|
||||
| "ZRANGEBYSCORE"
|
||||
| "ZRANK"
|
||||
| "ZREVRANGE"
|
||||
| "ZREVRANGEBYLEX"
|
||||
| "ZREVRANGEBYSCORE"
|
||||
| "ZREVRANK"
|
||||
| "ZSCAN"
|
||||
| "ZSCORE"
|
||||
| "ZUNION"
|
||||
| "BF.CARD"
|
||||
| "BF.EXISTS"
|
||||
| "BF.INFO"
|
||||
| "BF.MEXISTS"
|
||||
| "CF.COUNT"
|
||||
| "CF.EXISTS"
|
||||
| "CF.INFO"
|
||||
| "CF.MEXISTS"
|
||||
| "CMS.INFO"
|
||||
| "CMS.QUERY"
|
||||
| "FT._LIST"
|
||||
| "FT.AGGREGATE"
|
||||
| "FT.DICTDUMP"
|
||||
| "FT.EXPLAIN"
|
||||
| "FT.EXPLAINCLI"
|
||||
| "FT.INFO"
|
||||
| "FT.PROFILE"
|
||||
| "FT.SEARCH"
|
||||
| "FT.SPELLCHECK"
|
||||
| "FT.SYNDUMP"
|
||||
| "FT.TAGVALS"
|
||||
| "GRAPH.RO_QUERY"
|
||||
| "JSON.ARRINDEX"
|
||||
| "JSON.ARRLEN"
|
||||
| "JSON.GET"
|
||||
| "JSON.MGET"
|
||||
| "JSON.OBJKEYS"
|
||||
| "JSON.OBJLEN"
|
||||
| "JSON.RESP"
|
||||
| "JSON.STRLEN"
|
||||
| "JSON.TYPE"
|
||||
| "TDIGEST.BYRANK"
|
||||
| "TDIGEST.BYREVRANK"
|
||||
| "TDIGEST.CDF"
|
||||
| "TDIGEST.INFO"
|
||||
| "TDIGEST.MAX"
|
||||
| "TDIGEST.MIN"
|
||||
| "TDIGEST.QUANTILE"
|
||||
| "TDIGEST.RANK"
|
||||
| "TDIGEST.REVRANK"
|
||||
| "TDIGEST.TRIMMED_MEAN"
|
||||
| "TOPK.INFO"
|
||||
| "TOPK.LIST"
|
||||
| "TOPK.QUERY"
|
||||
| "TS.GET"
|
||||
| "TS.INFO"
|
||||
| "TS.MGET"
|
||||
| "TS.MRANGE"
|
||||
| "TS.QUERYINDEX"
|
||||
| "TS.RANGE" => RedisCommandSafety::Allowed,
|
||||
"DEL" | "UNLINK" | "EXPIRE" | "EXPIREAT" | "PEXPIRE" | "PEXPIREAT" | "RENAME" | "RENAMENX" | "GETDEL"
|
||||
| "HDEL" | "LPOP" | "RPOP" | "LREM" | "LTRIM" | "SPOP" | "SREM" | "ZREM" | "ZPOPMAX" | "ZPOPMIN" | "ZMPOP"
|
||||
| "HDEL" | "JSON.ARRPOP" | "JSON.ARRTRIM" | "JSON.CLEAR" | "JSON.DEL" | "JSON.FORGET" | "BLMOVE" | "BLMPOP"
|
||||
| "BLPOP" | "BRPOP" | "BRPOPLPUSH" | "LPOP" | "LMOVE" | "LMPOP" | "RPOP" | "RPOPLPUSH" | "LREM" | "LTRIM"
|
||||
| "SPOP" | "SREM" | "ZREM" | "ZPOPMAX" | "ZPOPMIN" | "ZMPOP" | "BZMPOP" | "BZPOPMAX" | "BZPOPMIN"
|
||||
| "ZREMRANGEBYLEX" | "ZREMRANGEBYRANK" | "ZREMRANGEBYSCORE" | "XDEL" | "XTRIM" | "MOVE" | "SORT"
|
||||
| "SDIFFSTORE" | "SINTERSTORE" | "SUNIONSTORE" | "ZDIFFSTORE" | "ZINTERSTORE" | "ZRANGESTORE"
|
||||
| "ZUNIONSTORE" | "PFMERGE" | "GEOSEARCHSTORE" | "FLUSHDB" => RedisCommandSafety::Confirm,
|
||||
| "ZUNIONSTORE" | "PFMERGE" | "GEOSEARCHSTORE" => RedisCommandSafety::Confirm,
|
||||
"APPEND" | "BITFIELD" | "BITOP" | "COPY" | "DECR" | "DECRBY" | "GEOADD" | "GEORADIUS" | "GEORADIUSBYMEMBER"
|
||||
| "GETSET" | "INCR" | "INCRBY" | "INCRBYFLOAT" | "SET" | "SETEX" | "PSETEX" | "SETNX" | "SETRANGE" | "MSET"
|
||||
| "MSETNX" | "PERSIST" | "HSET" | "HMSET" | "HINCRBY" | "HINCRBYFLOAT" | "HSETNX" | "LINSERT" | "LSET"
|
||||
| "LMOVE" | "LPUSH" | "LPUSHX" | "PFADD" | "RPUSH" | "RPUSHX" | "RESTORE" | "SADD" | "ZADD" | "ZINCRBY"
|
||||
| "SETBIT" | "XADD" | "XACK" | "XAUTOCLAIM" | "XCLAIM" | "XSETID" => RedisCommandSafety::Write,
|
||||
_ => RedisCommandSafety::Allowed,
|
||||
| "GETEX" | "GETSET" | "INCR" | "INCRBY" | "INCRBYFLOAT" | "SET" | "SETEX" | "PSETEX" | "SETNX"
|
||||
| "SETRANGE" | "MSET" | "MSETNX" | "PERSIST" | "HSET" | "HMSET" | "HINCRBY" | "HINCRBYFLOAT" | "HSETNX"
|
||||
| "JSON.ARRAPPEND" | "JSON.ARRINSERT" | "JSON.MERGE" | "JSON.MSET" | "JSON.NUMINCRBY" | "JSON.NUMMULTBY"
|
||||
| "JSON.SET" | "JSON.STRAPPEND" | "JSON.TOGGLE" | "LINSERT" | "LSET" | "LPUSH" | "LPUSHX" | "PFADD"
|
||||
| "RPUSH" | "RPUSHX" | "RESTORE" | "SADD" | "ZADD" | "ZINCRBY" | "SETBIT" | "SPUBLISH" | "PUBLISH"
|
||||
| "TOUCH" | "XADD" | "XACK" | "XAUTOCLAIM" | "XCLAIM" | "XREADGROUP" | "XSETID" => RedisCommandSafety::Write,
|
||||
_ => RedisCommandSafety::Blocked,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2971,11 +3112,31 @@ mod tests {
|
|||
assert_eq!(argv, vec!["SET", "user:1", "Ada \"Lovelace\""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_quoted_and_escaped_command_names_before_classification() {
|
||||
for command_text in [r#""JSON.SET" user:1 $ {}"#, r#"JSON\.SET user:1 $ {}"#] {
|
||||
let argv = parse_command_argv(command_text).unwrap();
|
||||
assert_eq!(classify_command(&argv[0]), RedisCommandSafety::Write);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_command_text() {
|
||||
assert_eq!(parse_command_argv(" ").unwrap_err(), "Redis command is empty");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raw_command_execution_fails_closed_before_sending_invalid_or_unknown_commands() {
|
||||
let mut con = FakeRedisConnection::new(Vec::new());
|
||||
|
||||
let unknown = super::execute_command(&mut con, "VENDOR.WRITE key value", false).await.unwrap_err();
|
||||
assert!(unknown.contains("blocked for safety"));
|
||||
|
||||
let malformed = super::execute_command(&mut con, r#"GET "unterminated"#, true).await.unwrap_err();
|
||||
assert_eq!(malformed, "Redis command has an unterminated quote");
|
||||
assert!(con.commands.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_redis_values_case_insensitively() {
|
||||
assert!(redis_value_matches_query(&string_value("Hello Redis"), "redis"));
|
||||
|
|
@ -3040,13 +3201,20 @@ mod tests {
|
|||
#[test]
|
||||
fn classifies_safe_confirmed_and_blocked_commands() {
|
||||
assert_eq!(classify_command("GET"), RedisCommandSafety::Allowed);
|
||||
assert_eq!(classify_command("JSON.GET"), RedisCommandSafety::Allowed);
|
||||
assert_eq!(classify_command("set"), RedisCommandSafety::Write);
|
||||
assert_eq!(classify_command("hset"), RedisCommandSafety::Write);
|
||||
assert_eq!(classify_command("JSON.SET"), RedisCommandSafety::Write);
|
||||
assert_eq!(classify_command("GETEX"), RedisCommandSafety::Write);
|
||||
assert_eq!(classify_command("XREADGROUP"), RedisCommandSafety::Write);
|
||||
assert_eq!(classify_command("del"), RedisCommandSafety::Confirm);
|
||||
assert_eq!(classify_command("flushdb"), RedisCommandSafety::Confirm);
|
||||
assert_eq!(classify_command("flushdb"), RedisCommandSafety::Blocked);
|
||||
assert_eq!(classify_command("KEYS"), RedisCommandSafety::Blocked);
|
||||
assert_eq!(classify_command("flushall"), RedisCommandSafety::Blocked);
|
||||
assert_eq!(classify_command("eval"), RedisCommandSafety::Blocked);
|
||||
assert_eq!(classify_command("FCALL"), RedisCommandSafety::Blocked);
|
||||
assert_eq!(classify_command("XGROUP"), RedisCommandSafety::Blocked);
|
||||
assert_eq!(classify_command("VENDOR.WRITE"), RedisCommandSafety::Blocked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -2085,6 +2085,24 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_mcp_access_is_ignored_and_not_serialized() {
|
||||
let legacy: ConnectionConfig = serde_json::from_value(serde_json::json!({
|
||||
"id": "legacy",
|
||||
"name": "Legacy",
|
||||
"db_type": "mysql",
|
||||
"host": "127.0.0.1",
|
||||
"port": 3306,
|
||||
"username": "root",
|
||||
"password": "",
|
||||
"database": null,
|
||||
"mcp_access": "read_only"
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(serde_json::to_value(&legacy).unwrap().get("mcp_access").is_none());
|
||||
assert!(!legacy.read_only);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn database_identifier_whitespace_is_preserved_and_percent_encoded() {
|
||||
let mut config = mysql_config("root", "secret", Some(" analytics "));
|
||||
|
|
|
|||
|
|
@ -86,6 +86,17 @@ impl MongoCommand {
|
|||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_effectively_unbounded_filter(&self) -> bool {
|
||||
match self {
|
||||
Self::Update { filter, .. }
|
||||
| Self::Delete { filter, .. }
|
||||
| Self::FindOneAndUpdate { filter, .. }
|
||||
| Self::FindOneAndReplace { filter, .. }
|
||||
| Self::FindOneAndDelete { filter, .. } => mongo_filter_is_effectively_unbounded(filter),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_safety(
|
||||
|
|
@ -97,7 +108,7 @@ pub fn validate_safety(
|
|||
if command.is_mutating() && !allow_writes {
|
||||
return Err(MongoSafetyError::WritesDisabled);
|
||||
}
|
||||
if command.has_empty_filter() && !allow_dangerous {
|
||||
if command.has_effectively_unbounded_filter() && !allow_dangerous {
|
||||
return Err(MongoSafetyError::EmptyFilter);
|
||||
}
|
||||
if command.is_dangerous() && !allow_dangerous {
|
||||
|
|
@ -109,6 +120,207 @@ pub fn validate_safety(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub fn mongo_filter_is_effectively_unbounded(filter_json: &str) -> bool {
|
||||
serde_json::from_str::<serde_json::Value>(filter_json)
|
||||
.ok()
|
||||
.as_ref()
|
||||
.is_none_or(|value| mongo_filter_contains_opaque_logic(value) || mongo_filter_value_is_unbounded(value))
|
||||
}
|
||||
|
||||
fn mongo_filter_contains_opaque_logic(value: &serde_json::Value) -> bool {
|
||||
let Some(filter) = value.as_object() else {
|
||||
return true;
|
||||
};
|
||||
filter.iter().any(|(key, value)| match key.as_str() {
|
||||
"$comment" => false,
|
||||
"$where" | "$expr" | "$nor" => true,
|
||||
"$and" | "$or" => {
|
||||
let Some(clauses) = value.as_array() else {
|
||||
return true;
|
||||
};
|
||||
clauses.is_empty()
|
||||
|| clauses.iter().any(|clause| !clause.is_object() || mongo_filter_contains_opaque_logic(clause))
|
||||
|| (key == "$or"
|
||||
&& clauses
|
||||
.iter()
|
||||
.any(|clause| clause.as_object().is_some_and(|document| document.contains_key("$and"))))
|
||||
|| (key == "$or" && mongo_or_has_complementary_field_clauses(clauses))
|
||||
}
|
||||
_ => key.starts_with('$') || mongo_field_predicate_contains_opaque_logic(value),
|
||||
})
|
||||
}
|
||||
|
||||
fn mongo_field_predicate_contains_opaque_logic(value: &serde_json::Value) -> bool {
|
||||
let Some(predicate) = value.as_object() else {
|
||||
return false;
|
||||
};
|
||||
if mongo_extended_json_scalar_literal_is_valid(value) {
|
||||
return false;
|
||||
}
|
||||
let has_operator = predicate.keys().any(|key| key.starts_with('$'));
|
||||
has_operator
|
||||
&& predicate.keys().any(|key| {
|
||||
!matches!(key.as_str(), "$eq" | "$ne" | "$gt" | "$gte" | "$lt" | "$lte" | "$in" | "$nin" | "$exists")
|
||||
})
|
||||
}
|
||||
|
||||
fn mongo_extended_json_scalar_literal_is_valid(value: &serde_json::Value) -> bool {
|
||||
let Some(wrapper) = value.as_object().filter(|wrapper| wrapper.len() == 1) else {
|
||||
return false;
|
||||
};
|
||||
if let Some(value) = wrapper.get("$oid").and_then(serde_json::Value::as_str) {
|
||||
return value.len() == 24 && value.bytes().all(|byte| byte.is_ascii_hexdigit());
|
||||
}
|
||||
if let Some(value) = wrapper.get("$numberLong").and_then(serde_json::Value::as_str) {
|
||||
return value.parse::<i64>().is_ok();
|
||||
}
|
||||
wrapper
|
||||
.get("$date")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|value| chrono::DateTime::parse_from_rfc3339(value).is_ok())
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum MongoFieldOperator {
|
||||
Eq,
|
||||
Ne,
|
||||
Gt,
|
||||
Gte,
|
||||
Lt,
|
||||
Lte,
|
||||
In,
|
||||
Nin,
|
||||
Exists,
|
||||
}
|
||||
|
||||
struct MongoPureFieldPredicate<'a> {
|
||||
field: &'a str,
|
||||
operator: MongoFieldOperator,
|
||||
operand: &'a serde_json::Value,
|
||||
}
|
||||
|
||||
fn mongo_or_has_complementary_field_clauses(clauses: &[serde_json::Value]) -> bool {
|
||||
clauses.iter().enumerate().any(|(index, clause)| {
|
||||
let Some(predicate) = mongo_pure_field_predicate(clause) else {
|
||||
return false;
|
||||
};
|
||||
clauses[index + 1..]
|
||||
.iter()
|
||||
.filter_map(mongo_pure_field_predicate)
|
||||
.any(|other| mongo_field_predicates_are_complementary(&predicate, &other))
|
||||
})
|
||||
}
|
||||
|
||||
fn mongo_pure_field_predicate(value: &serde_json::Value) -> Option<MongoPureFieldPredicate<'_>> {
|
||||
let filter = value.as_object()?;
|
||||
let mut entries = filter.iter().filter(|(key, _)| key.as_str() != "$comment");
|
||||
let (field, predicate) = entries.next()?;
|
||||
if entries.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
if field == "$and" {
|
||||
let clauses = predicate.as_array()?;
|
||||
let mut bounded = clauses.iter().filter(|clause| !mongo_filter_value_is_unbounded(clause));
|
||||
let clause = bounded.next()?;
|
||||
if bounded.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
return mongo_pure_field_predicate(clause);
|
||||
}
|
||||
if field == "$or" {
|
||||
let clauses = predicate.as_array()?;
|
||||
return (clauses.len() == 1).then(|| mongo_pure_field_predicate(&clauses[0])).flatten();
|
||||
}
|
||||
if field.starts_with('$') {
|
||||
return None;
|
||||
}
|
||||
let Some(operator_document) = predicate.as_object() else {
|
||||
return Some(MongoPureFieldPredicate { field, operator: MongoFieldOperator::Eq, operand: predicate });
|
||||
};
|
||||
if mongo_extended_json_scalar_literal_is_valid(predicate)
|
||||
|| !operator_document.keys().any(|key| key.starts_with('$'))
|
||||
{
|
||||
return Some(MongoPureFieldPredicate { field, operator: MongoFieldOperator::Eq, operand: predicate });
|
||||
}
|
||||
let mut operators = operator_document.iter();
|
||||
let (operator, operand) = operators.next()?;
|
||||
if operators.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
let operator = match operator.as_str() {
|
||||
"$eq" => MongoFieldOperator::Eq,
|
||||
"$ne" => MongoFieldOperator::Ne,
|
||||
"$gt" => MongoFieldOperator::Gt,
|
||||
"$gte" => MongoFieldOperator::Gte,
|
||||
"$lt" => MongoFieldOperator::Lt,
|
||||
"$lte" => MongoFieldOperator::Lte,
|
||||
"$in" => MongoFieldOperator::In,
|
||||
"$nin" => MongoFieldOperator::Nin,
|
||||
"$exists" => MongoFieldOperator::Exists,
|
||||
_ => return None,
|
||||
};
|
||||
Some(MongoPureFieldPredicate { field, operator, operand })
|
||||
}
|
||||
|
||||
fn mongo_field_predicates_are_complementary(
|
||||
left: &MongoPureFieldPredicate<'_>,
|
||||
right: &MongoPureFieldPredicate<'_>,
|
||||
) -> bool {
|
||||
if left.field != right.field {
|
||||
return false;
|
||||
}
|
||||
use MongoFieldOperator::{Eq, Exists, Gt, Gte, In, Lt, Lte, Ne, Nin};
|
||||
match (left.operator, right.operator) {
|
||||
(Exists, Exists) => {
|
||||
left.operand.as_bool().zip(right.operand.as_bool()).is_some_and(|(left, right)| left != right)
|
||||
}
|
||||
(In, Nin) | (Nin, In) => mongo_json_sets_equal(left.operand, right.operand),
|
||||
(Eq, Ne) | (Ne, Eq) | (Gt, Lte) | (Lte, Gt) | (Gte, Lt) | (Lt, Gte) => left.operand == right.operand,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn mongo_json_sets_equal(left: &serde_json::Value, right: &serde_json::Value) -> bool {
|
||||
let (Some(left), Some(right)) = (left.as_array(), right.as_array()) else {
|
||||
return false;
|
||||
};
|
||||
left.iter().all(|value| right.contains(value)) && right.iter().all(|value| left.contains(value))
|
||||
}
|
||||
|
||||
fn mongo_filter_value_is_unbounded(value: &serde_json::Value) -> bool {
|
||||
let Some(filter) = value.as_object() else {
|
||||
return true;
|
||||
};
|
||||
if filter.is_empty() || filter.contains_key("$where") || filter.contains_key("$expr") {
|
||||
return true;
|
||||
}
|
||||
filter.iter().all(|(key, value)| match key.as_str() {
|
||||
"$comment" => true,
|
||||
"$and" => value
|
||||
.as_array()
|
||||
.is_none_or(|clauses| clauses.is_empty() || clauses.iter().all(mongo_filter_value_is_unbounded)),
|
||||
"$or" => value
|
||||
.as_array()
|
||||
.is_none_or(|clauses| clauses.is_empty() || clauses.iter().any(mongo_filter_value_is_unbounded)),
|
||||
"$nor" => true,
|
||||
_ if mongo_field_predicate_is_empty_nin(value) => true,
|
||||
"_id" if mongo_field_predicate_is_exists_true(value) => true,
|
||||
_ => key.starts_with('$'),
|
||||
})
|
||||
}
|
||||
|
||||
fn mongo_field_predicate_is_empty_nin(value: &serde_json::Value) -> bool {
|
||||
value.as_object().is_some_and(|predicate| {
|
||||
predicate.len() == 1 && predicate.get("$nin").and_then(serde_json::Value::as_array).is_some_and(Vec::is_empty)
|
||||
})
|
||||
}
|
||||
|
||||
fn mongo_field_predicate_is_exists_true(value: &serde_json::Value) -> bool {
|
||||
value.as_object().is_some_and(|predicate| {
|
||||
predicate.len() == 1 && predicate.get("$exists").and_then(serde_json::Value::as_bool) == Some(true)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn parse(input: &str) -> Result<MongoCommand, String> {
|
||||
let source = input.trim().trim_end_matches(';').trim();
|
||||
if source.eq_ignore_ascii_case("db.version()") {
|
||||
|
|
@ -598,6 +810,33 @@ mod tests {
|
|||
assert!(update.has_empty_filter());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn treats_effectively_unbounded_write_filters_as_dangerous() {
|
||||
for command in [
|
||||
r#"db.items.deleteMany({_id: {$exists: true}})"#,
|
||||
r#"db.items.deleteMany({id: {$nin: []}})"#,
|
||||
r#"db.items.deleteMany({$expr: true})"#,
|
||||
r#"db.items.deleteMany({$or: [{id: 1}, {id: {$ne: 1}}]})"#,
|
||||
] {
|
||||
let command = parse(command).unwrap();
|
||||
assert!(command.has_effectively_unbounded_filter(), "{command:?}");
|
||||
assert_eq!(
|
||||
validate_safety(&command, true, false, false),
|
||||
Err(MongoSafetyError::EmptyFilter),
|
||||
"{command:?}"
|
||||
);
|
||||
}
|
||||
|
||||
for command in [
|
||||
r#"db.items.deleteMany({_id: ObjectId('507f1f77bcf86cd799439011')})"#,
|
||||
r#"db.items.updateMany({tenant_id: 7}, {$set: {active: false}})"#,
|
||||
] {
|
||||
let command = parse(command).unwrap();
|
||||
assert!(!command.has_effectively_unbounded_filter(), "{command:?}");
|
||||
assert_eq!(validate_safety(&command, true, false, false), Ok(()), "{command:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_multiline_chains_and_update_options() {
|
||||
let command = parse(
|
||||
|
|
|
|||
|
|
@ -104,6 +104,78 @@ pub fn is_production_database(config: &ConnectionConfig, database: &str) -> bool
|
|||
.any(|name| normalize_database_name(name) == normalize_database_name(database)))
|
||||
}
|
||||
|
||||
/// Returns whether a MongoDB aggregation write stage targets production scope.
|
||||
/// `$out` and `$merge` can name a database different from the pipeline's source
|
||||
/// database, so checking only the selected database is insufficient.
|
||||
pub fn mongo_pipeline_targets_production_database(
|
||||
config: &ConnectionConfig,
|
||||
active_database: &str,
|
||||
pipeline_json: &str,
|
||||
) -> bool {
|
||||
if is_production_database(config, active_database) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let Ok(serde_json::Value::Array(stages)) = serde_json::from_str::<serde_json::Value>(pipeline_json) else {
|
||||
return true;
|
||||
};
|
||||
let mut has_write_stage = false;
|
||||
let mut uncertain = false;
|
||||
let mut targets = HashSet::new();
|
||||
|
||||
for stage in stages {
|
||||
let Some(document) = stage.as_object() else {
|
||||
continue;
|
||||
};
|
||||
if let Some(target) = document.get("$out") {
|
||||
has_write_stage = true;
|
||||
match target {
|
||||
serde_json::Value::String(_) => {
|
||||
add_mongo_current_database(&mut targets, &mut uncertain, active_database)
|
||||
}
|
||||
serde_json::Value::Object(target) => match target.get("db").and_then(serde_json::Value::as_str) {
|
||||
Some(database) if !database.trim().is_empty() => {
|
||||
targets.insert(database.to_string());
|
||||
}
|
||||
_ => uncertain = true,
|
||||
},
|
||||
_ => uncertain = true,
|
||||
}
|
||||
}
|
||||
if let Some(target) = document.get("$merge") {
|
||||
has_write_stage = true;
|
||||
match target {
|
||||
serde_json::Value::String(_) => {
|
||||
add_mongo_current_database(&mut targets, &mut uncertain, active_database)
|
||||
}
|
||||
serde_json::Value::Object(target) => match target.get("into") {
|
||||
Some(serde_json::Value::String(_)) => {
|
||||
add_mongo_current_database(&mut targets, &mut uncertain, active_database)
|
||||
}
|
||||
Some(serde_json::Value::Object(into)) => match into.get("db").and_then(serde_json::Value::as_str) {
|
||||
Some(database) if !database.trim().is_empty() => {
|
||||
targets.insert(database.to_string());
|
||||
}
|
||||
_ => uncertain = true,
|
||||
},
|
||||
_ => uncertain = true,
|
||||
},
|
||||
_ => uncertain = true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
has_write_stage && (uncertain || targets.iter().any(|database| is_production_database(config, database)))
|
||||
}
|
||||
|
||||
fn add_mongo_current_database(targets: &mut HashSet<String>, uncertain: &mut bool, active_database: &str) {
|
||||
if active_database.trim().is_empty() {
|
||||
*uncertain = true;
|
||||
} else {
|
||||
targets.insert(active_database.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether a non-read SQL statement targets production scope.
|
||||
///
|
||||
/// Agent execution already classifies SQL risk with `sql_risk`; this function
|
||||
|
|
@ -144,6 +216,7 @@ fn referenced_databases(sql: &str, db_type: &DatabaseType, active_database: &str
|
|||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
use_database = database;
|
||||
assessment.databases.insert(use_database.clone());
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -547,7 +620,7 @@ fn append_quoted_identifier_token(
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{is_production_database, targets_production_database};
|
||||
use super::{is_production_database, mongo_pipeline_targets_production_database, targets_production_database};
|
||||
use crate::models::connection::{ConnectionConfig, DatabaseType};
|
||||
use serde::Deserialize;
|
||||
|
||||
|
|
@ -649,6 +722,7 @@ mod tests {
|
|||
"staging",
|
||||
"SELECT * FROM prod_app.users; DELETE FROM staging.users WHERE id = 1"
|
||||
));
|
||||
assert!(targets_production_database(&config(), "staging", "USE prod_app"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -686,4 +760,40 @@ mod tests {
|
|||
assert!(targets_production_database(&sqlserver, "staging", "DELETE FROM prod_app.dbo.users WHERE id = 1"));
|
||||
assert!(!targets_production_database(&sqlserver, "staging", "DELETE FROM prod_app.users WHERE id = 1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_cross_database_mongo_aggregate_write_targets() {
|
||||
let mut mongo = config();
|
||||
mongo.db_type = DatabaseType::MongoDb;
|
||||
mongo.database = Some("staging".to_string());
|
||||
mongo.production_databases = vec!["production".to_string()];
|
||||
|
||||
assert!(mongo_pipeline_targets_production_database(
|
||||
&mongo,
|
||||
"staging",
|
||||
r#"[{"$out":{"db":"production","coll":"copied"}}]"#
|
||||
));
|
||||
assert!(mongo_pipeline_targets_production_database(
|
||||
&mongo,
|
||||
"staging",
|
||||
r#"[{"$merge":{"into":{"db":"production","coll":"copied"}}}]"#
|
||||
));
|
||||
assert!(!mongo_pipeline_targets_production_database(&mongo, "staging", r#"[{"$out":"copied"}]"#));
|
||||
assert!(mongo_pipeline_targets_production_database(&mongo, "production", r#"[{"$merge":{"into":"copied"}}]"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fails_closed_for_indeterminate_mongo_aggregate_write_targets() {
|
||||
let mut mongo = config();
|
||||
mongo.db_type = DatabaseType::MongoDb;
|
||||
mongo.database = None;
|
||||
mongo.production_databases = vec!["production".to_string()];
|
||||
|
||||
assert!(mongo_pipeline_targets_production_database(&mongo, "", r#"[{"$out":"copied"}]"#));
|
||||
assert!(mongo_pipeline_targets_production_database(
|
||||
&mongo,
|
||||
"staging",
|
||||
r#"[{"$merge":{"whenMatched":"replace"}}]"#
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -474,7 +474,7 @@ fn has_extra_statement_after_semicolon(sql: &str) -> bool {
|
|||
stripped.split(';').skip(1).any(|part| !part.trim().is_empty())
|
||||
}
|
||||
|
||||
fn strip_sql_comments(sql: &str) -> String {
|
||||
pub(crate) fn strip_sql_comments(sql: &str) -> String {
|
||||
let mut output = String::with_capacity(sql.len());
|
||||
let mut chars = sql.chars().peekable();
|
||||
let mut in_line_comment = false;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -24,6 +24,7 @@ const STORAGE_DB_FILE_NAME: &str = "dbx.db";
|
|||
const APP_STATE_EDITOR_SETTINGS_KEY: &str = "editor_settings";
|
||||
const APP_STATE_OPEN_TABS_KEY: &str = "open_tabs";
|
||||
const APP_STATE_SAVED_SQL_EDITOR_POSITIONS_KEY: &str = "saved_sql_editor_positions";
|
||||
const MCP_GLOBAL_POLICY_KEY: &str = "mcp_global_policy";
|
||||
const USER_DATA_TABLES: &[&str] = &[
|
||||
"connections",
|
||||
"connection_secrets",
|
||||
|
|
@ -165,6 +166,34 @@ pub struct DesktopSettings {
|
|||
pub sidebar_table_page_size: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct McpGlobalPolicy {
|
||||
pub read_only: bool,
|
||||
#[serde(default)]
|
||||
pub allow_dangerous_sql: bool,
|
||||
pub allowed_connection_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct McpGlobalPolicyState {
|
||||
pub configured: bool,
|
||||
pub read_only: bool,
|
||||
pub allow_dangerous_sql: bool,
|
||||
pub allowed_connection_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl McpGlobalPolicyState {
|
||||
pub fn policy(&self) -> McpGlobalPolicy {
|
||||
McpGlobalPolicy {
|
||||
read_only: self.read_only,
|
||||
allow_dangerous_sql: self.allow_dangerous_sql,
|
||||
allowed_connection_ids: self.allowed_connection_ids.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_sidebar_table_page_size() -> usize {
|
||||
1000
|
||||
}
|
||||
|
|
@ -1088,7 +1117,7 @@ impl Storage {
|
|||
};
|
||||
match serde_json::from_str::<serde_json::Value>(&json).map_err(|e| e.to_string())? {
|
||||
serde_json::Value::Object(map) => Ok(map),
|
||||
_ => Ok(serde_json::Map::new()),
|
||||
_ => Err("app settings JSON must be an object".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1096,8 +1125,23 @@ impl Storage {
|
|||
&self,
|
||||
settings: &serde_json::Map<String, serde_json::Value>,
|
||||
) -> Result<(), String> {
|
||||
let json = serde_json::Value::Object(settings.clone()).to_string();
|
||||
let mut settings = settings.clone();
|
||||
self.with_conn(move |conn| {
|
||||
// The dedicated policy writer is the only owner of this key. Keep
|
||||
// its latest value across overlapping legacy settings saves.
|
||||
let current: Option<String> = conn
|
||||
.query_row("SELECT settings_json FROM app_settings WHERE id = 1", [], |row| row.get(0))
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())?;
|
||||
settings.remove(MCP_GLOBAL_POLICY_KEY);
|
||||
if let Some(current) = current {
|
||||
let current = serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(¤t)
|
||||
.map_err(|e| format!("invalid app settings JSON: {e}"))?;
|
||||
if let Some(policy) = current.get(MCP_GLOBAL_POLICY_KEY) {
|
||||
settings.insert(MCP_GLOBAL_POLICY_KEY.to_string(), policy.clone());
|
||||
}
|
||||
}
|
||||
let json = serde_json::Value::Object(settings).to_string();
|
||||
conn.execute("INSERT OR REPLACE INTO app_settings (id, settings_json) VALUES (1, ?1)", [json])
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
|
|
@ -1116,6 +1160,68 @@ impl Storage {
|
|||
Ok(settings.get("password_hash").and_then(|v| v.as_str()).map(|s| s.to_string()))
|
||||
}
|
||||
|
||||
pub async fn load_mcp_global_policy(&self) -> Result<McpGlobalPolicyState, String> {
|
||||
let result = self
|
||||
.with_conn(|conn| {
|
||||
let json: Option<String> = conn
|
||||
.query_row("SELECT settings_json FROM app_settings WHERE id = 1", [], |row| row.get(0))
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let Some(json) = json else {
|
||||
let policy = McpGlobalPolicy::default();
|
||||
return Ok(McpGlobalPolicyState {
|
||||
configured: false,
|
||||
read_only: policy.read_only,
|
||||
allow_dangerous_sql: policy.allow_dangerous_sql,
|
||||
allowed_connection_ids: policy.allowed_connection_ids,
|
||||
});
|
||||
};
|
||||
let settings = serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&json)
|
||||
.map_err(|e| format!("invalid app settings JSON: {e}"))?;
|
||||
let Some(value) = settings.get(MCP_GLOBAL_POLICY_KEY) else {
|
||||
let policy = McpGlobalPolicy::default();
|
||||
return Ok(McpGlobalPolicyState {
|
||||
configured: false,
|
||||
read_only: policy.read_only,
|
||||
allow_dangerous_sql: policy.allow_dangerous_sql,
|
||||
allowed_connection_ids: policy.allowed_connection_ids,
|
||||
});
|
||||
};
|
||||
let policy = serde_json::from_value::<McpGlobalPolicy>(value.clone())
|
||||
.map_err(|e| format!("invalid MCP policy: {e}"))?;
|
||||
Ok(McpGlobalPolicyState {
|
||||
configured: true,
|
||||
read_only: policy.read_only,
|
||||
allow_dangerous_sql: policy.allow_dangerous_sql,
|
||||
allowed_connection_ids: policy.allowed_connection_ids,
|
||||
})
|
||||
})
|
||||
.await;
|
||||
result.map_err(|e| format!("MCP_POLICY_UNAVAILABLE: {e}"))
|
||||
}
|
||||
|
||||
pub async fn save_mcp_global_policy(&self, policy: &McpGlobalPolicy) -> Result<(), String> {
|
||||
let policy = serde_json::to_value(policy).map_err(|e| format!("MCP_POLICY_UNAVAILABLE: {e}"))?;
|
||||
self.with_conn(move |conn| {
|
||||
let current: Option<String> = conn
|
||||
.query_row("SELECT settings_json FROM app_settings WHERE id = 1", [], |row| row.get(0))
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut settings = match current {
|
||||
Some(json) => serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&json)
|
||||
.map_err(|e| format!("invalid app settings JSON: {e}"))?,
|
||||
None => serde_json::Map::new(),
|
||||
};
|
||||
settings.insert(MCP_GLOBAL_POLICY_KEY.to_string(), policy);
|
||||
let json = serde_json::to_string(&settings).map_err(|e| e.to_string())?;
|
||||
conn.execute("INSERT OR REPLACE INTO app_settings (id, settings_json) VALUES (1, ?1)", [json])
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("MCP_POLICY_UNAVAILABLE: {e}"))
|
||||
}
|
||||
|
||||
pub async fn save_desktop_settings(&self, desktop_settings: &DesktopSettings) -> Result<(), String> {
|
||||
let mut settings = self.load_app_settings_json().await?;
|
||||
settings.remove("run_in_background");
|
||||
|
|
@ -1456,6 +1562,121 @@ impl Storage {
|
|||
|
||||
// Connections
|
||||
|
||||
fn load_mcp_global_policy_in_tx(tx: &rusqlite::Transaction<'_>) -> Result<McpGlobalPolicy, String> {
|
||||
let settings_json: Option<String> = tx
|
||||
.query_row("SELECT settings_json FROM app_settings WHERE id = 1", [], |row| row.get(0))
|
||||
.optional()
|
||||
.map_err(|e| format!("MCP_POLICY_UNAVAILABLE: {e}"))?;
|
||||
Ok(match settings_json {
|
||||
Some(json) => {
|
||||
let settings = serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&json)
|
||||
.map_err(|e| format!("MCP_POLICY_UNAVAILABLE: invalid app settings JSON: {e}"))?;
|
||||
match settings.get(MCP_GLOBAL_POLICY_KEY) {
|
||||
Some(value) => serde_json::from_value::<McpGlobalPolicy>(value.clone())
|
||||
.map_err(|e| format!("MCP_POLICY_UNAVAILABLE: invalid MCP policy: {e}"))?,
|
||||
None => McpGlobalPolicy::default(),
|
||||
}
|
||||
}
|
||||
None => McpGlobalPolicy::default(),
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_mcp_connection_change_allowed_in_tx(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
target_connection_id: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let policy = load_mcp_global_policy_in_tx(tx)?;
|
||||
if policy.read_only {
|
||||
return Err(
|
||||
"MCP_READ_ONLY: DBX global MCP read-only mode is enabled. Connection changes are blocked.".to_string()
|
||||
);
|
||||
}
|
||||
if let Some(connection_id) = target_connection_id {
|
||||
if policy.allowed_connection_ids.as_ref().is_some_and(|ids| !ids.iter().any(|id| id == connection_id)) {
|
||||
return Err(format!(
|
||||
"CONNECTION_OUT_OF_SCOPE: connection '{connection_id}' is not allowed by the current DBX MCP policy"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn persist_connection_in_tx(tx: &rusqlite::Transaction<'_>, config: &ConnectionConfig) -> Result<(), String> {
|
||||
let config = config.clone().canonicalized();
|
||||
let config_id = config.id.clone();
|
||||
let mut sanitized = config.clone();
|
||||
sanitized.password = String::new();
|
||||
scrub_transport_layer_secrets(&mut sanitized);
|
||||
sanitized.redis_sentinel_password = String::new();
|
||||
sanitized.connection_string = None;
|
||||
sanitized.init_script = None;
|
||||
scrub_mq_auth_secrets(&mut sanitized);
|
||||
scrub_mq_token_signing_secret(&mut sanitized);
|
||||
scrub_nacos_auth_secrets(&mut sanitized);
|
||||
let json = serde_json::to_string(&sanitized).map_err(|e| e.to_string())?;
|
||||
|
||||
tx.execute("INSERT INTO connections (id, config_json) VALUES (?1, ?2)", params![config_id, json])
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
persist_secret_in_tx(tx, &config.id, "password", &config.password)?;
|
||||
delete_secret_prefix_in_tx(tx, &config.id, TRANSPORT_LAYER_SECRET_PREFIX)?;
|
||||
for (index, layer) in config.transport_layers.iter().enumerate() {
|
||||
match layer {
|
||||
TransportLayerConfig::Ssh(ssh) => {
|
||||
persist_secret_in_tx(tx, &config.id, &transport_layer_ssh_password_key(index, layer), &ssh.password)?;
|
||||
persist_secret_in_tx(
|
||||
tx,
|
||||
&config.id,
|
||||
&transport_layer_ssh_key_passphrase_key(index, layer),
|
||||
&ssh.key_passphrase,
|
||||
)?;
|
||||
}
|
||||
TransportLayerConfig::Proxy(proxy) => {
|
||||
persist_secret_in_tx(
|
||||
tx,
|
||||
&config.id,
|
||||
&transport_layer_proxy_password_key(index, layer),
|
||||
&proxy.password,
|
||||
)?;
|
||||
}
|
||||
TransportLayerConfig::HttpTunnel(http) => {
|
||||
persist_secret_in_tx(
|
||||
tx,
|
||||
&config.id,
|
||||
&transport_layer_http_tunnel_token_key(index, layer),
|
||||
&http.token,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
persist_secret_in_tx(tx, &config.id, "redis_sentinel_password", &config.redis_sentinel_password)?;
|
||||
persist_secret_in_tx(tx, &config.id, "ssh_password", "")?;
|
||||
persist_secret_in_tx(tx, &config.id, "ssh_key_passphrase", "")?;
|
||||
persist_secret_in_tx(tx, &config.id, "proxy_password", "")?;
|
||||
delete_secret_prefix_in_tx(tx, &config.id, SSH_TUNNEL_SECRET_PREFIX)?;
|
||||
if let Some(cs) = &config.connection_string {
|
||||
persist_secret_in_tx(tx, &config.id, "connection_string", cs)?;
|
||||
} else {
|
||||
tx.execute(
|
||||
"DELETE FROM connection_secrets WHERE connection_id = ?1 AND key = ?2",
|
||||
params![config.id, "connection_string"],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
if let Some(script) = &config.init_script {
|
||||
persist_secret_in_tx(tx, &config.id, "init_script", script)?;
|
||||
} else {
|
||||
tx.execute(
|
||||
"DELETE FROM connection_secrets WHERE connection_id = ?1 AND key = ?2",
|
||||
params![config.id, "init_script"],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
persist_mq_auth_secrets_in_tx(tx, &config)?;
|
||||
persist_mq_token_signing_secret_in_tx(tx, &config)?;
|
||||
persist_nacos_auth_secrets_in_tx(tx, &config)
|
||||
}
|
||||
|
||||
impl Storage {
|
||||
pub async fn save_connection_metadata_preserving_secrets(
|
||||
&self,
|
||||
|
|
@ -1505,84 +1726,7 @@ impl Storage {
|
|||
tx.execute("DELETE FROM connections", []).map_err(|e| e.to_string())?;
|
||||
|
||||
for config in &configs {
|
||||
let config = config.canonicalized();
|
||||
let config_id = config.id.clone();
|
||||
let mut sanitized = config.clone();
|
||||
sanitized.password = String::new();
|
||||
scrub_transport_layer_secrets(&mut sanitized);
|
||||
sanitized.redis_sentinel_password = String::new();
|
||||
sanitized.connection_string = None;
|
||||
sanitized.init_script = None;
|
||||
scrub_mq_auth_secrets(&mut sanitized);
|
||||
scrub_mq_token_signing_secret(&mut sanitized);
|
||||
scrub_nacos_auth_secrets(&mut sanitized);
|
||||
let json = serde_json::to_string(&sanitized).map_err(|e| e.to_string())?;
|
||||
|
||||
tx.execute("INSERT INTO connections (id, config_json) VALUES (?1, ?2)", params![config_id, json])
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
persist_secret_in_tx(&tx, &config.id, "password", &config.password)?;
|
||||
delete_secret_prefix_in_tx(&tx, &config.id, TRANSPORT_LAYER_SECRET_PREFIX)?;
|
||||
for (index, layer) in config.transport_layers.iter().enumerate() {
|
||||
match layer {
|
||||
TransportLayerConfig::Ssh(ssh) => {
|
||||
persist_secret_in_tx(
|
||||
&tx,
|
||||
&config.id,
|
||||
&transport_layer_ssh_password_key(index, layer),
|
||||
&ssh.password,
|
||||
)?;
|
||||
persist_secret_in_tx(
|
||||
&tx,
|
||||
&config.id,
|
||||
&transport_layer_ssh_key_passphrase_key(index, layer),
|
||||
&ssh.key_passphrase,
|
||||
)?;
|
||||
}
|
||||
TransportLayerConfig::Proxy(proxy) => {
|
||||
persist_secret_in_tx(
|
||||
&tx,
|
||||
&config.id,
|
||||
&transport_layer_proxy_password_key(index, layer),
|
||||
&proxy.password,
|
||||
)?;
|
||||
}
|
||||
TransportLayerConfig::HttpTunnel(http) => {
|
||||
persist_secret_in_tx(
|
||||
&tx,
|
||||
&config.id,
|
||||
&transport_layer_http_tunnel_token_key(index, layer),
|
||||
&http.token,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
persist_secret_in_tx(&tx, &config.id, "redis_sentinel_password", &config.redis_sentinel_password)?;
|
||||
persist_secret_in_tx(&tx, &config.id, "ssh_password", "")?;
|
||||
persist_secret_in_tx(&tx, &config.id, "ssh_key_passphrase", "")?;
|
||||
persist_secret_in_tx(&tx, &config.id, "proxy_password", "")?;
|
||||
delete_secret_prefix_in_tx(&tx, &config.id, SSH_TUNNEL_SECRET_PREFIX)?;
|
||||
if let Some(cs) = &config.connection_string {
|
||||
persist_secret_in_tx(&tx, &config.id, "connection_string", cs)?;
|
||||
} else {
|
||||
tx.execute(
|
||||
"DELETE FROM connection_secrets WHERE connection_id = ?1 AND key = ?2",
|
||||
params![config.id, "connection_string"],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
if let Some(script) = &config.init_script {
|
||||
persist_secret_in_tx(&tx, &config.id, "init_script", script)?;
|
||||
} else {
|
||||
tx.execute(
|
||||
"DELETE FROM connection_secrets WHERE connection_id = ?1 AND key = ?2",
|
||||
params![config.id, "init_script"],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
persist_mq_auth_secrets_in_tx(&tx, &config)?;
|
||||
persist_mq_token_signing_secret_in_tx(&tx, &config)?;
|
||||
persist_nacos_auth_secrets_in_tx(&tx, &config)?;
|
||||
persist_connection_in_tx(&tx, config)?;
|
||||
}
|
||||
|
||||
if configs.is_empty() {
|
||||
|
|
@ -1599,6 +1743,35 @@ impl Storage {
|
|||
.await
|
||||
}
|
||||
|
||||
pub async fn add_connection_for_mcp(&self, config: ConnectionConfig) -> Result<ConnectionConfig, String> {
|
||||
let config = config.canonicalized();
|
||||
self.with_conn(move |conn| {
|
||||
let tx = conn.transaction().map_err(|e| e.to_string())?;
|
||||
ensure_mcp_connection_change_allowed_in_tx(&tx, None)?;
|
||||
persist_connection_in_tx(&tx, &config)?;
|
||||
tx.commit().map_err(|e| e.to_string())?;
|
||||
Ok(config)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn remove_connection_for_mcp(&self, connection_id: &str) -> Result<bool, String> {
|
||||
let connection_id = connection_id.to_string();
|
||||
self.with_conn(move |conn| {
|
||||
let tx = conn.transaction().map_err(|e| e.to_string())?;
|
||||
ensure_mcp_connection_change_allowed_in_tx(&tx, Some(&connection_id))?;
|
||||
let removed =
|
||||
tx.execute("DELETE FROM connections WHERE id = ?1", [&connection_id]).map_err(|e| e.to_string())? > 0;
|
||||
if removed {
|
||||
tx.execute("DELETE FROM connection_secrets WHERE connection_id = ?1", [&connection_id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
tx.commit().map_err(|e| e.to_string())?;
|
||||
Ok(removed)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn save_connection_database_info(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
|
|
@ -2864,7 +3037,10 @@ fn map_from_sql_err(err: serde_json::Error) -> rusqlite::Error {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{maybe_import_user_data_db, DataDbImportResult, DesktopIconTheme, DesktopSettings, Storage};
|
||||
use super::{
|
||||
maybe_import_user_data_db, DataDbImportResult, DesktopIconTheme, DesktopSettings, McpGlobalPolicy,
|
||||
McpGlobalPolicyState, Storage, MCP_GLOBAL_POLICY_KEY,
|
||||
};
|
||||
use crate::connection_secrets::{
|
||||
MQ_AUTH_PASSWORD_KEY, MQ_AUTH_TOKEN_KEY, MQ_TOKEN_SIGNING_KEY, NACOS_AUTH_PASSWORD_KEY,
|
||||
};
|
||||
|
|
@ -3382,6 +3558,169 @@ mod tests {
|
|||
assert_eq!(storage.load_desktop_settings().await.unwrap(), DesktopSettings::default());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mcp_global_policy_defaults_unconfigured_and_roundtrips_atomically() {
|
||||
let path = temp_db_path("mcp-global-policy");
|
||||
let storage = Storage::open(&path).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
storage.load_mcp_global_policy().await.unwrap(),
|
||||
McpGlobalPolicyState {
|
||||
configured: false,
|
||||
read_only: false,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: None,
|
||||
}
|
||||
);
|
||||
|
||||
storage.save_password_hash("preserved").await.unwrap();
|
||||
storage
|
||||
.save_mcp_global_policy(&McpGlobalPolicy {
|
||||
read_only: true,
|
||||
allow_dangerous_sql: true,
|
||||
allowed_connection_ids: Some(vec!["conn-1".to_string(), "conn-2".to_string()]),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
storage.load_mcp_global_policy().await.unwrap(),
|
||||
McpGlobalPolicyState {
|
||||
configured: true,
|
||||
read_only: true,
|
||||
allow_dangerous_sql: true,
|
||||
allowed_connection_ids: Some(vec!["conn-1".to_string(), "conn-2".to_string()]),
|
||||
}
|
||||
);
|
||||
assert_eq!(storage.load_password_hash().await.unwrap().as_deref(), Some("preserved"));
|
||||
let settings = storage.load_app_settings_json().await.unwrap();
|
||||
assert_eq!(settings[MCP_GLOBAL_POLICY_KEY]["readOnly"], true);
|
||||
assert_eq!(settings[MCP_GLOBAL_POLICY_KEY]["allowDangerousSql"], true);
|
||||
assert_eq!(settings[MCP_GLOBAL_POLICY_KEY]["allowedConnectionIds"][0], "conn-1");
|
||||
assert!(settings[MCP_GLOBAL_POLICY_KEY].get("configured").is_none());
|
||||
|
||||
storage.save_desktop_settings(&DesktopSettings::default()).await.unwrap();
|
||||
assert!(storage.load_mcp_global_policy().await.unwrap().read_only);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mcp_global_policy_fails_closed_on_malformed_settings() {
|
||||
let path = temp_db_path("mcp-global-policy-malformed");
|
||||
let storage = Storage::open(&path).await.unwrap();
|
||||
storage
|
||||
.with_conn(|conn| {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO app_settings (id, settings_json) VALUES (1, ?1)",
|
||||
[r#"{"mcp_global_policy":{"readOnly":"yes","allowedConnectionIds":null}}"#],
|
||||
)
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let error = storage.load_mcp_global_policy().await.unwrap_err();
|
||||
assert!(error.starts_with("MCP_POLICY_UNAVAILABLE:"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn malformed_app_settings_cannot_be_silently_replaced_by_an_unrelated_save() {
|
||||
let path = temp_db_path("mcp-global-policy-invalid-settings-shape");
|
||||
let storage = Storage::open(&path).await.unwrap();
|
||||
storage
|
||||
.with_conn(|conn| {
|
||||
conn.execute("INSERT OR REPLACE INTO app_settings (id, settings_json) VALUES (1, ?1)", ["[]"])
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(storage.load_mcp_global_policy().await.unwrap_err().starts_with("MCP_POLICY_UNAVAILABLE:"));
|
||||
assert!(storage.save_password_hash("must-not-reset-policy").await.is_err());
|
||||
let raw = storage
|
||||
.with_conn(|conn| {
|
||||
conn.query_row("SELECT settings_json FROM app_settings WHERE id = 1", [], |row| row.get::<_, String>(0))
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(raw, "[]");
|
||||
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mcp_global_policy_defaults_dangerous_sql_to_disabled_for_existing_settings() {
|
||||
let path = temp_db_path("mcp-global-policy-existing");
|
||||
let storage = Storage::open(&path).await.unwrap();
|
||||
storage
|
||||
.with_conn(|conn| {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO app_settings (id, settings_json) VALUES (1, ?1)",
|
||||
[r#"{"mcp_global_policy":{"readOnly":false,"allowedConnectionIds":null}}"#],
|
||||
)
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let policy = storage.load_mcp_global_policy().await.unwrap();
|
||||
assert!(policy.configured);
|
||||
assert!(!policy.allow_dangerous_sql);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mcp_connection_mutations_are_atomic_and_recheck_policy() {
|
||||
let path = temp_db_path("mcp-connection-mutation-guard");
|
||||
let storage = Storage::open(&path).await.unwrap();
|
||||
let kept = mq_connection("kept", "kept-token");
|
||||
let removed = mq_connection("removed", "removed-token");
|
||||
storage.save_connections(&[kept.clone(), removed.clone()]).await.unwrap();
|
||||
|
||||
storage
|
||||
.save_mcp_global_policy(&McpGlobalPolicy {
|
||||
read_only: false,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: Some(vec![kept.id.clone()]),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let error = storage.remove_connection_for_mcp(&removed.id).await.unwrap_err();
|
||||
assert!(error.starts_with("CONNECTION_OUT_OF_SCOPE:"));
|
||||
|
||||
let mut concurrently_updated = removed.clone();
|
||||
concurrently_updated.host = "updated-by-web-ui".to_string();
|
||||
storage.save_connections(&[kept.clone(), concurrently_updated.clone()]).await.unwrap();
|
||||
let added = mq_connection("added", "added-token");
|
||||
storage.add_connection_for_mcp(added.clone()).await.unwrap();
|
||||
let after_add = storage.load_connections().await.unwrap();
|
||||
assert_eq!(after_add.len(), 3);
|
||||
assert_eq!(
|
||||
after_add.iter().find(|config| config.id == concurrently_updated.id).map(|config| config.host.as_str()),
|
||||
Some("updated-by-web-ui")
|
||||
);
|
||||
|
||||
storage
|
||||
.save_mcp_global_policy(&McpGlobalPolicy {
|
||||
read_only: true,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let error = storage.remove_connection_for_mcp(&kept.id).await.unwrap_err();
|
||||
assert!(error.starts_with("MCP_READ_ONLY:"));
|
||||
assert_eq!(storage.load_connections().await.unwrap().len(), 3);
|
||||
|
||||
// Non-MCP callers remain governed by the ordinary DBX UI permissions.
|
||||
storage.save_connections(std::slice::from_ref(&kept)).await.unwrap();
|
||||
assert_eq!(storage.load_connections().await.unwrap()[0].id, kept.id);
|
||||
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn desktop_settings_fall_back_to_legacy_background_preference() {
|
||||
let path = temp_db_path("desktop-settings-legacy-background");
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use dbx_core::{
|
|||
connection::AppState,
|
||||
db::{redis_driver::RedisCommandResult, ColumnInfo, TableInfo},
|
||||
models::connection::{ConnectionConfig, DatabaseType},
|
||||
storage::Storage,
|
||||
storage::{McpGlobalPolicy, McpGlobalPolicyState, Storage},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
|
|
@ -42,8 +42,34 @@ impl From<&ConnectionConfig> for ConnectionSummary {
|
|||
}
|
||||
}
|
||||
|
||||
fn legacy_mcp_allow_writes() -> Option<bool> {
|
||||
match std::env::var("DBX_MCP_ALLOW_WRITES").ok()?.trim().to_ascii_lowercase().as_str() {
|
||||
"1" | "true" => Some(true),
|
||||
"0" | "false" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn effective_mcp_policy(state: McpGlobalPolicyState) -> McpGlobalPolicy {
|
||||
effective_mcp_policy_with_legacy_allow_writes(state, legacy_mcp_allow_writes())
|
||||
}
|
||||
|
||||
fn effective_mcp_policy_with_legacy_allow_writes(
|
||||
state: McpGlobalPolicyState,
|
||||
legacy_allow_writes: Option<bool>,
|
||||
) -> McpGlobalPolicy {
|
||||
let configured = state.configured;
|
||||
let mut policy = state.policy();
|
||||
if !configured && legacy_allow_writes == Some(false) {
|
||||
policy.read_only = true;
|
||||
}
|
||||
policy
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait DbxBackend: Send + Sync {
|
||||
async fn load_mcp_global_policy(&self) -> Result<McpGlobalPolicy, String>;
|
||||
|
||||
async fn load_connections(&self) -> Result<Vec<ConnectionConfig>, String>;
|
||||
async fn execute_agent_tool(
|
||||
&self,
|
||||
|
|
@ -64,7 +90,8 @@ pub trait DbxBackend: Send + Sync {
|
|||
let _ = (connection, database, sql, max_rows, timeout_secs);
|
||||
Err("SQL queries are not supported by this backend.".to_string())
|
||||
}
|
||||
async fn save_connections(&self, connections: &[ConnectionConfig]) -> Result<(), String>;
|
||||
async fn add_connection_for_mcp(&self, config: ConnectionConfig) -> Result<ConnectionConfig, String>;
|
||||
async fn remove_connection_for_mcp(&self, connection_id: &str) -> Result<bool, String>;
|
||||
async fn list_tables(
|
||||
&self,
|
||||
connection: &ConnectionConfig,
|
||||
|
|
@ -202,7 +229,10 @@ impl WebBackend {
|
|||
let mut retried = false;
|
||||
loop {
|
||||
let cookie = self.auth.lock().await.session_cookie.clone();
|
||||
let mut request = self.client.request(method.clone(), format!("{}{}", self.base_url, path));
|
||||
let mut request = self
|
||||
.client
|
||||
.request(method.clone(), format!("{}{}", self.base_url, path))
|
||||
.header("x-dbx-mcp-request", "1");
|
||||
if let Some(cookie) = cookie {
|
||||
request = request.header(reqwest::header::COOKIE, format!("dbx_session={cookie}"));
|
||||
}
|
||||
|
|
@ -250,6 +280,10 @@ impl LocalBackend {
|
|||
|
||||
#[async_trait]
|
||||
impl DbxBackend for LocalBackend {
|
||||
async fn load_mcp_global_policy(&self) -> Result<McpGlobalPolicy, String> {
|
||||
self.state.storage.load_mcp_global_policy().await.map(effective_mcp_policy)
|
||||
}
|
||||
|
||||
async fn load_connections(&self) -> Result<Vec<ConnectionConfig>, String> {
|
||||
self.state.storage.load_connections().await
|
||||
}
|
||||
|
|
@ -286,11 +320,18 @@ impl DbxBackend for LocalBackend {
|
|||
.await
|
||||
}
|
||||
|
||||
async fn save_connections(&self, connections: &[ConnectionConfig]) -> Result<(), String> {
|
||||
self.state.storage.save_connections(connections).await?;
|
||||
*self.state.configs.write().await =
|
||||
connections.iter().cloned().map(|config| (config.id.clone(), config)).collect();
|
||||
Ok(())
|
||||
async fn add_connection_for_mcp(&self, config: ConnectionConfig) -> Result<ConnectionConfig, String> {
|
||||
let config = self.state.storage.add_connection_for_mcp(config).await?;
|
||||
self.state.configs.write().await.insert(config.id.clone(), config.clone());
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
async fn remove_connection_for_mcp(&self, connection_id: &str) -> Result<bool, String> {
|
||||
let removed = self.state.storage.remove_connection_for_mcp(connection_id).await?;
|
||||
if removed {
|
||||
self.state.configs.write().await.remove(connection_id);
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
async fn list_tables(
|
||||
|
|
@ -571,6 +612,15 @@ impl DbxBackend for LocalBackend {
|
|||
|
||||
#[async_trait]
|
||||
impl DbxBackend for WebBackend {
|
||||
async fn load_mcp_global_policy(&self) -> Result<McpGlobalPolicy, String> {
|
||||
self.request(reqwest::Method::GET, "/api/app-settings/mcp-policy", None)
|
||||
.await?
|
||||
.json::<McpGlobalPolicyState>()
|
||||
.await
|
||||
.map(effective_mcp_policy)
|
||||
.map_err(|error| format!("Invalid MCP policy response: {error}"))
|
||||
}
|
||||
|
||||
async fn load_connections(&self) -> Result<Vec<ConnectionConfig>, String> {
|
||||
self.request(reqwest::Method::GET, "/api/connection/list", None)
|
||||
.await?
|
||||
|
|
@ -643,8 +693,24 @@ impl DbxBackend for WebBackend {
|
|||
.map_err(|error| format!("Invalid query response: {error}"))
|
||||
}
|
||||
|
||||
async fn save_connections(&self, _connections: &[ConnectionConfig]) -> Result<(), String> {
|
||||
Err("Connection changes are unavailable in DBX Web mode.".to_string())
|
||||
async fn add_connection_for_mcp(&self, config: ConnectionConfig) -> Result<ConnectionConfig, String> {
|
||||
self.request(reqwest::Method::POST, "/api/connection/mcp/add", Some(json!({ "config": config })))
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|error| format!("Invalid MCP connection response: {error}"))
|
||||
}
|
||||
|
||||
async fn remove_connection_for_mcp(&self, connection_id: &str) -> Result<bool, String> {
|
||||
self.request(
|
||||
reqwest::Method::POST,
|
||||
"/api/connection/mcp/remove",
|
||||
Some(json!({ "connectionId": connection_id })),
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|error| format!("Invalid MCP connection response: {error}"))
|
||||
}
|
||||
|
||||
async fn list_tables(
|
||||
|
|
@ -1324,6 +1390,18 @@ pub fn new_connection_config(
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn policy_state(configured: bool, read_only: bool) -> McpGlobalPolicyState {
|
||||
McpGlobalPolicyState { configured, read_only, allow_dangerous_sql: false, allowed_connection_ids: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_read_only_only_restricts_an_unconfigured_policy() {
|
||||
assert!(effective_mcp_policy_with_legacy_allow_writes(policy_state(false, false), Some(false)).read_only);
|
||||
assert!(!effective_mcp_policy_with_legacy_allow_writes(policy_state(false, false), Some(true)).read_only);
|
||||
assert!(!effective_mcp_policy_with_legacy_allow_writes(policy_state(true, false), Some(false)).read_only);
|
||||
assert!(effective_mcp_policy_with_legacy_allow_writes(policy_state(true, true), Some(true)).read_only);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_database_type_using_dbx_protocol_names() {
|
||||
assert_eq!(parse_database_type("Postgres").unwrap(), DatabaseType::Postgres);
|
||||
|
|
|
|||
|
|
@ -14,8 +14,14 @@ use crate::mongo::{self, MongoCommand, MongoSafetyError};
|
|||
use dbx_core::{
|
||||
db::redis_driver::{classify_command, parse_command_argv, RedisCommandResult, RedisCommandSafety},
|
||||
models::connection::DatabaseType,
|
||||
production_safety::{is_production_database, targets_production_database},
|
||||
sql_risk::{classify_sql_risk_for_database, SqlRisk},
|
||||
production_safety::{
|
||||
is_production_database, mongo_pipeline_targets_production_database, targets_production_database,
|
||||
},
|
||||
query_execution_sql::is_write_sql_for_database,
|
||||
sql_risk::{
|
||||
classify_sql_risk_for_database, is_dangerous_sql_for_database, mcp_sql_has_forbidden_database_switch, SqlRisk,
|
||||
},
|
||||
storage::McpGlobalPolicy,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
|
|
@ -131,27 +137,44 @@ pub struct DbxMcpServer {
|
|||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct McpScope {
|
||||
pub connection_id: Option<String>,
|
||||
pub connection_ids: Vec<String>,
|
||||
pub connection_name: Option<String>,
|
||||
pub database: Option<String>,
|
||||
}
|
||||
|
||||
struct ResolvedConnection {
|
||||
connection: dbx_core::models::connection::ConnectionConfig,
|
||||
policy: McpGlobalPolicy,
|
||||
}
|
||||
|
||||
impl McpScope {
|
||||
pub fn from_env() -> Self {
|
||||
let mut connection_ids = scoped_connection_ids(std::env::var("DBX_MCP_SCOPE_CONNECTION_IDS").ok().as_deref());
|
||||
if connection_ids.is_empty() {
|
||||
if let Some(connection_id) = non_empty_env("DBX_MCP_SCOPE_CONNECTION_ID") {
|
||||
connection_ids.push(connection_id);
|
||||
}
|
||||
}
|
||||
Self {
|
||||
connection_id: non_empty_env("DBX_MCP_SCOPE_CONNECTION_ID"),
|
||||
connection_ids,
|
||||
connection_name: non_empty_env("DBX_MCP_SCOPE_CONNECTION_NAME"),
|
||||
database: non_empty_env("DBX_MCP_SCOPE_DATABASE"),
|
||||
}
|
||||
}
|
||||
|
||||
fn enabled(&self) -> bool {
|
||||
self.connection_id.is_some() || self.connection_name.is_some()
|
||||
self.connection_scope_enabled() || self.database.is_some()
|
||||
}
|
||||
|
||||
fn connection_scope_enabled(&self) -> bool {
|
||||
!self.connection_ids.is_empty() || self.connection_name.is_some()
|
||||
}
|
||||
|
||||
fn matches(&self, connection: &dbx_core::models::connection::ConnectionConfig) -> bool {
|
||||
self.connection_id.as_deref() == Some(connection.id.as_str())
|
||||
|| self.connection_name.as_deref() == Some(connection.name.as_str())
|
||||
if !self.connection_ids.is_empty() {
|
||||
return self.connection_ids.iter().any(|id| id == &connection.id);
|
||||
}
|
||||
self.connection_name.as_deref() == Some(connection.name.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -191,18 +214,21 @@ impl DbxMcpServer {
|
|||
let rows = connections.iter().map(ConnectionSummary::from).collect::<Vec<_>>();
|
||||
text(format_connections(&rows))
|
||||
}
|
||||
Err(error) => tool_error("CONNECTION_LOAD_ERROR", error),
|
||||
Err(error) => backend_tool_error("CONNECTION_LOAD_ERROR", error),
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(name = "dbx_list_tables", description = "List tables and views for a database connection")]
|
||||
async fn list_tables(&self, Parameters(request): Parameters<ListTablesRequest>) -> CallToolResult {
|
||||
let connection = match self.resolve_connection(&request.selector).await {
|
||||
Ok(connection) => connection,
|
||||
let resolved = match self.resolve_connection(&request.selector).await {
|
||||
Ok(resolved) => resolved,
|
||||
Err(error) => return error,
|
||||
};
|
||||
let database = self.resolve_database(request.database, &connection);
|
||||
match self.backend.list_tables(&connection, &database, &request.schema.unwrap_or_default()).await {
|
||||
let database = match self.resolve_database(request.database, &resolved.connection) {
|
||||
Ok(database) => database,
|
||||
Err(error) => return error,
|
||||
};
|
||||
match self.backend.list_tables(&resolved.connection, &database, &request.schema.unwrap_or_default()).await {
|
||||
Ok(tables) if tables.is_empty() => text("No tables found."),
|
||||
Ok(tables) => text(
|
||||
tables
|
||||
|
|
@ -224,14 +250,17 @@ impl DbxMcpServer {
|
|||
|
||||
#[tool(name = "dbx_describe_table", description = "Get column definitions for a table")]
|
||||
async fn describe_table(&self, Parameters(request): Parameters<DescribeTableRequest>) -> CallToolResult {
|
||||
let connection = match self.resolve_connection(&request.selector).await {
|
||||
Ok(connection) => connection,
|
||||
let resolved = match self.resolve_connection(&request.selector).await {
|
||||
Ok(resolved) => resolved,
|
||||
Err(error) => return error,
|
||||
};
|
||||
let database = match self.resolve_database(request.database, &resolved.connection) {
|
||||
Ok(database) => database,
|
||||
Err(error) => return error,
|
||||
};
|
||||
let database = self.resolve_database(request.database, &connection);
|
||||
match self
|
||||
.backend
|
||||
.get_columns(&connection, &database, &request.schema.unwrap_or_default(), &request.table)
|
||||
.get_columns(&resolved.connection, &database, &request.schema.unwrap_or_default(), &request.table)
|
||||
.await
|
||||
{
|
||||
Ok(columns) if columns.is_empty() => text("No columns found."),
|
||||
|
|
@ -245,35 +274,43 @@ impl DbxMcpServer {
|
|||
description = "Execute a SQL query on a database connection (max 100 rows returned)"
|
||||
)]
|
||||
async fn execute_query(&self, Parameters(request): Parameters<ExecuteQueryRequest>) -> CallToolResult {
|
||||
let connection = match self.resolve_connection(&request.selector).await {
|
||||
Ok(connection) => connection,
|
||||
let resolved = match self.resolve_connection(&request.selector).await {
|
||||
Ok(resolved) => resolved,
|
||||
Err(error) => return error,
|
||||
};
|
||||
let connection = &resolved.connection;
|
||||
if connection.db_type == dbx_core::models::connection::DatabaseType::Redis {
|
||||
return tool_error(
|
||||
"REDIS_COMMAND_REQUIRED",
|
||||
"Redis connections do not accept SQL through dbx_execute_query. Use dbx_execute_redis_command.",
|
||||
);
|
||||
}
|
||||
let database = self.resolve_database(request.database, &connection);
|
||||
let database = match self.resolve_database(request.database, connection) {
|
||||
Ok(database) => database,
|
||||
Err(error) => return error,
|
||||
};
|
||||
if connection.db_type == DatabaseType::MongoDb {
|
||||
let command = match validate_mongo_command(&connection, &database, &request.sql) {
|
||||
let command = match validate_mongo_command(connection, &resolved.policy, &database, &request.sql) {
|
||||
Ok(command) => command,
|
||||
Err(error) => return error,
|
||||
};
|
||||
return match self.backend.execute_mongo_command(&connection, &database, &command).await {
|
||||
return match self.backend.execute_mongo_command(connection, &database, &command).await {
|
||||
Ok(result) => text(format_query_result(&result, 100)),
|
||||
Err(error) => tool_error("QUERY_ERROR", error),
|
||||
Err(error) => backend_tool_error("QUERY_ERROR", error),
|
||||
};
|
||||
}
|
||||
let permissions = match validate_sql_policy(connection, &resolved.policy, &database, &request.sql) {
|
||||
Ok(permissions) => permissions,
|
||||
Err(error) => return error,
|
||||
};
|
||||
let result = self
|
||||
.backend
|
||||
.execute_agent_tool(
|
||||
&connection,
|
||||
connection,
|
||||
&database,
|
||||
"execute_query",
|
||||
json!({ "sql": request.sql, "limit": 100 }),
|
||||
default_permissions(),
|
||||
permissions,
|
||||
)
|
||||
.await;
|
||||
agent_result(result)
|
||||
|
|
@ -284,10 +321,11 @@ impl DbxMcpServer {
|
|||
&self,
|
||||
Parameters(request): Parameters<ExecuteRedisCommandRequest>,
|
||||
) -> CallToolResult {
|
||||
let connection = match self.resolve_connection(&request.selector).await {
|
||||
Ok(connection) => connection,
|
||||
let resolved = match self.resolve_connection(&request.selector).await {
|
||||
Ok(resolved) => resolved,
|
||||
Err(error) => return error,
|
||||
};
|
||||
let connection = &resolved.connection;
|
||||
if connection.db_type != DatabaseType::Redis {
|
||||
return tool_error("INVALID_CONNECTION_TYPE", format!("Connection \"{}\" is not Redis.", connection.name));
|
||||
}
|
||||
|
|
@ -296,12 +334,21 @@ impl DbxMcpServer {
|
|||
Err(error) => return tool_error("REDIS_COMMAND_BLOCKED", error),
|
||||
};
|
||||
let safety = classify_command(&argv[0]);
|
||||
let permissions = default_permissions();
|
||||
let permissions = mcp_permissions(connection, &resolved.policy);
|
||||
if safety != RedisCommandSafety::Allowed && resolved.policy.read_only {
|
||||
return tool_error("MCP_READ_ONLY", "DBX global MCP read-only mode is enabled. Redis command blocked.");
|
||||
}
|
||||
if safety != RedisCommandSafety::Allowed && connection.read_only {
|
||||
return tool_error(
|
||||
"CONNECTION_READ_ONLY",
|
||||
format!("Connection \"{}\" has read-only protection enabled. Redis command blocked.", connection.name),
|
||||
);
|
||||
}
|
||||
if safety == RedisCommandSafety::Blocked && !permissions.allow_dangerous {
|
||||
return tool_error(
|
||||
"REDIS_COMMAND_BLOCKED",
|
||||
format!(
|
||||
"Dangerous Redis command \"{}\" is blocked. Set DBX_MCP_ALLOW_DANGEROUS_SQL=1 to allow it.",
|
||||
"Dangerous Redis command \"{}\" is disabled in DBX MCP settings.",
|
||||
argv[0].to_ascii_uppercase()
|
||||
),
|
||||
);
|
||||
|
|
@ -309,16 +356,15 @@ impl DbxMcpServer {
|
|||
if safety != RedisCommandSafety::Allowed && !permissions.allow_writes {
|
||||
return tool_error(
|
||||
"REDIS_COMMAND_BLOCKED",
|
||||
"MCP Redis command execution is read-only for this session. Set DBX_MCP_ALLOW_WRITES=1 to allow write or dangerous commands.",
|
||||
"MCP Redis command execution is read-only in DBX MCP settings.",
|
||||
);
|
||||
}
|
||||
let database = request
|
||||
.db
|
||||
.or_else(|| self.scope.database.as_deref().and_then(parse_redis_database))
|
||||
.or_else(|| redis_database(&connection))
|
||||
.unwrap_or(0);
|
||||
let database = match self.resolve_redis_database(request.db, connection) {
|
||||
Ok(database) => database,
|
||||
Err(error) => return error,
|
||||
};
|
||||
// Production protection is stricter than the opt-in write flags by design.
|
||||
if safety != RedisCommandSafety::Allowed && is_production_database(&connection, &database.to_string()) {
|
||||
if safety != RedisCommandSafety::Allowed && is_production_database(connection, &database.to_string()) {
|
||||
return tool_error(
|
||||
"PRODUCTION_WRITE_BLOCKED",
|
||||
"MCP cannot execute write or dangerous Redis commands against a production database.",
|
||||
|
|
@ -327,7 +373,7 @@ impl DbxMcpServer {
|
|||
match self
|
||||
.backend
|
||||
.execute_redis_command(
|
||||
&connection,
|
||||
connection,
|
||||
database,
|
||||
&request.command,
|
||||
safety == RedisCommandSafety::Blocked && permissions.allow_dangerous,
|
||||
|
|
@ -335,20 +381,24 @@ impl DbxMcpServer {
|
|||
.await
|
||||
{
|
||||
Ok(result) => text(format_redis_result(&result)),
|
||||
Err(error) => tool_error("REDIS_COMMAND_ERROR", error),
|
||||
Err(error) => backend_tool_error("REDIS_COMMAND_ERROR", error),
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(name = "dbx_get_schema_context", description = "Get compact table and column context for writing SQL")]
|
||||
async fn get_schema_context(&self, Parameters(request): Parameters<SchemaContextRequest>) -> CallToolResult {
|
||||
let connection = match self.resolve_connection(&request.selector).await {
|
||||
Ok(connection) => connection,
|
||||
let resolved = match self.resolve_connection(&request.selector).await {
|
||||
Ok(resolved) => resolved,
|
||||
Err(error) => return error,
|
||||
};
|
||||
let connection = &resolved.connection;
|
||||
let database = match self.resolve_database(request.database, connection) {
|
||||
Ok(database) => database,
|
||||
Err(error) => return error,
|
||||
};
|
||||
let database = self.resolve_database(request.database, &connection);
|
||||
let schema = request.schema.unwrap_or_default();
|
||||
let max_tables = request.max_tables.unwrap_or(8).clamp(1, 20);
|
||||
let available = match self.backend.list_tables(&connection, &database, &schema).await {
|
||||
let available = match self.backend.list_tables(connection, &database, &schema).await {
|
||||
Ok(tables) => tables,
|
||||
Err(error) => return tool_error("SCHEMA_CONTEXT_ERROR", error),
|
||||
};
|
||||
|
|
@ -371,7 +421,7 @@ impl DbxMcpServer {
|
|||
let mut tables = Vec::with_capacity(selected.len());
|
||||
for table in selected {
|
||||
// Keep metadata calls sequential because some embedded drivers expose a single physical connection.
|
||||
let columns = match self.backend.get_columns(&connection, &database, &schema, &table.name).await {
|
||||
let columns = match self.backend.get_columns(connection, &database, &schema, &table.name).await {
|
||||
Ok(columns) => columns,
|
||||
Err(error) => return tool_error("SCHEMA_CONTEXT_ERROR", error),
|
||||
};
|
||||
|
|
@ -382,7 +432,17 @@ impl DbxMcpServer {
|
|||
|
||||
#[tool(name = "dbx_add_connection", description = "Add a new database connection to DBX")]
|
||||
async fn add_connection(&self, Parameters(request): Parameters<AddConnectionRequest>) -> CallToolResult {
|
||||
let mut connections = match self.backend.load_connections().await {
|
||||
let policy = match self.load_policy().await {
|
||||
Ok(policy) => policy,
|
||||
Err(error) => return error,
|
||||
};
|
||||
if policy.read_only {
|
||||
return tool_error(
|
||||
"MCP_READ_ONLY",
|
||||
"DBX global MCP read-only mode is enabled. Connection management is not allowed.",
|
||||
);
|
||||
}
|
||||
let connections = match self.backend.load_connections().await {
|
||||
Ok(connections) => connections,
|
||||
Err(error) => return tool_error("CONNECTION_LOAD_ERROR", error),
|
||||
};
|
||||
|
|
@ -412,16 +472,25 @@ impl DbxMcpServer {
|
|||
Ok(config) => config,
|
||||
Err(error) => return tool_error("INVALID_CONNECTION", error),
|
||||
};
|
||||
connections.push(config.clone());
|
||||
if let Err(error) = self.backend.save_connections(&connections).await {
|
||||
return tool_error("CONNECTION_SAVE_ERROR", error);
|
||||
match self.backend.add_connection_for_mcp(config).await {
|
||||
Ok(config) => text(format!("Connection \"{}\" added (id: {}).", config.name, config.id)),
|
||||
Err(error) => backend_tool_error("CONNECTION_SAVE_ERROR", error),
|
||||
}
|
||||
text(format!("Connection \"{}\" added (id: {}).", config.name, config.id))
|
||||
}
|
||||
|
||||
#[tool(name = "dbx_remove_connection", description = "Remove a database connection from DBX")]
|
||||
async fn remove_connection(&self, Parameters(request): Parameters<RemoveConnectionRequest>) -> CallToolResult {
|
||||
let mut connections = match self.backend.load_connections().await {
|
||||
let policy = match self.load_policy().await {
|
||||
Ok(policy) => policy,
|
||||
Err(error) => return error,
|
||||
};
|
||||
if policy.read_only {
|
||||
return tool_error(
|
||||
"MCP_READ_ONLY",
|
||||
"DBX global MCP read-only mode is enabled. Connection management is not allowed.",
|
||||
);
|
||||
}
|
||||
let connections = match self.backend.load_connections().await {
|
||||
Ok(connections) => connections,
|
||||
Err(error) => return tool_error("CONNECTION_LOAD_ERROR", error),
|
||||
};
|
||||
|
|
@ -444,20 +513,24 @@ impl DbxMcpServer {
|
|||
format!("Connection \"{}\" not found.", request.connection_name),
|
||||
);
|
||||
};
|
||||
connections.retain(|connection| connection.id != target.id);
|
||||
if let Err(error) = self.backend.save_connections(&connections).await {
|
||||
return tool_error("CONNECTION_SAVE_ERROR", error);
|
||||
match self.backend.remove_connection_for_mcp(&target.id).await {
|
||||
Ok(true) => text(format!("Connection \"{}\" (id: {}) removed.", target.name, target.id)),
|
||||
Ok(false) => tool_error("CONNECTION_NOT_FOUND", format!("Connection \"{}\" not found.", target.name)),
|
||||
Err(error) => backend_tool_error("CONNECTION_SAVE_ERROR", error),
|
||||
}
|
||||
text(format!("Connection \"{}\" (id: {}) removed.", target.name, target.id))
|
||||
}
|
||||
|
||||
#[tool(name = "dbx_open_table", description = "Open a table in DBX desktop app. Requires DBX to be running.")]
|
||||
async fn open_table(&self, Parameters(request): Parameters<OpenTableRequest>) -> CallToolResult {
|
||||
let connection = match self.resolve_connection(&request.selector).await {
|
||||
Ok(connection) => connection,
|
||||
let resolved = match self.resolve_connection(&request.selector).await {
|
||||
Ok(resolved) => resolved,
|
||||
Err(error) => return error,
|
||||
};
|
||||
let connection = &resolved.connection;
|
||||
let database = match self.resolve_database(request.database, connection) {
|
||||
Ok(database) => database,
|
||||
Err(error) => return error,
|
||||
};
|
||||
let database = self.resolve_database(request.database, &connection);
|
||||
match self
|
||||
.backend
|
||||
.bridge_request(
|
||||
|
|
@ -473,7 +546,7 @@ impl DbxMcpServer {
|
|||
.await
|
||||
{
|
||||
Ok(()) => text(format!("Opened {} in DBX", request.table)),
|
||||
Err(error) => tool_error("DBX_NOT_RUNNING", error),
|
||||
Err(error) => backend_tool_error("DBX_NOT_RUNNING", error),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -482,35 +555,29 @@ impl DbxMcpServer {
|
|||
description = "Execute a SQL query in DBX desktop app UI and show results there. Requires DBX to be running."
|
||||
)]
|
||||
async fn execute_and_show(&self, Parameters(request): Parameters<ExecuteAndShowRequest>) -> CallToolResult {
|
||||
let connection = match self.resolve_connection(&request.selector).await {
|
||||
Ok(connection) => connection,
|
||||
let resolved = match self.resolve_connection(&request.selector).await {
|
||||
Ok(resolved) => resolved,
|
||||
Err(error) => return error,
|
||||
};
|
||||
let connection = &resolved.connection;
|
||||
if connection.db_type == DatabaseType::Redis {
|
||||
return tool_error("REDIS_COMMAND_REQUIRED", "Use dbx_execute_redis_command for Redis connections.");
|
||||
}
|
||||
let database = self.resolve_database(request.database, &connection);
|
||||
let permissions = default_permissions();
|
||||
if connection.db_type == DatabaseType::MongoDb {
|
||||
if let Err(error) = validate_mongo_command(&connection, &database, &request.sql) {
|
||||
return error;
|
||||
}
|
||||
let database = match self.resolve_database(request.database, connection) {
|
||||
Ok(database) => database,
|
||||
Err(error) => return error,
|
||||
};
|
||||
let permissions = if connection.db_type == DatabaseType::MongoDb {
|
||||
mcp_permissions(connection, &resolved.policy)
|
||||
} else {
|
||||
let risk = match classify_sql_risk_for_database(&request.sql, connection.db_type) {
|
||||
Ok(risk) => risk,
|
||||
Err(error) => return tool_error("SQL_BLOCKED", error),
|
||||
};
|
||||
if risk != SqlRisk::ReadOnly && targets_production_database(&connection, &database, &request.sql) {
|
||||
return tool_error(
|
||||
"PRODUCTION_WRITE_BLOCKED",
|
||||
"MCP cannot send writes against a production database to DBX.",
|
||||
);
|
||||
match validate_sql_policy(connection, &resolved.policy, &database, &request.sql) {
|
||||
Ok(permissions) => permissions,
|
||||
Err(error) => return error,
|
||||
}
|
||||
if risk == SqlRisk::Transaction || (risk == SqlRisk::Ddl && !permissions.allow_dangerous) {
|
||||
return tool_error("SQL_BLOCKED", format!("{} statement is blocked for this session.", risk));
|
||||
}
|
||||
if risk == SqlRisk::Write && !permissions.allow_writes {
|
||||
return tool_error("SQL_BLOCKED", "MCP SQL execution is read-only for this session.");
|
||||
};
|
||||
if connection.db_type == DatabaseType::MongoDb {
|
||||
if let Err(error) = validate_mongo_command(connection, &resolved.policy, &database, &request.sql) {
|
||||
return error;
|
||||
}
|
||||
}
|
||||
match self
|
||||
|
|
@ -529,32 +596,77 @@ impl DbxMcpServer {
|
|||
.await
|
||||
{
|
||||
Ok(()) => text("Query sent to DBX"),
|
||||
Err(error) => tool_error("DBX_NOT_RUNNING", error),
|
||||
Err(error) => backend_tool_error("DBX_NOT_RUNNING", error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DbxMcpServer {
|
||||
async fn load_scoped_connections(&self) -> Result<Vec<dbx_core::models::connection::ConnectionConfig>, String> {
|
||||
let policy = self.backend.load_mcp_global_policy().await?;
|
||||
let connections = self.backend.load_connections().await?;
|
||||
if !self.scope.enabled() {
|
||||
return Ok(connections);
|
||||
}
|
||||
Ok(connections.into_iter().filter(|connection| self.scope.matches(connection)).collect())
|
||||
Ok(connections
|
||||
.into_iter()
|
||||
.filter(|connection| policy_allows_connection(&policy, connection))
|
||||
.filter(|connection| !self.scope.connection_scope_enabled() || self.scope.matches(connection))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn load_policy(&self) -> Result<McpGlobalPolicy, CallToolResult> {
|
||||
self.backend.load_mcp_global_policy().await.map_err(|error| backend_tool_error("MCP_POLICY_UNAVAILABLE", error))
|
||||
}
|
||||
|
||||
// CallToolResult is the rmcp wire response type; keeping it unboxed avoids conversions at every tool boundary.
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn resolve_database(
|
||||
&self,
|
||||
requested: Option<String>,
|
||||
connection: &dbx_core::models::connection::ConnectionConfig,
|
||||
) -> String {
|
||||
requested.or_else(|| self.scope.database.clone()).or_else(|| connection.database.clone()).unwrap_or_default()
|
||||
) -> Result<String, CallToolResult> {
|
||||
let requested = requested.map(|database| database.trim().to_string()).filter(|database| !database.is_empty());
|
||||
if let Some(scoped) = self.scope.database.as_deref() {
|
||||
if let Some(requested) = requested.as_deref() {
|
||||
if requested != scoped {
|
||||
return Err(tool_error(
|
||||
"DATABASE_OUT_OF_SCOPE",
|
||||
format!("Database \"{requested}\" is outside the scoped database \"{scoped}\"."),
|
||||
));
|
||||
}
|
||||
}
|
||||
return Ok(scoped.to_string());
|
||||
}
|
||||
Ok(requested.or_else(|| connection.database.clone()).unwrap_or_default())
|
||||
}
|
||||
|
||||
async fn resolve_connection(
|
||||
// CallToolResult is the rmcp wire response type; keeping it unboxed avoids conversions at every tool boundary.
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn resolve_redis_database(
|
||||
&self,
|
||||
selector: &ConnectionSelector,
|
||||
) -> Result<dbx_core::models::connection::ConnectionConfig, CallToolResult> {
|
||||
requested: Option<u32>,
|
||||
connection: &dbx_core::models::connection::ConnectionConfig,
|
||||
) -> Result<u32, CallToolResult> {
|
||||
if let Some(scoped) = self.scope.database.as_deref() {
|
||||
let scoped_database = parse_redis_database(scoped).ok_or_else(|| {
|
||||
tool_error(
|
||||
"INVALID_DATABASE_SCOPE",
|
||||
format!("Redis database scope \"{scoped}\" must be a non-negative integer."),
|
||||
)
|
||||
})?;
|
||||
if let Some(requested) = requested {
|
||||
if requested != scoped_database {
|
||||
return Err(tool_error(
|
||||
"DATABASE_OUT_OF_SCOPE",
|
||||
format!("Redis database {requested} is outside the scoped database {scoped_database}."),
|
||||
));
|
||||
}
|
||||
}
|
||||
return Ok(scoped_database);
|
||||
}
|
||||
Ok(requested.or_else(|| redis_database(connection)).unwrap_or(0))
|
||||
}
|
||||
|
||||
async fn resolve_connection(&self, selector: &ConnectionSelector) -> Result<ResolvedConnection, CallToolResult> {
|
||||
let policy = self.load_policy().await?;
|
||||
let connections =
|
||||
self.backend.load_connections().await.map_err(|error| tool_error("CONNECTION_LOAD_ERROR", error))?;
|
||||
if let Some(id) = selector.connection_id.as_deref().map(str::trim).filter(|id| !id.is_empty()) {
|
||||
|
|
@ -562,15 +674,21 @@ impl DbxMcpServer {
|
|||
.into_iter()
|
||||
.find(|connection| connection.id == id)
|
||||
.ok_or_else(|| tool_error("CONNECTION_NOT_FOUND", format!("Connection with id \"{id}\" not found.")))?;
|
||||
if self.scope.enabled() && !self.scope.matches(&connection) {
|
||||
if self.scope.connection_scope_enabled() && !self.scope.matches(&connection) {
|
||||
return Err(tool_error(
|
||||
"CONNECTION_OUT_OF_SCOPE",
|
||||
format!("Connection \"{id}\" is outside this DBX AI session scope."),
|
||||
));
|
||||
}
|
||||
return Ok(connection);
|
||||
if !policy_allows_connection(&policy, &connection) {
|
||||
return Err(tool_error(
|
||||
"CONNECTION_OUT_OF_SCOPE",
|
||||
format!("Connection \"{id}\" is not allowed by DBX MCP settings."),
|
||||
));
|
||||
}
|
||||
return Ok(ResolvedConnection { connection, policy });
|
||||
}
|
||||
if self.scope.enabled() {
|
||||
if self.scope.connection_scope_enabled() {
|
||||
let connection = connections
|
||||
.into_iter()
|
||||
.find(|connection| self.scope.matches(connection))
|
||||
|
|
@ -583,17 +701,34 @@ impl DbxMcpServer {
|
|||
));
|
||||
}
|
||||
}
|
||||
return Ok(connection);
|
||||
if !policy_allows_connection(&policy, &connection) {
|
||||
return Err(tool_error(
|
||||
"CONNECTION_OUT_OF_SCOPE",
|
||||
"The DBX AI session scope is outside the global MCP connection allowlist.",
|
||||
));
|
||||
}
|
||||
return Ok(ResolvedConnection { connection, policy });
|
||||
}
|
||||
let Some(name) = selector.connection_name.as_deref().map(str::trim).filter(|name| !name.is_empty()) else {
|
||||
return Err(tool_error("CONNECTION_NOT_FOUND", "Either connection_id or connection_name is required."));
|
||||
};
|
||||
let matching =
|
||||
connections.into_iter().filter(|connection| connection.name.eq_ignore_ascii_case(name)).collect::<Vec<_>>();
|
||||
match matching.as_slice() {
|
||||
[] => Err(tool_error("CONNECTION_NOT_FOUND", format!("Connection \"{name}\" not found."))),
|
||||
[connection] => Ok(connection.clone()),
|
||||
_ => Err(tool_error("AMBIGUOUS_CONNECTION", ambiguous_connections(name, &matching))),
|
||||
let allowed = matching
|
||||
.iter()
|
||||
.filter(|connection| policy_allows_connection(&policy, connection))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
match allowed.as_slice() {
|
||||
[] if matching.is_empty() => {
|
||||
Err(tool_error("CONNECTION_NOT_FOUND", format!("Connection \"{name}\" not found.")))
|
||||
}
|
||||
[] => Err(tool_error(
|
||||
"CONNECTION_OUT_OF_SCOPE",
|
||||
format!("Connection \"{name}\" is not allowed by DBX MCP settings."),
|
||||
)),
|
||||
[connection] => Ok(ResolvedConnection { connection: connection.clone(), policy }),
|
||||
_ => Err(tool_error("AMBIGUOUS_CONNECTION", ambiguous_connections(name, &allowed))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -615,25 +750,93 @@ fn tool_error(code: &str, message: impl Into<String>) -> CallToolResult {
|
|||
CallToolResult::error(vec![ContentBlock::text(format!("Error [{code}]: {}", message.into()))])
|
||||
}
|
||||
|
||||
fn backend_tool_error(default_code: &str, error: impl Into<String>) -> CallToolResult {
|
||||
let error = error.into();
|
||||
for code in [
|
||||
"MCP_POLICY_UNAVAILABLE",
|
||||
"MCP_READ_ONLY",
|
||||
"CONNECTION_OUT_OF_SCOPE",
|
||||
"DATABASE_OUT_OF_SCOPE",
|
||||
"INVALID_DATABASE_SCOPE",
|
||||
"CONNECTION_READ_ONLY",
|
||||
"PRODUCTION_DATABASE_READ_ONLY",
|
||||
"PRODUCTION_WRITE_BLOCKED",
|
||||
"SQL_BLOCKED",
|
||||
] {
|
||||
let marker = format!("{code}:");
|
||||
if let Some(index) = error.find(&marker) {
|
||||
return tool_error(code, error[index + marker.len()..].trim());
|
||||
}
|
||||
}
|
||||
tool_error(default_code, error)
|
||||
}
|
||||
|
||||
fn agent_result(result: dbx_core::agent_events::ToolResult) -> CallToolResult {
|
||||
if result.is_error {
|
||||
tool_error("DBX_TOOL_ERROR", result.content.trim_start_matches("Error: "))
|
||||
backend_tool_error("DBX_TOOL_ERROR", result.content.trim_start_matches("Error: "))
|
||||
} else {
|
||||
text(result.content)
|
||||
}
|
||||
}
|
||||
|
||||
fn default_permissions() -> dbx_core::agent_tools::AgentSqlPermissions {
|
||||
fn policy_allows_connection(
|
||||
policy: &McpGlobalPolicy,
|
||||
connection: &dbx_core::models::connection::ConnectionConfig,
|
||||
) -> bool {
|
||||
policy.allowed_connection_ids.as_ref().is_none_or(|allowed| allowed.iter().any(|id| id == &connection.id))
|
||||
}
|
||||
|
||||
fn mcp_permissions(
|
||||
connection: &dbx_core::models::connection::ConnectionConfig,
|
||||
policy: &McpGlobalPolicy,
|
||||
) -> dbx_core::agent_tools::AgentSqlPermissions {
|
||||
dbx_core::agent_tools::AgentSqlPermissions {
|
||||
allow_writes: boolean_env("DBX_MCP_ALLOW_WRITES").unwrap_or(true),
|
||||
allow_dangerous: boolean_env("DBX_MCP_ALLOW_DANGEROUS_SQL").unwrap_or(false),
|
||||
allow_writes: !policy.read_only && !connection.read_only,
|
||||
allow_dangerous: !policy.read_only && !connection.read_only && policy.allow_dangerous_sql,
|
||||
}
|
||||
}
|
||||
|
||||
// CallToolResult is the transport-native error payload; boxing it would complicate every MCP call site.
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn validate_sql_policy(
|
||||
connection: &dbx_core::models::connection::ConnectionConfig,
|
||||
policy: &McpGlobalPolicy,
|
||||
database: &str,
|
||||
sql: &str,
|
||||
) -> Result<dbx_core::agent_tools::AgentSqlPermissions, CallToolResult> {
|
||||
if mcp_sql_has_forbidden_database_switch(sql, connection.db_type) {
|
||||
return Err(tool_error("SQL_BLOCKED", "MCP does not allow USE or persistent database switching."));
|
||||
}
|
||||
let risk =
|
||||
classify_sql_risk_for_database(sql, connection.db_type).map_err(|error| tool_error("SQL_BLOCKED", error))?;
|
||||
if risk == SqlRisk::Transaction {
|
||||
return Err(tool_error("SQL_BLOCKED", "Transaction statements are not supported by MCP."));
|
||||
}
|
||||
let is_write = is_write_sql_for_database(sql, connection.db_type);
|
||||
if policy.read_only && is_write {
|
||||
return Err(tool_error("MCP_READ_ONLY", "DBX global MCP read-only mode is enabled. SQL write blocked."));
|
||||
}
|
||||
if connection.read_only && is_write {
|
||||
return Err(tool_error(
|
||||
"CONNECTION_READ_ONLY",
|
||||
format!("Connection \"{}\" has read-only protection enabled. SQL write blocked.", connection.name),
|
||||
));
|
||||
}
|
||||
let high_risk = risk == SqlRisk::Ddl || is_dangerous_sql_for_database(sql, connection.db_type);
|
||||
if high_risk && !policy.allow_dangerous_sql {
|
||||
return Err(tool_error("SQL_BLOCKED", "High-risk SQL is disabled in DBX MCP settings."));
|
||||
}
|
||||
if is_write && targets_production_database(connection, database, sql) {
|
||||
return Err(tool_error("PRODUCTION_WRITE_BLOCKED", "MCP cannot execute writes against a production database."));
|
||||
}
|
||||
Ok(mcp_permissions(connection, policy))
|
||||
}
|
||||
|
||||
// CallToolResult is the transport-native error payload; boxing it would complicate every MCP call site.
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn validate_mongo_command(
|
||||
connection: &dbx_core::models::connection::ConnectionConfig,
|
||||
policy: &McpGlobalPolicy,
|
||||
database: &str,
|
||||
source: &str,
|
||||
) -> Result<MongoCommand, CallToolResult> {
|
||||
|
|
@ -645,25 +848,28 @@ fn validate_mongo_command(
|
|||
),
|
||||
)
|
||||
})?;
|
||||
let permissions = default_permissions();
|
||||
if let Err(error) = mongo::validate_safety(
|
||||
&command,
|
||||
permissions.allow_writes,
|
||||
permissions.allow_dangerous,
|
||||
is_production_database(connection, database),
|
||||
) {
|
||||
let permissions = mcp_permissions(connection, policy);
|
||||
let production_database = match &command {
|
||||
MongoCommand::Aggregate { pipeline, .. } => {
|
||||
mongo_pipeline_targets_production_database(connection, database, pipeline)
|
||||
}
|
||||
_ => is_production_database(connection, database),
|
||||
};
|
||||
if let Err(error) =
|
||||
mongo::validate_safety(&command, permissions.allow_writes, permissions.allow_dangerous, production_database)
|
||||
{
|
||||
return Err(match error {
|
||||
MongoSafetyError::WritesDisabled => tool_error(
|
||||
"SQL_BLOCKED",
|
||||
"MCP MongoDB execution is read-only for this session. Set DBX_MCP_ALLOW_WRITES=1 to allow write commands.",
|
||||
if policy.read_only { "MCP_READ_ONLY" } else { "CONNECTION_READ_ONLY" },
|
||||
"MCP MongoDB execution is read-only in DBX MCP settings.",
|
||||
),
|
||||
MongoSafetyError::EmptyFilter => tool_error(
|
||||
"SQL_BLOCKED",
|
||||
"MongoDB update/delete commands must include a non-empty filter unless DBX_MCP_ALLOW_DANGEROUS_SQL=1 is set.",
|
||||
"MongoDB update/delete commands must include a non-empty filter unless high-risk operations are enabled in DBX MCP settings.",
|
||||
),
|
||||
MongoSafetyError::Dangerous => tool_error(
|
||||
"SQL_BLOCKED",
|
||||
"Dangerous MongoDB command is blocked. Set DBX_MCP_ALLOW_DANGEROUS_SQL=1 to allow it.",
|
||||
"Dangerous MongoDB command is disabled in DBX MCP settings.",
|
||||
),
|
||||
MongoSafetyError::ProductionWrite => {
|
||||
tool_error("PRODUCTION_WRITE_BLOCKED", "MCP cannot execute writes against a production database.")
|
||||
|
|
@ -673,18 +879,20 @@ fn validate_mongo_command(
|
|||
Ok(command)
|
||||
}
|
||||
|
||||
fn boolean_env(name: &str) -> Option<bool> {
|
||||
match std::env::var(name).ok()?.trim().to_ascii_lowercase().as_str() {
|
||||
"1" | "true" => Some(true),
|
||||
"0" | "false" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn non_empty_env(name: &str) -> Option<String> {
|
||||
std::env::var(name).ok().map(|value| value.trim().to_string()).filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn scoped_connection_ids(value: Option<&str>) -> Vec<String> {
|
||||
let mut ids = Vec::new();
|
||||
for id in value.unwrap_or_default().split(',').map(str::trim).filter(|id| !id.is_empty()) {
|
||||
if !ids.iter().any(|existing| existing == id) {
|
||||
ids.push(id.to_string());
|
||||
}
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
fn default_port(db_type: &str) -> Option<u16> {
|
||||
match db_type.trim().to_ascii_lowercase().as_str() {
|
||||
"mysql" | "doris" | "starrocks" | "manticoresearch" => Some(3306),
|
||||
|
|
@ -822,8 +1030,31 @@ mod tests {
|
|||
connections: Vec<ConnectionConfig>,
|
||||
}
|
||||
|
||||
fn connection(id: &str, name: &str, db_type: &str, database: &str) -> ConnectionConfig {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"id": id,
|
||||
"name": name,
|
||||
"db_type": db_type,
|
||||
"host": "",
|
||||
"port": 0,
|
||||
"username": "",
|
||||
"password": "",
|
||||
"database": database,
|
||||
"ssl": false
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn result_text(result: &CallToolResult) -> &str {
|
||||
result.content[0].as_text().expect("text tool result").text.as_str()
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DbxBackend for FakeBackend {
|
||||
async fn load_mcp_global_policy(&self) -> Result<McpGlobalPolicy, String> {
|
||||
Ok(McpGlobalPolicy::default())
|
||||
}
|
||||
|
||||
async fn load_connections(&self) -> Result<Vec<ConnectionConfig>, String> {
|
||||
Ok(self.connections.clone())
|
||||
}
|
||||
|
|
@ -845,8 +1076,12 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
async fn save_connections(&self, _connections: &[ConnectionConfig]) -> Result<(), String> {
|
||||
Ok(())
|
||||
async fn add_connection_for_mcp(&self, config: ConnectionConfig) -> Result<ConnectionConfig, String> {
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
async fn remove_connection_for_mcp(&self, _connection_id: &str) -> Result<bool, String> {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -890,7 +1125,7 @@ mod tests {
|
|||
fn scoped_server_hides_mutating_and_desktop_tools() {
|
||||
let server = DbxMcpServer::with_runtime_options(
|
||||
Arc::new(FakeBackend { connections: Vec::new() }),
|
||||
McpScope { connection_id: Some("scoped".to_string()), ..Default::default() },
|
||||
McpScope { connection_ids: vec!["scoped".to_string()], ..Default::default() },
|
||||
false,
|
||||
);
|
||||
let names = server.tool_router.list_all().into_iter().map(|tool| tool.name).collect::<Vec<_>>();
|
||||
|
|
@ -900,4 +1135,93 @@ mod tests {
|
|||
assert!(!names.iter().any(|name| name == "dbx_open_table"));
|
||||
assert!(!names.iter().any(|name| name == "dbx_execute_and_show"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scoped_connection_ids_are_deduplicated_and_take_precedence_over_name() {
|
||||
assert_eq!(scoped_connection_ids(Some(" first, second,first ,, ")), vec!["first", "second"]);
|
||||
|
||||
let first = connection("first", "other", "sqlite", ":memory:");
|
||||
let named = ConnectionConfig { id: "named".to_string(), name: "scope-name".to_string(), ..first.clone() };
|
||||
let scope = McpScope {
|
||||
connection_ids: vec!["first".to_string()],
|
||||
connection_name: Some("scope-name".to_string()),
|
||||
database: None,
|
||||
};
|
||||
|
||||
assert!(scope.matches(&first));
|
||||
assert!(!scope.matches(&named));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn database_scope_is_a_hard_bound_without_filtering_connections() {
|
||||
let scoped = connection("scoped", "scoped", "postgres", "configured");
|
||||
let server = DbxMcpServer::with_runtime_options(
|
||||
Arc::new(FakeBackend { connections: vec![scoped.clone()] }),
|
||||
McpScope { database: Some("analytics".to_string()), ..Default::default() },
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(server.load_scoped_connections().await.unwrap().len(), 1);
|
||||
assert_eq!(server.resolve_database(None, &scoped).unwrap(), "analytics");
|
||||
assert_eq!(server.resolve_database(Some("analytics".to_string()), &scoped).unwrap(), "analytics");
|
||||
let error = server.resolve_database(Some("production".to_string()), &scoped).unwrap_err();
|
||||
assert!(result_text(&error).contains("DATABASE_OUT_OF_SCOPE"));
|
||||
|
||||
let names = server.tool_router.list_all().into_iter().map(|tool| tool.name).collect::<Vec<_>>();
|
||||
assert!(!names.iter().any(|name| name == "dbx_add_connection"));
|
||||
assert!(!names.iter().any(|name| name == "dbx_execute_and_show"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redis_database_scope_fails_closed_and_cannot_be_overridden() {
|
||||
let redis = connection("redis", "redis", "redis", "1");
|
||||
let scoped = DbxMcpServer::with_runtime_options(
|
||||
Arc::new(FakeBackend { connections: vec![redis.clone()] }),
|
||||
McpScope { database: Some("2".to_string()), ..Default::default() },
|
||||
false,
|
||||
);
|
||||
assert_eq!(scoped.resolve_redis_database(None, &redis).unwrap(), 2);
|
||||
let error = scoped.resolve_redis_database(Some(3), &redis).unwrap_err();
|
||||
assert!(result_text(&error).contains("DATABASE_OUT_OF_SCOPE"));
|
||||
|
||||
let invalid = DbxMcpServer::with_runtime_options(
|
||||
Arc::new(FakeBackend { connections: vec![redis.clone()] }),
|
||||
McpScope { database: Some("analytics".to_string()), ..Default::default() },
|
||||
false,
|
||||
);
|
||||
let error = invalid.resolve_redis_database(None, &redis).unwrap_err();
|
||||
assert!(result_text(&error).contains("INVALID_DATABASE_SCOPE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_mongo_aggregate_cannot_write_to_a_production_database() {
|
||||
let mut mongo = connection("mongo", "mongo", "mongodb", "staging");
|
||||
mongo.production_databases = vec!["production".to_string()];
|
||||
let policy = McpGlobalPolicy { read_only: false, allow_dangerous_sql: true, allowed_connection_ids: None };
|
||||
|
||||
let error = validate_mongo_command(
|
||||
&mongo,
|
||||
&policy,
|
||||
"staging",
|
||||
r#"db.items.aggregate([{"$out":{"db":"production","coll":"archive"}}])"#,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(result_text(&error).contains("PRODUCTION_WRITE_BLOCKED"));
|
||||
|
||||
assert!(
|
||||
validate_mongo_command(&mongo, &policy, "staging", r#"db.items.aggregate([{"$out":"archive"}])"#,).is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_results_preserve_stable_backend_policy_errors() {
|
||||
let result = agent_result(dbx_core::agent_events::ToolResult {
|
||||
tool_call_id: "test".to_string(),
|
||||
tool_name: "execute_query".to_string(),
|
||||
content: "Error: API request failed: MCP_READ_ONLY: policy changed".to_string(),
|
||||
is_error: true,
|
||||
explain_data: None,
|
||||
});
|
||||
assert!(result_text(&result).contains("Error [MCP_READ_ONLY]: policy changed"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use std::sync::Arc;
|
||||
use std::{ffi::OsString, sync::Arc};
|
||||
|
||||
use dbx_core::{models::connection::ConnectionConfig, storage::Storage};
|
||||
use dbx_mcp::{DbxMcpServer, LocalBackend, McpScope};
|
||||
|
|
@ -6,6 +6,29 @@ use rmcp::{model::CallToolRequestParams, ServiceExt};
|
|||
use serde_json::{json, Map, Value};
|
||||
use tempfile::tempdir;
|
||||
|
||||
struct EnvVarGuard {
|
||||
name: &'static str,
|
||||
original: Option<OsString>,
|
||||
}
|
||||
|
||||
impl EnvVarGuard {
|
||||
fn set(name: &'static str, value: &str) -> Self {
|
||||
let original = std::env::var_os(name);
|
||||
std::env::set_var(name, value);
|
||||
Self { name, original }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvVarGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(value) = self.original.take() {
|
||||
std::env::set_var(self.name, value);
|
||||
} else {
|
||||
std::env::remove_var(self.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_backend_reads_dbx_storage_without_desktop_process() {
|
||||
let directory = tempdir().expect("temporary data directory");
|
||||
|
|
@ -42,6 +65,51 @@ async fn local_backend_reads_dbx_storage_without_desktop_process() {
|
|||
server_task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_read_only_config_applies_before_settings_are_opened() {
|
||||
let _allow_writes = EnvVarGuard::set("DBX_MCP_ALLOW_WRITES", "0");
|
||||
let directory = tempdir().expect("temporary data directory");
|
||||
let db_path = directory.path().join("dbx.db");
|
||||
let storage = Storage::open(&db_path).await.expect("open storage");
|
||||
assert!(!storage.load_mcp_global_policy().await.expect("load MCP policy").configured);
|
||||
let connection: ConnectionConfig = serde_json::from_value(json!({
|
||||
"id": "legacy-read-only",
|
||||
"name": "legacy-read-only",
|
||||
"db_type": "sqlite",
|
||||
"host": "",
|
||||
"port": 0,
|
||||
"username": "",
|
||||
"password": "",
|
||||
"database": directory.path().join("legacy.sqlite").to_string_lossy(),
|
||||
"ssl": false
|
||||
}))
|
||||
.expect("minimal connection config");
|
||||
storage.save_connections(&[connection]).await.expect("save connection");
|
||||
|
||||
let backend = Arc::new(LocalBackend::open(&db_path).await.expect("open local backend"));
|
||||
let server = DbxMcpServer::with_runtime_options(backend, McpScope::default(), false);
|
||||
let (server_transport, client_transport) = tokio::io::duplex(16 * 1024);
|
||||
let server_task = tokio::spawn(async move { server.serve(server_transport).await });
|
||||
let client = ().serve(client_transport).await.expect("initialize client");
|
||||
let arguments = json!({
|
||||
"connection_id": "legacy-read-only",
|
||||
"sql": "INSERT INTO items (name) VALUES ('blocked')",
|
||||
})
|
||||
.as_object()
|
||||
.cloned()
|
||||
.unwrap_or_else(Map::<String, Value>::new);
|
||||
let result = client
|
||||
.peer()
|
||||
.call_tool(CallToolRequestParams::new("dbx_execute_query").with_arguments(arguments))
|
||||
.await
|
||||
.expect("execute query");
|
||||
let text = result.content[0].as_text().expect("text result");
|
||||
assert_eq!(result.is_error, Some(true));
|
||||
assert!(text.text.contains("MCP_READ_ONLY"), "unexpected MCP response: {}", text.text);
|
||||
client.cancel().await.expect("close client");
|
||||
server_task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires DBX_MCP_TEST_MONGO_HOST and DBX_MCP_TEST_MONGO_PASSWORD"]
|
||||
async fn executes_mongo_shell_commands_without_desktop_process() {
|
||||
|
|
@ -74,8 +142,6 @@ async fn executes_mongo_shell_commands_without_desktop_process() {
|
|||
let (server_transport, client_transport) = tokio::io::duplex(32 * 1024);
|
||||
let server_task = tokio::spawn(async move { server.serve(server_transport).await });
|
||||
let client = ().serve(client_transport).await.expect("initialize client");
|
||||
let original_writes = std::env::var_os("DBX_MCP_ALLOW_WRITES");
|
||||
std::env::set_var("DBX_MCP_ALLOW_WRITES", "1");
|
||||
|
||||
call_query(&client, "db.items.deleteOne({_id: 'rust-mcp-e2e'})").await;
|
||||
call_query(&client, "db.items.insert({_id: 'rust-mcp-e2e', name: 'Ada'})").await;
|
||||
|
|
@ -83,10 +149,6 @@ async fn executes_mongo_shell_commands_without_desktop_process() {
|
|||
assert!(result.contains("Ada"), "unexpected MongoDB result: {result}");
|
||||
call_query(&client, "db.items.deleteOne({_id: 'rust-mcp-e2e'})").await;
|
||||
|
||||
match original_writes {
|
||||
Some(value) => std::env::set_var("DBX_MCP_ALLOW_WRITES", value),
|
||||
None => std::env::remove_var("DBX_MCP_ALLOW_WRITES"),
|
||||
}
|
||||
client.cancel().await.expect("close client");
|
||||
server_task.abort();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,22 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use dbx_core::{agent_events::ToolResult, agent_tools::AgentSqlPermissions, models::connection::ConnectionConfig};
|
||||
use dbx_core::{
|
||||
agent_events::ToolResult, agent_tools::AgentSqlPermissions, models::connection::ConnectionConfig,
|
||||
storage::McpGlobalPolicy,
|
||||
};
|
||||
use dbx_mcp::{DbxBackend, DbxMcpServer, McpScope};
|
||||
use rmcp::{model::CallToolRequestParams, ServiceExt};
|
||||
use serde_json::Value;
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
struct EmptyBackend;
|
||||
|
||||
#[async_trait]
|
||||
impl DbxBackend for EmptyBackend {
|
||||
async fn load_mcp_global_policy(&self) -> Result<McpGlobalPolicy, String> {
|
||||
Ok(McpGlobalPolicy::default())
|
||||
}
|
||||
|
||||
async fn load_connections(&self) -> Result<Vec<ConnectionConfig>, String> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
|
@ -31,9 +38,69 @@ impl DbxBackend for EmptyBackend {
|
|||
}
|
||||
}
|
||||
|
||||
async fn save_connections(&self, _connections: &[ConnectionConfig]) -> Result<(), String> {
|
||||
Ok(())
|
||||
async fn add_connection_for_mcp(&self, config: ConnectionConfig) -> Result<ConnectionConfig, String> {
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
async fn remove_connection_for_mcp(&self, _connection_id: &str) -> Result<bool, String> {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
struct PolicyBackend {
|
||||
policy: McpGlobalPolicy,
|
||||
connections: Vec<ConnectionConfig>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DbxBackend for PolicyBackend {
|
||||
async fn load_mcp_global_policy(&self) -> Result<McpGlobalPolicy, String> {
|
||||
Ok(self.policy.clone())
|
||||
}
|
||||
|
||||
async fn load_connections(&self) -> Result<Vec<ConnectionConfig>, String> {
|
||||
Ok(self.connections.clone())
|
||||
}
|
||||
|
||||
async fn execute_agent_tool(
|
||||
&self,
|
||||
_connection: &ConnectionConfig,
|
||||
_database: &str,
|
||||
tool_name: &str,
|
||||
_arguments: Value,
|
||||
_permissions: AgentSqlPermissions,
|
||||
) -> ToolResult {
|
||||
ToolResult {
|
||||
tool_call_id: "policy-test".to_string(),
|
||||
tool_name: tool_name.to_string(),
|
||||
content: "query should have been blocked".to_string(),
|
||||
is_error: true,
|
||||
explain_data: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn add_connection_for_mcp(&self, config: ConnectionConfig) -> Result<ConnectionConfig, String> {
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
async fn remove_connection_for_mcp(&self, _connection_id: &str) -> Result<bool, String> {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
fn test_connection(id: &str, name: &str) -> ConnectionConfig {
|
||||
serde_json::from_value(json!({
|
||||
"id": id,
|
||||
"name": name,
|
||||
"db_type": "sqlite",
|
||||
"host": "",
|
||||
"port": 0,
|
||||
"username": "",
|
||||
"password": "",
|
||||
"database": ":memory:",
|
||||
"ssl": false
|
||||
}))
|
||||
.expect("test connection")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -57,3 +124,53 @@ async fn initializes_lists_tools_and_calls_a_tool() {
|
|||
client.cancel().await.expect("close MCP client");
|
||||
server_task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enforces_global_connection_scope_and_read_only_policy() {
|
||||
let backend = PolicyBackend {
|
||||
policy: McpGlobalPolicy {
|
||||
read_only: true,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: Some(vec!["allowed".to_string()]),
|
||||
},
|
||||
connections: vec![test_connection("allowed", "allowed-db"), test_connection("blocked", "blocked-db")],
|
||||
};
|
||||
let (server_transport, client_transport) = tokio::io::duplex(16 * 1024);
|
||||
let server = DbxMcpServer::with_runtime_options(Arc::new(backend), McpScope::default(), false);
|
||||
let server_task = tokio::spawn(async move { server.serve(server_transport).await });
|
||||
let client = ().serve(client_transport).await.expect("initialize MCP client");
|
||||
|
||||
let listed =
|
||||
client.peer().call_tool(CallToolRequestParams::new("dbx_list_connections")).await.expect("list connections");
|
||||
let listed_text = listed.content[0].as_text().expect("list result").text.clone();
|
||||
assert!(listed_text.contains("allowed-db"));
|
||||
assert!(!listed_text.contains("blocked-db"));
|
||||
|
||||
let blocked = client
|
||||
.peer()
|
||||
.call_tool(CallToolRequestParams::new("dbx_execute_query").with_arguments(
|
||||
json!({ "connection_id": "blocked", "sql": "SELECT 1" }).as_object().cloned().unwrap_or_else(Map::new),
|
||||
))
|
||||
.await
|
||||
.expect("call blocked connection");
|
||||
assert_eq!(blocked.is_error, Some(true));
|
||||
assert!(blocked.content[0].as_text().expect("blocked result").text.contains("CONNECTION_OUT_OF_SCOPE"));
|
||||
|
||||
let read_only = client
|
||||
.peer()
|
||||
.call_tool(
|
||||
CallToolRequestParams::new("dbx_execute_query").with_arguments(
|
||||
json!({ "connection_id": "allowed", "sql": "DELETE FROM users" })
|
||||
.as_object()
|
||||
.cloned()
|
||||
.unwrap_or_else(Map::new),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("call read-only policy");
|
||||
assert_eq!(read_only.is_error, Some(true));
|
||||
assert!(read_only.content[0].as_text().expect("read-only result").text.contains("MCP_READ_ONLY"));
|
||||
|
||||
client.cancel().await.expect("close MCP client");
|
||||
server_task.abort();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -198,6 +198,8 @@ async fn main() {
|
|||
.route("/connection/close-database", post(routes::connection::close_database_connection))
|
||||
.route("/connection/save", post(routes::connection::save_connections))
|
||||
.route("/connection/list", get(routes::connection::load_connections))
|
||||
.route("/connection/mcp/add", post(routes::connection::mcp_add_connection))
|
||||
.route("/connection/mcp/remove", post(routes::connection::mcp_remove_connection))
|
||||
.route("/plugins", get(routes::plugins::list_plugins))
|
||||
// JDBC
|
||||
.route("/jdbc/drivers", get(routes::jdbc::list_jdbc_drivers).post(routes::jdbc::import_jdbc_drivers))
|
||||
|
|
@ -550,6 +552,10 @@ async fn main() {
|
|||
"/app-settings/pinned-tree-node-ids",
|
||||
get(routes::app_settings::load_pinned_tree_node_ids).post(routes::app_settings::save_pinned_tree_node_ids),
|
||||
)
|
||||
.route(
|
||||
"/app-settings/mcp-policy",
|
||||
get(routes::app_settings::load_mcp_global_policy).put(routes::app_settings::save_mcp_global_policy),
|
||||
)
|
||||
.route("/app-settings/config/decrypt", post(routes::app_settings::decrypt_config))
|
||||
// Cloud sync
|
||||
.route("/cloud-sync/webdav/test", post(routes::cloud_sync::webdav_sync_test))
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
|
|||
use axum::extract::State;
|
||||
use axum::Json;
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
||||
use dbx_core::storage::{McpGlobalPolicy, McpGlobalPolicyState};
|
||||
use pbkdf2::pbkdf2_hmac;
|
||||
use serde::Deserialize;
|
||||
use sha2::Sha256;
|
||||
|
|
@ -50,6 +51,20 @@ pub async fn save_pinned_tree_node_ids(
|
|||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn load_mcp_global_policy(
|
||||
State(state): State<Arc<WebState>>,
|
||||
) -> Result<Json<McpGlobalPolicyState>, AppError> {
|
||||
state.app.storage.load_mcp_global_policy().await.map(Json).map_err(AppError)
|
||||
}
|
||||
|
||||
pub async fn save_mcp_global_policy(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(policy): Json<McpGlobalPolicy>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
state.app.storage.save_mcp_global_policy(&policy).await.map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn decrypt_config(Json(body): Json<DecryptConfigRequest>) -> Result<Json<String>, AppError> {
|
||||
decrypt_config_payload(&body.payload, &body.passphrase).map(Json).map_err(AppError)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,18 @@ pub struct SaveConnectionsRequest {
|
|||
pub configs: Vec<ConnectionConfig>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct McpAddConnectionRequest {
|
||||
pub config: ConnectionConfig,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct McpRemoveConnectionRequest {
|
||||
pub connection_id: String,
|
||||
}
|
||||
|
||||
fn is_connection_info_capability_unsupported(error: &str) -> bool {
|
||||
let error = error.to_ascii_lowercase();
|
||||
error.contains("connectioninfo")
|
||||
|
|
@ -259,6 +271,31 @@ pub async fn save_connections(
|
|||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn mcp_add_connection(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(body): Json<McpAddConnectionRequest>,
|
||||
) -> Result<Json<ConnectionConfig>, AppError> {
|
||||
let saved = state.app.storage.add_connection_for_mcp(body.config).await.map_err(AppError)?;
|
||||
state.app.configs.write().await.insert(saved.id.clone(), saved.clone());
|
||||
Ok(Json(saved))
|
||||
}
|
||||
|
||||
pub async fn mcp_remove_connection(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(body): Json<McpRemoveConnectionRequest>,
|
||||
) -> Result<Json<bool>, AppError> {
|
||||
let connection_id = body.connection_id;
|
||||
let removed = state.app.storage.remove_connection_for_mcp(&connection_id).await.map_err(AppError)?;
|
||||
if removed {
|
||||
state.app.configs.write().await.remove(&connection_id);
|
||||
state.app.remove_connection_pools_detached(&connection_id).await;
|
||||
state.app.nacos_registry.drop_connection(&connection_id).await;
|
||||
#[cfg(feature = "mq-admin")]
|
||||
state.app.mq_registry.drop_connection(&connection_id).await;
|
||||
}
|
||||
Ok(Json(removed))
|
||||
}
|
||||
|
||||
pub async fn load_connections(State(state): State<Arc<WebState>>) -> Result<Json<Vec<ConnectionConfig>>, AppError> {
|
||||
let configs = state.app.storage.load_connections().await.map_err(AppError)?;
|
||||
let sync = sync_connection_configs(&state, &configs).await;
|
||||
|
|
@ -349,9 +386,10 @@ async fn remove_connection_pools_for_connection_ids(state: &WebState, connection
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
connect_db, connection_final_proxy_port, disconnect_db, load_connections, save_connection_database_info,
|
||||
save_connections, test_connection, test_connection_with_info, ConnectRequest, DisconnectRequest,
|
||||
SaveConnectionDatabaseInfoRequest, SaveConnectionsRequest,
|
||||
connect_db, connection_final_proxy_port, disconnect_db, load_connections, mcp_add_connection,
|
||||
mcp_remove_connection, save_connection_database_info, save_connections, test_connection,
|
||||
test_connection_with_info, ConnectRequest, DisconnectRequest, McpAddConnectionRequest,
|
||||
McpRemoveConnectionRequest, SaveConnectionDatabaseInfoRequest, SaveConnectionsRequest,
|
||||
};
|
||||
use crate::state::{LoginRateLimit, WebState};
|
||||
use axum::extract::State;
|
||||
|
|
@ -361,7 +399,7 @@ mod tests {
|
|||
AttachedDatabaseConfig, ConnectionConfig, DatabaseConnectionInfo, DatabaseType, ProxyTunnelConfig, ProxyType,
|
||||
TransportLayerConfig,
|
||||
};
|
||||
use dbx_core::storage::Storage;
|
||||
use dbx_core::storage::{McpGlobalPolicy, Storage};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
#[cfg(feature = "mq-admin")]
|
||||
|
|
@ -584,6 +622,97 @@ mod tests {
|
|||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mcp_connection_routes_preserve_unrelated_concurrent_changes() {
|
||||
let (state, dir) = test_web_state().await;
|
||||
let mut existing = sqlite_config("existing", &dir.join("before.db").to_string_lossy());
|
||||
state.app.storage.save_connections(std::slice::from_ref(&existing)).await.unwrap();
|
||||
state
|
||||
.app
|
||||
.storage
|
||||
.save_mcp_global_policy(&McpGlobalPolicy {
|
||||
read_only: false,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: Some(vec![existing.id.clone()]),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Simulate a Web UI edit after the MCP client last observed the list.
|
||||
existing.host = dir.join("after.db").to_string_lossy().into_owned();
|
||||
state.app.storage.save_connections(std::slice::from_ref(&existing)).await.unwrap();
|
||||
let added = sqlite_config("added", &dir.join("added.db").to_string_lossy());
|
||||
let result =
|
||||
mcp_add_connection(State(state.clone()), Json(McpAddConnectionRequest { config: added.clone() })).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let persisted = state.app.storage.load_connections().await.unwrap();
|
||||
assert_eq!(persisted.len(), 2);
|
||||
assert_eq!(
|
||||
persisted.iter().find(|config| config.id == existing.id).map(|config| config.host.as_str()),
|
||||
Some(existing.host.as_str())
|
||||
);
|
||||
assert!(persisted.iter().any(|config| config.id == added.id));
|
||||
assert!(state.app.configs.read().await.contains_key(&added.id));
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mcp_connection_routes_recheck_read_only_and_allowlist_in_the_mutation_transaction() {
|
||||
let (state, dir) = test_web_state().await;
|
||||
let kept = sqlite_config("kept", &dir.join("kept.db").to_string_lossy());
|
||||
let removed = sqlite_config("removed", &dir.join("removed.db").to_string_lossy());
|
||||
state.app.storage.save_connections(&[kept.clone(), removed.clone()]).await.unwrap();
|
||||
state
|
||||
.app
|
||||
.storage
|
||||
.save_mcp_global_policy(&McpGlobalPolicy {
|
||||
read_only: false,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: Some(vec![removed.id.clone()]),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let removed_result = mcp_remove_connection(
|
||||
State(state.clone()),
|
||||
Json(McpRemoveConnectionRequest { connection_id: removed.id.clone() }),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("{}", error.0));
|
||||
assert!(removed_result.0);
|
||||
assert_eq!(state.app.storage.load_connections().await.unwrap()[0].id, kept.id);
|
||||
|
||||
let scope_error = mcp_remove_connection(
|
||||
State(state.clone()),
|
||||
Json(McpRemoveConnectionRequest { connection_id: kept.id.clone() }),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(scope_error.0.starts_with("CONNECTION_OUT_OF_SCOPE:"));
|
||||
|
||||
state
|
||||
.app
|
||||
.storage
|
||||
.save_mcp_global_policy(&McpGlobalPolicy {
|
||||
read_only: true,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let read_only_error = mcp_add_connection(
|
||||
State(state.clone()),
|
||||
Json(McpAddConnectionRequest { config: sqlite_config("new", &dir.join("new.db").to_string_lossy()) }),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(read_only_error.0.starts_with("MCP_READ_ONLY:"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_connection_database_info_preserves_connected_pool() {
|
||||
let (state, dir) = test_web_state().await;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,438 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
use dbx_core::models::connection::ConnectionConfig;
|
||||
use dbx_core::storage::McpGlobalPolicy;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::state::WebState;
|
||||
|
||||
const MCP_REQUEST_HEADER: &str = "x-dbx-mcp-request";
|
||||
|
||||
pub fn is_mcp_request(headers: &HeaderMap) -> bool {
|
||||
headers.get(MCP_REQUEST_HEADER).and_then(|value| value.to_str().ok()) == Some("1")
|
||||
}
|
||||
|
||||
pub fn mongo_pipeline_has_write_stage(pipeline_json: &str) -> bool {
|
||||
serde_json::from_str::<serde_json::Value>(pipeline_json)
|
||||
.ok()
|
||||
.and_then(|value| value.as_array().cloned())
|
||||
.is_some_and(|stages| {
|
||||
stages.iter().any(|stage| {
|
||||
stage
|
||||
.as_object()
|
||||
.is_some_and(|document| document.contains_key("$out") || document.contains_key("$merge"))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn mongo_filter_is_effectively_unbounded(filter_json: &str) -> bool {
|
||||
serde_json::from_str::<serde_json::Value>(filter_json)
|
||||
.ok()
|
||||
.as_ref()
|
||||
.is_none_or(|value| mongo_filter_contains_opaque_logic(value) || mongo_filter_value_is_unbounded(value))
|
||||
}
|
||||
|
||||
fn mongo_filter_contains_opaque_logic(value: &serde_json::Value) -> bool {
|
||||
let Some(filter) = value.as_object() else {
|
||||
return true;
|
||||
};
|
||||
filter.iter().any(|(key, value)| match key.as_str() {
|
||||
"$comment" => false,
|
||||
"$where" | "$expr" | "$nor" => true,
|
||||
"$and" | "$or" => {
|
||||
let Some(clauses) = value.as_array() else {
|
||||
return true;
|
||||
};
|
||||
clauses.is_empty()
|
||||
|| clauses.iter().any(|clause| !clause.is_object() || mongo_filter_contains_opaque_logic(clause))
|
||||
|| (key == "$or"
|
||||
&& clauses
|
||||
.iter()
|
||||
.any(|clause| clause.as_object().is_some_and(|document| document.contains_key("$and"))))
|
||||
|| (key == "$or" && mongo_or_has_complementary_field_clauses(clauses))
|
||||
}
|
||||
_ => key.starts_with('$') || mongo_field_predicate_contains_opaque_logic(value),
|
||||
})
|
||||
}
|
||||
|
||||
fn mongo_field_predicate_contains_opaque_logic(value: &serde_json::Value) -> bool {
|
||||
let Some(predicate) = value.as_object() else {
|
||||
return false;
|
||||
};
|
||||
if mongo_extended_json_scalar_literal_is_valid(value) {
|
||||
return false;
|
||||
}
|
||||
let has_operator = predicate.keys().any(|key| key.starts_with('$'));
|
||||
has_operator
|
||||
&& predicate.keys().any(|key| {
|
||||
!matches!(key.as_str(), "$eq" | "$ne" | "$gt" | "$gte" | "$lt" | "$lte" | "$in" | "$nin" | "$exists")
|
||||
})
|
||||
}
|
||||
|
||||
fn mongo_extended_json_scalar_literal_is_valid(value: &serde_json::Value) -> bool {
|
||||
let Some(wrapper) = value.as_object().filter(|wrapper| wrapper.len() == 1) else {
|
||||
return false;
|
||||
};
|
||||
if let Some(value) = wrapper.get("$oid").and_then(serde_json::Value::as_str) {
|
||||
return value.len() == 24 && value.bytes().all(|byte| byte.is_ascii_hexdigit());
|
||||
}
|
||||
if let Some(value) = wrapper.get("$numberLong").and_then(serde_json::Value::as_str) {
|
||||
return value.parse::<i64>().is_ok();
|
||||
}
|
||||
wrapper
|
||||
.get("$date")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|value| chrono::DateTime::parse_from_rfc3339(value).is_ok())
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum MongoFieldOperator {
|
||||
Eq,
|
||||
Ne,
|
||||
Gt,
|
||||
Gte,
|
||||
Lt,
|
||||
Lte,
|
||||
In,
|
||||
Nin,
|
||||
Exists,
|
||||
}
|
||||
|
||||
struct MongoPureFieldPredicate<'a> {
|
||||
field: &'a str,
|
||||
operator: MongoFieldOperator,
|
||||
operand: &'a serde_json::Value,
|
||||
}
|
||||
|
||||
fn mongo_or_has_complementary_field_clauses(clauses: &[serde_json::Value]) -> bool {
|
||||
clauses.iter().enumerate().any(|(index, clause)| {
|
||||
let Some(predicate) = mongo_pure_field_predicate(clause) else {
|
||||
return false;
|
||||
};
|
||||
clauses[index + 1..]
|
||||
.iter()
|
||||
.filter_map(mongo_pure_field_predicate)
|
||||
.any(|other| mongo_field_predicates_are_complementary(&predicate, &other))
|
||||
})
|
||||
}
|
||||
|
||||
fn mongo_pure_field_predicate(value: &serde_json::Value) -> Option<MongoPureFieldPredicate<'_>> {
|
||||
let filter = value.as_object()?;
|
||||
let mut entries = filter.iter().filter(|(key, _)| key.as_str() != "$comment");
|
||||
let (field, predicate) = entries.next()?;
|
||||
if entries.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
if field == "$and" {
|
||||
let clauses = predicate.as_array()?;
|
||||
let mut bounded = clauses.iter().filter(|clause| !mongo_filter_value_is_unbounded(clause));
|
||||
let clause = bounded.next()?;
|
||||
if bounded.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
return mongo_pure_field_predicate(clause);
|
||||
}
|
||||
if field == "$or" {
|
||||
let clauses = predicate.as_array()?;
|
||||
return (clauses.len() == 1).then(|| mongo_pure_field_predicate(&clauses[0])).flatten();
|
||||
}
|
||||
if field.starts_with('$') {
|
||||
return None;
|
||||
}
|
||||
let Some(operator_document) = predicate.as_object() else {
|
||||
return Some(MongoPureFieldPredicate { field, operator: MongoFieldOperator::Eq, operand: predicate });
|
||||
};
|
||||
if mongo_extended_json_scalar_literal_is_valid(predicate)
|
||||
|| !operator_document.keys().any(|key| key.starts_with('$'))
|
||||
{
|
||||
return Some(MongoPureFieldPredicate { field, operator: MongoFieldOperator::Eq, operand: predicate });
|
||||
}
|
||||
let mut operators = operator_document.iter();
|
||||
let (operator, operand) = operators.next()?;
|
||||
if operators.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
let operator = match operator.as_str() {
|
||||
"$eq" => MongoFieldOperator::Eq,
|
||||
"$ne" => MongoFieldOperator::Ne,
|
||||
"$gt" => MongoFieldOperator::Gt,
|
||||
"$gte" => MongoFieldOperator::Gte,
|
||||
"$lt" => MongoFieldOperator::Lt,
|
||||
"$lte" => MongoFieldOperator::Lte,
|
||||
"$in" => MongoFieldOperator::In,
|
||||
"$nin" => MongoFieldOperator::Nin,
|
||||
"$exists" => MongoFieldOperator::Exists,
|
||||
_ => return None,
|
||||
};
|
||||
Some(MongoPureFieldPredicate { field, operator, operand })
|
||||
}
|
||||
|
||||
fn mongo_field_predicates_are_complementary(
|
||||
left: &MongoPureFieldPredicate<'_>,
|
||||
right: &MongoPureFieldPredicate<'_>,
|
||||
) -> bool {
|
||||
if left.field != right.field {
|
||||
return false;
|
||||
}
|
||||
use MongoFieldOperator::{Eq, Exists, Gt, Gte, In, Lt, Lte, Ne, Nin};
|
||||
match (left.operator, right.operator) {
|
||||
(Exists, Exists) => {
|
||||
left.operand.as_bool().zip(right.operand.as_bool()).is_some_and(|(left, right)| left != right)
|
||||
}
|
||||
(In, Nin) | (Nin, In) => mongo_json_sets_equal(left.operand, right.operand),
|
||||
(Eq, Ne) | (Ne, Eq) | (Gt, Lte) | (Lte, Gt) | (Gte, Lt) | (Lt, Gte) => left.operand == right.operand,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn mongo_json_sets_equal(left: &serde_json::Value, right: &serde_json::Value) -> bool {
|
||||
let (Some(left), Some(right)) = (left.as_array(), right.as_array()) else {
|
||||
return false;
|
||||
};
|
||||
left.iter().all(|value| right.contains(value)) && right.iter().all(|value| left.contains(value))
|
||||
}
|
||||
|
||||
fn mongo_filter_value_is_unbounded(value: &serde_json::Value) -> bool {
|
||||
let Some(filter) = value.as_object() else {
|
||||
return true;
|
||||
};
|
||||
if filter.is_empty() || filter.contains_key("$where") || filter.contains_key("$expr") {
|
||||
return true;
|
||||
}
|
||||
filter.iter().all(|(key, value)| match key.as_str() {
|
||||
"$comment" => true,
|
||||
"$and" => value
|
||||
.as_array()
|
||||
.is_none_or(|clauses| clauses.is_empty() || clauses.iter().all(mongo_filter_value_is_unbounded)),
|
||||
"$or" => value
|
||||
.as_array()
|
||||
.is_none_or(|clauses| clauses.is_empty() || clauses.iter().any(mongo_filter_value_is_unbounded)),
|
||||
"$nor" => true,
|
||||
_ if mongo_field_predicate_is_empty_nin(value) => true,
|
||||
"_id" if mongo_field_predicate_is_exists_true(value) => true,
|
||||
_ => key.starts_with('$'),
|
||||
})
|
||||
}
|
||||
|
||||
fn mongo_field_predicate_is_empty_nin(value: &serde_json::Value) -> bool {
|
||||
value.as_object().is_some_and(|predicate| {
|
||||
predicate.len() == 1 && predicate.get("$nin").and_then(serde_json::Value::as_array).is_some_and(Vec::is_empty)
|
||||
})
|
||||
}
|
||||
|
||||
fn mongo_field_predicate_is_exists_true(value: &serde_json::Value) -> bool {
|
||||
value.as_object().is_some_and(|predicate| {
|
||||
predicate.len() == 1 && predicate.get("$exists").and_then(serde_json::Value::as_bool) == Some(true)
|
||||
})
|
||||
}
|
||||
|
||||
async fn load_policy(state: &Arc<WebState>) -> Result<McpGlobalPolicy, AppError> {
|
||||
state.app.storage.load_mcp_global_policy().await.map(|state| state.policy()).map_err(AppError)
|
||||
}
|
||||
|
||||
fn ensure_allowed(policy: &McpGlobalPolicy, connection_id: &str) -> Result<(), AppError> {
|
||||
if policy.allowed_connection_ids.as_ref().is_some_and(|allowed| !allowed.iter().any(|id| id == connection_id)) {
|
||||
return Err(AppError(format!(
|
||||
"CONNECTION_OUT_OF_SCOPE: connection '{connection_id}' is not allowed by DBX MCP settings"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn connection_read_only_error(message: impl Into<String>) -> AppError {
|
||||
AppError(format!("CONNECTION_READ_ONLY: {}", message.into()))
|
||||
}
|
||||
|
||||
async fn load_connection(state: &Arc<WebState>, connection_id: &str) -> Result<ConnectionConfig, AppError> {
|
||||
state
|
||||
.app
|
||||
.storage
|
||||
.load_connections()
|
||||
.await
|
||||
.map_err(AppError)?
|
||||
.into_iter()
|
||||
.find(|config| config.id == connection_id)
|
||||
.ok_or_else(|| AppError(format!("Connection with id '{connection_id}' not found")))
|
||||
}
|
||||
|
||||
pub async fn ensure_scope(state: &Arc<WebState>, headers: &HeaderMap, connection_id: &str) -> Result<(), AppError> {
|
||||
if !is_mcp_request(headers) {
|
||||
return Ok(());
|
||||
}
|
||||
ensure_allowed(&load_policy(state).await?, connection_id)
|
||||
}
|
||||
|
||||
pub async fn ensure_write(
|
||||
state: &Arc<WebState>,
|
||||
headers: &HeaderMap,
|
||||
connection_id: &str,
|
||||
database: &str,
|
||||
action: &str,
|
||||
) -> Result<(), AppError> {
|
||||
ensure_write_with_risk(state, headers, connection_id, database, action, false).await
|
||||
}
|
||||
|
||||
pub async fn ensure_dangerous_write(
|
||||
state: &Arc<WebState>,
|
||||
headers: &HeaderMap,
|
||||
connection_id: &str,
|
||||
database: &str,
|
||||
action: &str,
|
||||
) -> Result<(), AppError> {
|
||||
ensure_write_with_risk(state, headers, connection_id, database, action, true).await
|
||||
}
|
||||
|
||||
pub async fn ensure_mongo_pipeline_target(
|
||||
state: &Arc<WebState>,
|
||||
headers: &HeaderMap,
|
||||
connection_id: &str,
|
||||
database: &str,
|
||||
pipeline_json: &str,
|
||||
) -> Result<(), AppError> {
|
||||
if !is_mcp_request(headers) {
|
||||
return Ok(());
|
||||
}
|
||||
let config = load_connection(state, connection_id).await?;
|
||||
if dbx_core::production_safety::mongo_pipeline_targets_production_database(&config, database, pipeline_json) {
|
||||
return Err(AppError(
|
||||
"PRODUCTION_DATABASE_READ_ONLY: MongoDB aggregate write targeting production scope is blocked.".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_write_with_risk(
|
||||
state: &Arc<WebState>,
|
||||
headers: &HeaderMap,
|
||||
connection_id: &str,
|
||||
database: &str,
|
||||
action: &str,
|
||||
dangerous: bool,
|
||||
) -> Result<(), AppError> {
|
||||
if !is_mcp_request(headers) {
|
||||
return Ok(());
|
||||
}
|
||||
let policy = load_policy(state).await?;
|
||||
ensure_allowed(&policy, connection_id)?;
|
||||
if policy.read_only {
|
||||
return Err(AppError(format!("MCP_READ_ONLY: DBX MCP read-only mode is enabled. {action} blocked.")));
|
||||
}
|
||||
if dangerous && !policy.allow_dangerous_sql {
|
||||
return Err(AppError(format!("SQL_BLOCKED: High-risk operation '{action}' is disabled in DBX MCP settings.")));
|
||||
}
|
||||
let config = load_connection(state, connection_id).await?;
|
||||
if config.read_only {
|
||||
return Err(connection_read_only_error(format!(
|
||||
"Connection '{}' has read-only protection enabled. {action} blocked.",
|
||||
config.name
|
||||
)));
|
||||
}
|
||||
if dbx_core::production_safety::is_production_database(&config, database) {
|
||||
return Err(AppError(format!(
|
||||
"PRODUCTION_DATABASE_READ_ONLY: {action} blocked for production database '{database}'."
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn ensure_sql(
|
||||
state: &Arc<WebState>,
|
||||
headers: &HeaderMap,
|
||||
connection_id: &str,
|
||||
database: &str,
|
||||
sql: &str,
|
||||
) -> Result<(), AppError> {
|
||||
if !is_mcp_request(headers) {
|
||||
return Ok(());
|
||||
}
|
||||
let policy = load_policy(state).await?;
|
||||
ensure_allowed(&policy, connection_id)?;
|
||||
let config = load_connection(state, connection_id).await?;
|
||||
if dbx_core::sql_risk::mcp_sql_has_forbidden_database_switch(sql, config.db_type) {
|
||||
return Err(AppError("SQL_BLOCKED: MCP does not allow USE or persistent database switching.".to_string()));
|
||||
}
|
||||
let is_write = dbx_core::query_execution_sql::is_write_sql_for_database(sql, config.db_type);
|
||||
if policy.read_only && is_write {
|
||||
return Err(AppError("MCP_READ_ONLY: DBX MCP read-only mode is enabled. SQL write blocked.".to_string()));
|
||||
}
|
||||
if !policy.allow_dangerous_sql && dbx_core::sql_risk::is_dangerous_sql_for_database(sql, config.db_type) {
|
||||
return Err(AppError("SQL_BLOCKED: High-risk SQL is disabled in DBX MCP settings.".to_string()));
|
||||
}
|
||||
if config.read_only {
|
||||
dbx_core::query_execution_sql::check_read_only(sql, &config.name, config.db_type)
|
||||
.map_err(connection_read_only_error)?;
|
||||
}
|
||||
if is_write && dbx_core::production_safety::targets_production_database(&config, database, sql) {
|
||||
return Err(AppError(
|
||||
"PRODUCTION_DATABASE_READ_ONLY: SQL write targeting production scope is blocked.".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{connection_read_only_error, mongo_filter_is_effectively_unbounded, mongo_pipeline_has_write_stage};
|
||||
|
||||
#[test]
|
||||
fn connection_read_only_errors_use_the_stable_mcp_code() {
|
||||
assert_eq!(connection_read_only_error("write blocked").0, "CONNECTION_READ_ONLY: write blocked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_only_top_level_aggregate_write_stages() {
|
||||
assert!(mongo_pipeline_has_write_stage(r#"[{"$out":"archive"}]"#));
|
||||
assert!(mongo_pipeline_has_write_stage(r#"[{"$merge":{"into":"archive"}}]"#));
|
||||
assert!(!mongo_pipeline_has_write_stage(r#"[{"$project":{"value":"$out"}}]"#));
|
||||
assert!(!mongo_pipeline_has_write_stage("not-json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_effectively_unbounded_mongo_filters() {
|
||||
for filter in [
|
||||
"{}",
|
||||
r#"{"$comment":"all rows"}"#,
|
||||
r#"{"$expr":true}"#,
|
||||
r#"{"$or":[{}, {"id":1}]}"#,
|
||||
r#"{"$nor":[{"$expr":false}]}"#,
|
||||
r#"{"$or":[{"id":{"$exists":true}},{"id":{"$exists":false}}]}"#,
|
||||
r#"{"$or":[{"id":{"$exists":true}},{"id":{"$not":{"$exists":true}}}]}"#,
|
||||
r#"{"$or":[{"id":{"$eq":1}},{"id":{"$ne":1}}]}"#,
|
||||
r#"{"$or":[{"$and":[{"id":{"$eq":1}}]},{"id":{"$ne":1}}]}"#,
|
||||
r#"{"$or":[{"$and":[{"id":{"$eq":1}},{}]},{"id":{"$ne":1}}]}"#,
|
||||
r#"{"$or":[{"$and":[{"id":{"$eq":1}},{"x":{"$exists":true}}]},{"id":{"$ne":1}},{"x":{"$exists":false}}]}"#,
|
||||
r#"{"$or":[{"id":1},{"id":{"$ne":1}}]}"#,
|
||||
r#"{"$or":[{"id":{"$gt":1}},{"id":{"$lte":1}}]}"#,
|
||||
r#"{"$or":[{"id":{"$gte":1}},{"id":{"$lt":1}}]}"#,
|
||||
r#"{"$or":[{"id":{"$in":[1,2]}},{"id":{"$nin":[2,1]}}]}"#,
|
||||
r#"{"_id":{"$exists":true}}"#,
|
||||
r#"{"id":{"$nin":[]}}"#,
|
||||
r#"{"_id":{"$oid":"not-an-object-id"}}"#,
|
||||
r#"{"sequence":{"$numberLong":"9223372036854775808"}}"#,
|
||||
r#"{"created_at":{"$date":"2026-02-30T00:00:00Z"}}"#,
|
||||
r#"{"name":{"$regex":".*"}}"#,
|
||||
r#"{"$or":[{"_id":{"$oid":"507f1f77bcf86cd799439011"}},{"_id":{"$ne":{"$oid":"507f1f77bcf86cd799439011"}}}]}"#,
|
||||
r#"{"$and":[{"tenant_id":1},{"$nor":[{"archived":true}]}]}"#,
|
||||
r#"{"$or":[]}"#,
|
||||
r#"{"$opaque":[{"id":1}]}"#,
|
||||
] {
|
||||
assert!(mongo_filter_is_effectively_unbounded(filter), "{filter}");
|
||||
}
|
||||
for filter in [
|
||||
r#"{"id":1}"#,
|
||||
r#"{"created_at":{"$gte":"2026-01-01"}}"#,
|
||||
r#"{"$and":[{}, {"tenant_id":1}]}"#,
|
||||
r#"{"$or":[{"tenant_id":1},{"tenant_id":2}]}"#,
|
||||
r#"{"id":{"$ne":1}}"#,
|
||||
r#"{"id":{"$in":[1,2]}}"#,
|
||||
r#"{"id":{"$exists":true}}"#,
|
||||
r#"{"_id":{"$oid":"507f1f77bcf86cd799439011"}}"#,
|
||||
r#"{"sequence":{"$numberLong":"9223372036854775807"}}"#,
|
||||
r#"{"created_at":{"$date":"2026-01-01T00:00:00.000Z"}}"#,
|
||||
r#"{"tenant_id":1,"id":{"$nin":[]}}"#,
|
||||
] {
|
||||
assert!(!mongo_filter_is_effectively_unbounded(filter), "{filter}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ pub mod etcd;
|
|||
pub mod history;
|
||||
pub mod jdbc;
|
||||
pub mod layout;
|
||||
pub mod mcp_policy;
|
||||
pub mod mongo;
|
||||
#[cfg(feature = "mq-admin")]
|
||||
pub mod mq;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ use std::future::Future;
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::HeaderMap;
|
||||
use axum::Json;
|
||||
use serde::Deserialize;
|
||||
|
||||
|
|
@ -269,8 +270,10 @@ pub struct MongoDeleteRequest {
|
|||
|
||||
pub async fn list_databases(
|
||||
State(state): State<Arc<WebState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<MongoConnectionRequest>,
|
||||
) -> Result<Json<Vec<String>>, AppError> {
|
||||
super::mcp_policy::ensure_scope(&state, &headers, &req.connection_id).await?;
|
||||
let result =
|
||||
dbx_core::mongo_ops::mongo_list_databases_core(&state.app, &req.connection_id).await.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
|
|
@ -278,8 +281,10 @@ pub async fn list_databases(
|
|||
|
||||
pub async fn list_collections(
|
||||
State(state): State<Arc<WebState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<MongoCollectionRequest>,
|
||||
) -> Result<Json<Vec<dbx_core::document_ops::CollectionInfo>>, AppError> {
|
||||
super::mcp_policy::ensure_scope(&state, &headers, &req.connection_id).await?;
|
||||
let result = dbx_core::mongo_ops::mongo_list_collections_core(&state.app, &req.connection_id, &req.database)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
|
|
@ -333,8 +338,11 @@ pub async fn drop_database(
|
|||
|
||||
pub async fn drop_collection(
|
||||
State(state): State<Arc<WebState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<MongoCollectionNameRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
super::mcp_policy::ensure_dangerous_write(&state, &headers, &req.connection_id, &req.database, "Drop collection")
|
||||
.await?;
|
||||
ensure_writable(&state.app, &req.connection_id, "Drop collection").await?;
|
||||
dbx_core::mongo_ops::mongo_drop_collection_core(&state.app, &req.connection_id, &req.database, &req.collection)
|
||||
.await
|
||||
|
|
@ -344,8 +352,10 @@ pub async fn drop_collection(
|
|||
|
||||
pub async fn find_documents(
|
||||
State(state): State<Arc<WebState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<MongoFindRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
super::mcp_policy::ensure_scope(&state, &headers, &req.connection_id).await?;
|
||||
let result = run_cancellable(
|
||||
&state,
|
||||
req.execution_id.clone(),
|
||||
|
|
@ -388,8 +398,10 @@ pub async fn find_one(
|
|||
|
||||
pub async fn count_documents(
|
||||
State(state): State<Arc<WebState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<MongoCountRequest>,
|
||||
) -> Result<Json<u64>, AppError> {
|
||||
super::mcp_policy::ensure_scope(&state, &headers, &req.connection_id).await?;
|
||||
let result = run_cancellable(
|
||||
&state,
|
||||
req.execution_id,
|
||||
|
|
@ -408,8 +420,10 @@ pub async fn count_documents(
|
|||
|
||||
pub async fn server_version(
|
||||
State(state): State<Arc<WebState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<MongoServerVersionRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
super::mcp_policy::ensure_scope(&state, &headers, &req.connection_id).await?;
|
||||
let result = run_cancellable(
|
||||
&state,
|
||||
req.execution_id.clone(),
|
||||
|
|
@ -421,8 +435,10 @@ pub async fn server_version(
|
|||
|
||||
pub async fn collection_stats(
|
||||
State(state): State<Arc<WebState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<MongoCollectionStatsRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
super::mcp_policy::ensure_scope(&state, &headers, &req.connection_id).await?;
|
||||
let result = run_cancellable(
|
||||
&state,
|
||||
req.execution_id.clone(),
|
||||
|
|
@ -440,8 +456,28 @@ pub async fn collection_stats(
|
|||
|
||||
pub async fn aggregate_documents(
|
||||
State(state): State<Arc<WebState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<MongoAggregateRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
super::mcp_policy::ensure_scope(&state, &headers, &req.connection_id).await?;
|
||||
if super::mcp_policy::mongo_pipeline_has_write_stage(&req.pipeline_json) {
|
||||
super::mcp_policy::ensure_dangerous_write(
|
||||
&state,
|
||||
&headers,
|
||||
&req.connection_id,
|
||||
&req.database,
|
||||
"MongoDB aggregate write",
|
||||
)
|
||||
.await?;
|
||||
super::mcp_policy::ensure_mongo_pipeline_target(
|
||||
&state,
|
||||
&headers,
|
||||
&req.connection_id,
|
||||
&req.database,
|
||||
&req.pipeline_json,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let result = run_cancellable(
|
||||
&state,
|
||||
req.execution_id.clone(),
|
||||
|
|
@ -461,8 +497,10 @@ pub async fn aggregate_documents(
|
|||
|
||||
pub async fn distinct(
|
||||
State(state): State<Arc<WebState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<MongoDistinctRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
super::mcp_policy::ensure_scope(&state, &headers, &req.connection_id).await?;
|
||||
let result = run_cancellable(
|
||||
&state,
|
||||
req.execution_id.clone(),
|
||||
|
|
@ -481,8 +519,11 @@ pub async fn distinct(
|
|||
|
||||
pub async fn create_index(
|
||||
State(state): State<Arc<WebState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<MongoCreateIndexRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
super::mcp_policy::ensure_dangerous_write(&state, &headers, &req.connection_id, &req.database, "Create index")
|
||||
.await?;
|
||||
ensure_writable(&state.app, &req.connection_id, "Create index").await?;
|
||||
let name = dbx_core::mongo_ops::mongo_create_index_core(
|
||||
&state.app,
|
||||
|
|
@ -499,8 +540,11 @@ pub async fn create_index(
|
|||
|
||||
pub async fn drop_indexes(
|
||||
State(state): State<Arc<WebState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<MongoDropIndexesRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
super::mcp_policy::ensure_dangerous_write(&state, &headers, &req.connection_id, &req.database, "Drop indexes")
|
||||
.await?;
|
||||
ensure_writable(&state.app, &req.connection_id, "Drop indexes").await?;
|
||||
let result = dbx_core::mongo_ops::mongo_drop_indexes_core(
|
||||
&state.app,
|
||||
|
|
@ -535,8 +579,10 @@ pub async fn insert_document(
|
|||
|
||||
pub async fn insert_documents(
|
||||
State(state): State<Arc<WebState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<MongoInsertDocumentsRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
super::mcp_policy::ensure_write(&state, &headers, &req.connection_id, &req.database, "Insert").await?;
|
||||
ensure_writable(&state.app, &req.connection_id, "Insert").await?;
|
||||
let result = dbx_core::mongo_ops::mongo_insert_documents_core(
|
||||
&state.app,
|
||||
|
|
@ -571,8 +617,15 @@ pub async fn update_document(
|
|||
|
||||
pub async fn update_documents(
|
||||
State(state): State<Arc<WebState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<MongoUpdateDocumentsRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
if super::mcp_policy::mongo_filter_is_effectively_unbounded(&req.filter_json) {
|
||||
super::mcp_policy::ensure_dangerous_write(&state, &headers, &req.connection_id, &req.database, "Update")
|
||||
.await?;
|
||||
} else {
|
||||
super::mcp_policy::ensure_write(&state, &headers, &req.connection_id, &req.database, "Update").await?;
|
||||
}
|
||||
ensure_writable(&state.app, &req.connection_id, "Update").await?;
|
||||
let result = dbx_core::mongo_ops::mongo_update_documents_core(
|
||||
&state.app,
|
||||
|
|
@ -591,8 +644,11 @@ pub async fn update_documents(
|
|||
|
||||
pub async fn find_one_and_update(
|
||||
State(state): State<Arc<WebState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<MongoFindOneAndUpdateRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
ensure_find_one_write_policy(&state, &headers, &req.connection_id, &req.database, &req.filter_json, "Update")
|
||||
.await?;
|
||||
ensure_writable(&state.app, &req.connection_id, "Update").await?;
|
||||
let result = dbx_core::mongo_ops::mongo_find_one_and_update_core(
|
||||
&state.app,
|
||||
|
|
@ -610,8 +666,11 @@ pub async fn find_one_and_update(
|
|||
|
||||
pub async fn find_one_and_replace(
|
||||
State(state): State<Arc<WebState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<MongoFindOneAndReplaceRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
ensure_find_one_write_policy(&state, &headers, &req.connection_id, &req.database, &req.filter_json, "Replace")
|
||||
.await?;
|
||||
ensure_writable(&state.app, &req.connection_id, "Update").await?;
|
||||
let result = dbx_core::mongo_ops::mongo_find_one_and_replace_core(
|
||||
&state.app,
|
||||
|
|
@ -629,8 +688,11 @@ pub async fn find_one_and_replace(
|
|||
|
||||
pub async fn find_one_and_delete(
|
||||
State(state): State<Arc<WebState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<MongoFindOneAndDeleteRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
ensure_find_one_write_policy(&state, &headers, &req.connection_id, &req.database, &req.filter_json, "Delete")
|
||||
.await?;
|
||||
ensure_writable(&state.app, &req.connection_id, "Delete").await?;
|
||||
let result = dbx_core::mongo_ops::mongo_find_one_and_delete_core(
|
||||
&state.app,
|
||||
|
|
@ -665,8 +727,15 @@ pub async fn delete_document(
|
|||
|
||||
pub async fn delete_documents(
|
||||
State(state): State<Arc<WebState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<MongoDeleteDocumentsRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
if super::mcp_policy::mongo_filter_is_effectively_unbounded(&req.filter_json) {
|
||||
super::mcp_policy::ensure_dangerous_write(&state, &headers, &req.connection_id, &req.database, "Delete")
|
||||
.await?;
|
||||
} else {
|
||||
super::mcp_policy::ensure_write(&state, &headers, &req.connection_id, &req.database, "Delete").await?;
|
||||
}
|
||||
ensure_writable(&state.app, &req.connection_id, "Delete").await?;
|
||||
let result = dbx_core::mongo_ops::mongo_delete_documents_core(
|
||||
&state.app,
|
||||
|
|
@ -680,3 +749,140 @@ pub async fn delete_documents(
|
|||
.map_err(AppError)?;
|
||||
Ok(Json(serde_json::json!({ "affected_rows": result })))
|
||||
}
|
||||
|
||||
async fn ensure_find_one_write_policy(
|
||||
state: &Arc<WebState>,
|
||||
headers: &HeaderMap,
|
||||
connection_id: &str,
|
||||
database: &str,
|
||||
filter_json: &str,
|
||||
action: &str,
|
||||
) -> Result<(), AppError> {
|
||||
if super::mcp_policy::mongo_filter_is_effectively_unbounded(filter_json) {
|
||||
super::mcp_policy::ensure_dangerous_write(state, headers, connection_id, database, action).await
|
||||
} else {
|
||||
super::mcp_policy::ensure_write(state, headers, connection_id, database, action).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ensure_find_one_write_policy;
|
||||
use crate::state::{LoginRateLimit, WebState};
|
||||
use axum::http::{HeaderMap, HeaderValue};
|
||||
use dbx_core::connection::AppState;
|
||||
use dbx_core::models::connection::ConnectionConfig;
|
||||
use dbx_core::storage::{McpGlobalPolicy, Storage};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
|
||||
fn mongo_config(is_production: bool) -> ConnectionConfig {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"id": "mongo-policy-test",
|
||||
"name": "Mongo policy test",
|
||||
"db_type": "mongodb",
|
||||
"host": "localhost",
|
||||
"port": 27017,
|
||||
"username": "tester",
|
||||
"password": "",
|
||||
"database": "app",
|
||||
"is_production": is_production
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn test_web_state() -> (Arc<WebState>, std::path::PathBuf) {
|
||||
let dir = std::env::temp_dir().join(format!("dbx-web-mongo-policy-test-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let storage = Storage::open(&dir.join("storage.db")).await.unwrap();
|
||||
let app = Arc::new(AppState::new_with_plugin_dir(storage, dir.join("plugins")));
|
||||
let state = Arc::new(WebState {
|
||||
app,
|
||||
data_dir: dir.clone(),
|
||||
public_base_path: "/".to_string(),
|
||||
password_disabled: false,
|
||||
password_hash: RwLock::new(None),
|
||||
sessions: RwLock::new(HashSet::new()),
|
||||
sse_channels: RwLock::new(HashMap::new()),
|
||||
sql_file_executions: RwLock::new(HashMap::new()),
|
||||
login_rate_limit: Mutex::new(LoginRateLimit { fail_count: 0, locked_until: None }),
|
||||
export_files: RwLock::new(HashMap::new()),
|
||||
});
|
||||
(state, dir)
|
||||
}
|
||||
|
||||
fn mcp_headers() -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-dbx-mcp-request", HeaderValue::from_static("1"));
|
||||
headers
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn find_one_writes_recheck_policy_filter_production_and_allowlist() {
|
||||
let (state, dir) = test_web_state().await;
|
||||
let connection = mongo_config(false);
|
||||
state.app.storage.save_connections(std::slice::from_ref(&connection)).await.unwrap();
|
||||
let headers = mcp_headers();
|
||||
let writable_policy = McpGlobalPolicy {
|
||||
read_only: false,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: Some(vec![connection.id.clone()]),
|
||||
};
|
||||
state.app.storage.save_mcp_global_policy(&writable_policy).await.unwrap();
|
||||
|
||||
assert!(ensure_find_one_write_policy(&state, &headers, &connection.id, "app", r#"{"_id":1}"#, "Update")
|
||||
.await
|
||||
.is_ok());
|
||||
|
||||
state
|
||||
.app
|
||||
.storage
|
||||
.save_mcp_global_policy(&McpGlobalPolicy { read_only: true, ..writable_policy.clone() })
|
||||
.await
|
||||
.unwrap();
|
||||
let revoked = ensure_find_one_write_policy(&state, &headers, &connection.id, "app", r#"{"_id":1}"#, "Update")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(revoked.0.starts_with("MCP_READ_ONLY:"), "{}", revoked.0);
|
||||
|
||||
state.app.storage.save_mcp_global_policy(&writable_policy).await.unwrap();
|
||||
let empty_filter =
|
||||
ensure_find_one_write_policy(&state, &headers, &connection.id, "app", "{}", "Delete").await.unwrap_err();
|
||||
assert!(empty_filter.0.starts_with("SQL_BLOCKED:"), "{}", empty_filter.0);
|
||||
|
||||
state
|
||||
.app
|
||||
.storage
|
||||
.save_mcp_global_policy(&McpGlobalPolicy { allow_dangerous_sql: true, ..writable_policy.clone() })
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(ensure_find_one_write_policy(&state, &headers, &connection.id, "app", "{}", "Replace").await.is_ok());
|
||||
|
||||
state.app.storage.save_connections(&[mongo_config(true)]).await.unwrap();
|
||||
let production =
|
||||
ensure_find_one_write_policy(&state, &headers, &connection.id, "app", r#"{"_id":1}"#, "Update")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(production.0.starts_with("PRODUCTION_DATABASE_READ_ONLY:"), "{}", production.0);
|
||||
|
||||
state.app.storage.save_connections(std::slice::from_ref(&connection)).await.unwrap();
|
||||
state
|
||||
.app
|
||||
.storage
|
||||
.save_mcp_global_policy(&McpGlobalPolicy {
|
||||
read_only: false,
|
||||
allow_dangerous_sql: true,
|
||||
allowed_connection_ids: Some(vec!["different-connection".to_string()]),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let allowlist = ensure_find_one_write_policy(&state, &headers, &connection.id, "app", r#"{"_id":1}"#, "Delete")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(allowlist.0.starts_with("CONNECTION_OUT_OF_SCOPE:"), "{}", allowlist.0);
|
||||
|
||||
drop(state);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::HeaderMap;
|
||||
use axum::Json;
|
||||
use serde::Deserialize;
|
||||
|
||||
|
|
@ -317,8 +318,10 @@ pub struct BuildDatabaseSqlExportRequest {
|
|||
|
||||
pub async fn execute_query(
|
||||
State(state): State<Arc<WebState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<ExecuteQueryRequest>,
|
||||
) -> Result<Json<dbx_core::db::QueryResult>, AppError> {
|
||||
super::mcp_policy::ensure_sql(&state, &headers, &req.connection_id, &req.database, &req.sql).await?;
|
||||
let execution_id = req.execution_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
||||
|
||||
let registered = state.app.running_queries.register_task(
|
||||
|
|
@ -358,8 +361,10 @@ pub async fn execute_query(
|
|||
|
||||
pub async fn execute_multi(
|
||||
State(state): State<Arc<WebState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<ExecuteQueryRequest>,
|
||||
) -> Result<Json<Vec<dbx_core::query::ExecuteMultiResult>>, AppError> {
|
||||
super::mcp_policy::ensure_sql(&state, &headers, &req.connection_id, &req.database, &req.sql).await?;
|
||||
let execution_id = req.execution_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
||||
|
||||
let registered = state.app.running_queries.register_task(
|
||||
|
|
@ -399,8 +404,12 @@ pub async fn execute_multi(
|
|||
|
||||
pub async fn execute_batch(
|
||||
State(state): State<Arc<WebState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<ExecuteBatchRequest>,
|
||||
) -> Result<Json<dbx_core::db::QueryResult>, AppError> {
|
||||
for statement in &req.statements {
|
||||
super::mcp_policy::ensure_sql(&state, &headers, &req.connection_id, &req.database, statement).await?;
|
||||
}
|
||||
tracing::debug!(connection_id = %req.connection_id, "execute_batch");
|
||||
let result = dbx_core::query::execute_statements(
|
||||
&state.app,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::HeaderMap;
|
||||
use axum::Json;
|
||||
use serde::Deserialize;
|
||||
|
||||
|
|
@ -518,14 +519,41 @@ pub async fn flush_db(
|
|||
|
||||
pub async fn execute_command(
|
||||
State(state): State<Arc<WebState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<RedisCommandRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
super::mcp_policy::ensure_scope(&state, &headers, &req.connection_id).await?;
|
||||
let argv = dbx_core::db::redis_driver::parse_command_argv(&req.command)
|
||||
.map_err(|error| AppError(format!("Invalid Redis command: {error}")))?;
|
||||
let cmd_name = argv[0].to_ascii_uppercase();
|
||||
let safety = dbx_core::db::redis_driver::classify_command(&cmd_name);
|
||||
let is_mcp_request = super::mcp_policy::is_mcp_request(&headers);
|
||||
let centrally_approved_high_risk =
|
||||
is_mcp_request && safety == dbx_core::db::redis_driver::RedisCommandSafety::Blocked;
|
||||
if safety != dbx_core::db::redis_driver::RedisCommandSafety::Allowed {
|
||||
if centrally_approved_high_risk {
|
||||
super::mcp_policy::ensure_dangerous_write(
|
||||
&state,
|
||||
&headers,
|
||||
&req.connection_id,
|
||||
&req.db.to_string(),
|
||||
&format!("Redis command '{cmd_name}'"),
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
super::mcp_policy::ensure_write(
|
||||
&state,
|
||||
&headers,
|
||||
&req.connection_id,
|
||||
&req.db.to_string(),
|
||||
&format!("Redis command '{cmd_name}'"),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
// In read-only mode, only allow safe read commands
|
||||
if let Some(name) = dbx_core::query::connection_readonly_name(&state.app, &req.connection_id).await {
|
||||
let cmd_name = req.command.split_whitespace().next().unwrap_or("");
|
||||
if dbx_core::db::redis_driver::classify_command(cmd_name)
|
||||
!= dbx_core::db::redis_driver::RedisCommandSafety::Allowed
|
||||
{
|
||||
if safety != dbx_core::db::redis_driver::RedisCommandSafety::Allowed {
|
||||
return Err(AppError(format!(
|
||||
"Read-only mode: connection '{}' has read-only protection enabled. Command '{}' blocked.",
|
||||
name, cmd_name
|
||||
|
|
@ -537,7 +565,7 @@ pub async fn execute_command(
|
|||
&req.connection_id,
|
||||
req.db,
|
||||
&req.command,
|
||||
req.skip_safety_check.unwrap_or(false),
|
||||
if is_mcp_request { centrally_approved_high_risk } else { req.skip_safety_check.unwrap_or(false) },
|
||||
)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ AI 助手 → DBX MCP → 你的数据库 → 返回结果
|
|||
}
|
||||
```
|
||||
|
||||
如果使用全局安装,可将 `command` 改为 `dbx-mcp-server`。Windows 便携版请将 `DBX_DATA_DIR` 设置为 `DBX.exe` 同级的 `data` 目录。
|
||||
如果使用全局安装,可将 `command` 改为 `dbx-mcp-server`。连接 allowlist 和执行权限统一在 **DBX 设置 → MCP** 中管理,常规客户端配置不需要权限环境变量。Windows 便携版请将 `DBX_DATA_DIR` 设置为 `DBX.exe` 同级的 `data` 目录。
|
||||
</Step>
|
||||
<Step>
|
||||
### 开始使用
|
||||
|
|
@ -73,8 +73,8 @@ DBX MCP 当前提供 10 个工具:
|
|||
| 工具 | 说明 |
|
||||
| --- | --- |
|
||||
| `dbx_list_connections` | 列出当前 MCP 会话可见的连接 |
|
||||
| `dbx_add_connection` | 添加连接到本地 DBX 存储 |
|
||||
| `dbx_remove_connection` | 从本地 DBX 存储删除连接 |
|
||||
| `dbx_add_connection` | 添加连接到 DBX 存储 |
|
||||
| `dbx_remove_connection` | 从 DBX 存储删除连接 |
|
||||
| `dbx_list_tables` | 列出表、视图或集合 |
|
||||
| `dbx_describe_table` | 返回列定义和表元数据 |
|
||||
| `dbx_get_schema_context` | 返回适合 AI 使用的紧凑 Schema 上下文 |
|
||||
|
|
@ -117,16 +117,26 @@ Oracle、人大金仓和虚谷需要匹配的 DBX 原生 Agent,但不需要 JR
|
|||
|
||||
## 安全和环境变量
|
||||
|
||||
普通写入默认允许。设置 `DBX_MCP_ALLOW_WRITES=0` 可强制只读。`DROP`、`TRUNCATE`、`ALTER`、Redis 破坏性命令以及危险 MongoDB 修改默认仍会阻止,除非设置 `DBX_MCP_ALLOW_DANGEROUS_SQL=1`。
|
||||
DBX 在 **设置 → MCP** 中保存一份权威策略,并在每次请求时重新读取:
|
||||
|
||||
| 权限模式 | 允许的操作 |
|
||||
| --- | --- |
|
||||
| 只读 | 查询和元数据读取 |
|
||||
| 数据读写 | 普通插入、带有效过滤条件的更新/删除、范围明确的 MongoDB 修改和普通 Redis 写入 |
|
||||
| 完全访问 | 额外允许大范围更新/删除、DDL、`TRUNCATE`、MongoDB 破坏性操作和 Redis `FLUSH*` |
|
||||
|
||||
`WHERE TRUE`、`WHERE 1 = 1`、`_id: {$exists: true}` 或不透明 MongoDB 过滤器仍按高风险处理。连接自身只读、生产库保护、数据库账号权限和 MCP 连接 allowlist 在任何模式下都是权限上限。
|
||||
|
||||
新版 Server 不允许 `DBX_MCP_ALLOW_WRITES` 或 `DBX_MCP_ALLOW_DANGEROUS_SQL` 放宽 DBX 中央策略。为兼容升级,在中央策略首次保存前,旧配置中的 `DBX_MCP_ALLOW_WRITES=0`(或 `false`)仍会保持 MCP 只读;策略保存后仅以中央策略为准,并忽略旧权限变量。旧连接 scope 变量只能进一步收窄 DBX allowlist。
|
||||
|
||||
| 变量 | 用途 |
|
||||
| --- | --- |
|
||||
| `DBX_DATA_DIR` | 覆盖本地 DBX 数据目录 |
|
||||
| `DBX_WEB_URL` | 使用 DBX Web/Docker 后端 |
|
||||
| `DBX_WEB_PASSWORD` | 登录 DBX Web |
|
||||
| `DBX_MCP_ALLOW_WRITES` | 设置为 `0` 强制只读 |
|
||||
| `DBX_MCP_ALLOW_DANGEROUS_SQL` | 允许危险 SQL、Redis 和 MongoDB 操作 |
|
||||
| `DBX_MCP_SCOPE_CONNECTION_ID` | 限制为一个连接 ID |
|
||||
| `DBX_MCP_ALLOW_WRITES` | 仅用于升级兼容:`0`/`false` 使尚未配置的策略保持只读 |
|
||||
| `DBX_MCP_SCOPE_CONNECTION_ID` | 兼容旧配置:限制为一个连接 ID |
|
||||
| `DBX_MCP_SCOPE_CONNECTION_IDS` | 兼容旧配置:限制为多个连接 ID |
|
||||
| `DBX_MCP_SCOPE_CONNECTION_NAME` | 限制为一个连接名称 |
|
||||
| `DBX_MCP_SCOPE_DATABASE` | 限制为一个数据库 |
|
||||
| `DBX_MCP_DEBUG_SQL` | 临时诊断时输出 SQL |
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ AI agent → DBX MCP → your database → results
|
|||
}
|
||||
```
|
||||
|
||||
If you installed the package globally, you can use `"command": "dbx-mcp-server"`. For Windows portable DBX, set `DBX_DATA_DIR` to the `data` directory next to `DBX.exe`.
|
||||
If you installed the package globally, you can use `"command": "dbx-mcp-server"`. Manage the connection allowlist and execution mode centrally in **DBX Settings → MCP**; normal client configs do not need permission variables. For Windows portable DBX, set `DBX_DATA_DIR` to the `data` directory next to `DBX.exe`.
|
||||
</Step>
|
||||
<Step>
|
||||
### Start Using
|
||||
|
|
@ -73,8 +73,8 @@ DBX MCP currently provides 10 tools:
|
|||
| Tool | Description |
|
||||
| --- | --- |
|
||||
| `dbx_list_connections` | List connections visible to the MCP session |
|
||||
| `dbx_add_connection` | Add a connection to local DBX storage |
|
||||
| `dbx_remove_connection` | Remove a connection from local DBX storage |
|
||||
| `dbx_add_connection` | Add a connection to DBX storage |
|
||||
| `dbx_remove_connection` | Remove a connection from DBX storage |
|
||||
| `dbx_list_tables` | List tables, views, or collections |
|
||||
| `dbx_describe_table` | Return columns and table metadata |
|
||||
| `dbx_get_schema_context` | Return compact schema context for an AI model |
|
||||
|
|
@ -117,16 +117,26 @@ Set `DBX_WEB_URL` to use a deployed DBX Web backend instead of local connections
|
|||
|
||||
## Safety and Environment Variables
|
||||
|
||||
Writes are enabled by default. Use `DBX_MCP_ALLOW_WRITES=0` for read-only operation. Dangerous SQL such as `DROP`, `TRUNCATE`, and `ALTER`, Redis destructive commands, and dangerous MongoDB mutations remain blocked unless `DBX_MCP_ALLOW_DANGEROUS_SQL=1` is set.
|
||||
DBX stores one authoritative MCP policy under **Settings → MCP** and reloads it for every request:
|
||||
|
||||
| Permission mode | Allowed operations |
|
||||
| --- | --- |
|
||||
| Read only | Queries and metadata reads |
|
||||
| Data read/write | Regular inserts, effectively filtered updates/deletes, scoped MongoDB mutations, and ordinary Redis writes |
|
||||
| Full access | Also permits broad updates/deletes, DDL, `TRUNCATE`, MongoDB destructive operations, and Redis `FLUSH*` |
|
||||
|
||||
An ineffective condition such as `WHERE TRUE`, `WHERE 1 = 1`, `_id: {$exists: true}`, or an opaque MongoDB filter remains high risk. Connection-level read-only protection, production protection, database credentials, and the MCP connection allowlist remain upper bounds in every mode.
|
||||
|
||||
Updated servers do not let `DBX_MCP_ALLOW_WRITES` or `DBX_MCP_ALLOW_DANGEROUS_SQL` widen the DBX policy. For upgrade compatibility, `DBX_MCP_ALLOW_WRITES=0` (or `false`) still keeps MCP read-only until a central policy is saved for the first time. After that, the central policy is authoritative and the legacy permission variables are ignored. Legacy connection-scope variables can only narrow the DBX allowlist.
|
||||
|
||||
| Variable | Purpose |
|
||||
| --- | --- |
|
||||
| `DBX_DATA_DIR` | Override the local DBX data directory |
|
||||
| `DBX_WEB_URL` | Use a DBX Web/Docker backend |
|
||||
| `DBX_WEB_PASSWORD` | Authenticate to DBX Web |
|
||||
| `DBX_MCP_ALLOW_WRITES` | Set to `0` to force read-only execution |
|
||||
| `DBX_MCP_ALLOW_DANGEROUS_SQL` | Allow dangerous SQL, Redis, and MongoDB operations |
|
||||
| `DBX_MCP_SCOPE_CONNECTION_ID` | Restrict the session to one connection ID |
|
||||
| `DBX_MCP_ALLOW_WRITES` | Upgrade compatibility only: `0`/`false` keeps an unconfigured policy read-only |
|
||||
| `DBX_MCP_SCOPE_CONNECTION_ID` | Compatibility scope for one connection ID |
|
||||
| `DBX_MCP_SCOPE_CONNECTION_IDS` | Compatibility scope for multiple connection IDs |
|
||||
| `DBX_MCP_SCOPE_CONNECTION_NAME` | Restrict the session to one connection name |
|
||||
| `DBX_MCP_SCOPE_DATABASE` | Restrict the session to one database |
|
||||
| `DBX_MCP_DEBUG_SQL` | Include SQL in temporary diagnostics |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,171 @@
|
|||
# MCP 中央访问策略交接说明
|
||||
|
||||
## 文档状态
|
||||
|
||||
- 日期:2026-07-18
|
||||
- 分支:`issue_3696`
|
||||
- 关联 Issue:[#3696](https://github.com/t8y2/dbx/issues/3696)、[#3800](https://github.com/t8y2/dbx/issues/3800)
|
||||
- 状态:中央策略已迁移到 Rust MCP 运行时,冲突迁移后的自动化检查通过;仍需使用真实数据库完成发布前人工验证。
|
||||
- 评审:软件 QA、数据库专项复核与 Rust 迁移复查均已完成。
|
||||
|
||||
## 最终产品决策
|
||||
|
||||
MCP 权限集中在“设置 → MCP”管理,不再给每个连接增加 `disabled`、`read_only`、`read_write` 三态选项。
|
||||
|
||||
DBX 持久化一份中央策略:
|
||||
|
||||
```ts
|
||||
interface McpGlobalPolicy {
|
||||
readOnly: boolean;
|
||||
allowDangerousSql: boolean;
|
||||
allowedConnectionIds: string[] | null;
|
||||
}
|
||||
```
|
||||
|
||||
UI 使用单一的“权限模式”选择器,内部仍以两个布尔字段兼容现有存储:
|
||||
|
||||
| UI 模式 | 内部模式值 | `readOnly` | `allowDangerousSql` |
|
||||
| ------- | ---------- | ---------- | ------------------- |
|
||||
| 只读 | `read_only` | `true` | `false` |
|
||||
| 数据读写 | `safe_write` | `false` | `false` |
|
||||
| 完全访问 | `high_risk_write` | `false` | `true` |
|
||||
|
||||
- `allowedConnectionIds=null`:允许全部连接。
|
||||
- `allowedConnectionIds=[]`:不允许任何连接。
|
||||
- 非空数组:只允许其中的稳定连接 ID。
|
||||
- `readOnly=true`:仅允许 DBX 静态分类为读取的请求,阻止已识别的 MCP 数据库写入及连接新增、删除,`allowDangerousSql` 此时不生效。
|
||||
- `readOnly=false && allowDangerousSql=false`:数据读写,允许普通 `INSERT`、带有效过滤条件的 `UPDATE`/`DELETE`、MongoDB 带可验证有效过滤条件的更新/删除,以及 Redis 普通写入和明确键删除。
|
||||
- `readOnly=false && allowDangerousSql=true`:完全访问,在数据读写基础上允许全表 `UPDATE`/`DELETE`、DDL、`TRUNCATE`、MongoDB 清空/结构变更、Redis `FLUSH*` 及等价破坏性操作。
|
||||
- 通用连接 `read_only=true` 继续作为单连接写保护,并同时约束 DBX 与 MCP。
|
||||
- 生产库保护始终是权限上限,不能被高风险操作开关覆盖。
|
||||
- 新版 MCP Server 忽略客户端读写和高风险操作环境变量;当前生成配置不再包含权限或连接范围环境变量。旧客户端 scope 仅作为兼容层继续读取,并且只能收紧连接范围。
|
||||
|
||||
最终连接范围:
|
||||
|
||||
```text
|
||||
effective_connections =
|
||||
stored_connections
|
||||
INTERSECT global_allowed_connection_ids
|
||||
```
|
||||
|
||||
旧配置若仍声明客户端 scope,会在上述结果上继续取交集,但它不再是当前配置方式的一部分。
|
||||
|
||||
最终写权限:
|
||||
|
||||
```text
|
||||
effective_write_allowed =
|
||||
NOT global_mcp_read_only
|
||||
AND NOT connection_read_only
|
||||
AND NOT production_protected
|
||||
```
|
||||
|
||||
高风险操作权限由 `allowDangerousSql` 决定,且仍受上述写权限约束。SQL 中仅出现 `WHERE` 不足以降级风险;缺少有效过滤条件以及 `WHERE TRUE`、`WHERE 1 = 1` 等无效条件仍按高风险操作处理。无法可靠分类的操作失败关闭。
|
||||
|
||||
## 已实现内容
|
||||
|
||||
### 持久化与 API
|
||||
|
||||
- 策略以 `app_settings.settings_json.mcp_global_policy` 原子保存,JSON 字段使用 `readOnly`、`allowDangerousSql`、`allowedConnectionIds`。
|
||||
- 缺少数据库、表、记录或策略字段时按未配置默认值处理:允许全部连接、允许数据读写。
|
||||
- JSON、SQLite 或 Web API 读取异常返回 `MCP_POLICY_UNAVAILABLE`,MCP 工具失败关闭。
|
||||
- Tauri 增加 `load_mcp_global_policy`、`save_mcp_global_policy` 命令。
|
||||
- Web 增加经现有认证中间件保护的 `GET/PUT /api/app-settings/mcp-policy`。
|
||||
- 通用 app settings 保存会保留并发写入的最新 MCP 策略,避免旧设置快照覆盖安全策略。
|
||||
|
||||
### Rust MCP Server 与 backend
|
||||
|
||||
- `DbxBackend` 增加 `load_mcp_global_policy()`,本地模式直接读取 SQLite,Web 模式调用策略 API。
|
||||
- 列表和连接解析始终应用中央 allowlist;旧配置中的客户端 scope 仅作为额外收窄条件兼容读取。
|
||||
- 旧连接 ID、名称和数据库 scope 都是硬上限;请求参数不能覆盖数据库 scope。
|
||||
- 按 ID 或名称访问范围外连接返回 `CONNECTION_OUT_OF_SCOPE`。
|
||||
- Rust MCP 在每个工具请求中重新读取策略;Desktop bridge 和 Web 最终执行边界会再次读取最新策略。
|
||||
- SQL、MongoDB、Redis、`dbx_execute_and_show`、连接新增和删除均使用中央只读与高风险操作策略。
|
||||
- 删除连接前重新解析目标,不能按隐藏 ID 或名称删除 allowlist 外连接。
|
||||
- Web MCP 的 SQL、MongoDB、Redis 和连接保存请求携带 `X-DBX-MCP-Request: 1`,普通 Web UI 请求不携带,因此不受 MCP 全局只读影响。
|
||||
- Web MCP 连接新增、删除使用专用的单连接 `/api/connection/mcp/add` 与 `/api/connection/mcp/remove` 路由;服务端在同一 SQLite 事务中复核最新策略并只修改目标行,避免完整列表快照覆盖 Web UI 的并发连接修改。
|
||||
|
||||
### Desktop bridge 与 Web 最终执行边界
|
||||
|
||||
- Desktop bridge 自身重新读取中央策略,不信任 MCP 请求传入的 `allow_writes`、`allow_dangerous` 或 Redis `skip_safety_check`。
|
||||
- bridge 的连接解析、SQL、MongoDB 聚合/索引/文档写入、Redis 命令均检查 allowlist、全局只读、通用连接只读和生产库保护。
|
||||
- Web query、MongoDB 和 Redis 路由在检测到 MCP 来源标记时执行同样的末端校验。
|
||||
- MongoDB `$out`、`$merge` 只按顶层聚合阶段识别,避免普通字段值误报。
|
||||
- 本地 Rust MCP、Desktop bridge 和 Web 都会检查 `$out`、`$merge` 的跨库目标,完全访问模式也不能写入生产库。
|
||||
- SQL 数据读写允许普通 `INSERT` 和带有效过滤条件的 `UPDATE`/`DELETE`;全表修改、DDL、`TRUNCATE` 以及无法可靠分类的写操作需要完全访问权限。
|
||||
- MongoDB 数据读写允许带可验证有效过滤条件的更新/删除;空过滤、`$where`/`$expr`/`$nor` 等不透明过滤器、`$out`/`$merge` 和结构变更需要完全访问权限。
|
||||
- Redis 数据读写允许已明确分类的普通键值写入和明确键删除;`FLUSHDB`、`FLUSHALL` 等全局破坏性命令需要完全访问权限,未知命令需要完全访问权限。
|
||||
|
||||
### 桌面与 Web 设置 UI
|
||||
|
||||
- MCP 设置页同时支持桌面和 Web 模式。
|
||||
- 两个底层布尔权限字段在 UI 中收敛为互斥的“只读 / 数据读写 / 完全访问”三级选择,内部模式值保持 `read_only / safe_write / high_risk_write` 兼容。
|
||||
- 权限选择器下方展示读取、范围可控的数据变更、全量/清空、结构与高风险管理、连接管理五类能力对照,并明确所有模式仍受连接 allowlist、连接只读、生产库保护和数据库账号权限约束。
|
||||
- 连接多选已升级为 DBX 权威 allowlist,并提供“所有连接”和“不允许任何连接”。
|
||||
- 策略保存期间禁用控件;保存失败回滚并提示;加载失败时禁用策略控件,避免显示可写假象。
|
||||
- 旧 `dbx-mcp-config-readonly=true` 只在后端策略尚未初始化时迁移为全局只读,保存成功后清理旧键。
|
||||
- 旧客户端 scope localStorage 不迁移为全局 allowlist,避免把单客户端偏好意外升级为全局限制。
|
||||
- 生成配置只包含启动 MCP Server 并连接当前 DBX 实例所需的运行参数;Web 模式包含 `DBX_WEB_URL` 和 `DBX_WEB_PASSWORD` 占位值,但不再生成读写、高风险操作或连接 scope 环境变量。
|
||||
- 旧 scope 环境变量仍由新版 Server 兼容读取,但 DBX 不再展示、生成或推荐;升级后可从客户端配置中删除。
|
||||
|
||||
### 连接模型简化
|
||||
|
||||
- Rust 与 TypeScript `ConnectionConfig` 已删除 `mcp_access`。
|
||||
- 连接编辑页、store、i18n、文档和 MCP 运行时不再包含连接级 MCP 三态。
|
||||
- 旧 JSON 中的 `mcp_access` 会被忽略,且后续序列化不再输出该字段;不会迁移为通用连接只读。
|
||||
|
||||
### 原生 MCP 发布链
|
||||
|
||||
- `@dbx-app/mcp-server` 是轻量 Node 启动器,运行时选择对应平台的 Rust `dbx-mcp` 二进制。
|
||||
- `mcp-release.yml` 为 macOS、Linux 和 Windows 构建并发布平台包,再发布 MCP Server 启动器和原生 GitHub Release 归档。
|
||||
- 旧 `packages/node-core` 和 TypeScript MCP 运行时已删除,不再参与构建、测试或发布。
|
||||
- 本轮只修复发布配置和迁移冲突,没有实际向 npm 发布。
|
||||
|
||||
## 自动化验证结果
|
||||
|
||||
```text
|
||||
cargo test -p dbx-core mongo_shell --lib
|
||||
PASS
|
||||
|
||||
cargo test -p dbx-mcp --no-default-features --lib --test protocol
|
||||
PASS
|
||||
|
||||
cargo test -p dbx-web mcp_
|
||||
PASS
|
||||
|
||||
pnpm --filter @dbx-app/mcp-server test
|
||||
PASS
|
||||
|
||||
cargo check -p dbx-core -p dbx-web -p dbx-mcp
|
||||
PASS
|
||||
|
||||
cargo fmt --all -- --check
|
||||
PASS
|
||||
|
||||
git diff --check
|
||||
PASS
|
||||
```
|
||||
|
||||
Rust MCP 回归测试覆盖中央 allowlist、只读模式、旧 scope 交集、数据库 scope 硬约束、稳定策略错误码和 MongoDB 跨库生产目标。MCP npm 启动器测试使用隔离的临时 `DBX_DATA_DIR` 启动真实 Rust 二进制,不读取或修改用户的 DBX 数据库。
|
||||
|
||||
## 发布前人工验证
|
||||
|
||||
1. 所有连接保持普通配置,在 MCP 设置打开全局只读;即使旧客户端仍声明读写或高风险操作环境变量,也不得影响结果。
|
||||
2. 只读模式下 SQL `SELECT`、MongoDB find、Redis GET 成功;SQL/MongoDB/Redis 写入以及 MCP 连接增删均返回只读错误。
|
||||
3. 数据读写模式下,普通 `INSERT`、带有效过滤条件的 `UPDATE`/`DELETE`、MongoDB 带可验证有效过滤条件的更新/删除、Redis 已知普通写入和明确键删除成功。
|
||||
4. 数据读写模式下,全表 `UPDATE`/`DELETE`、`WHERE TRUE`/`WHERE 1 = 1`、DDL、`TRUNCATE`、MongoDB 空过滤清空/结构变更、Redis `FLUSH*` 均被拒绝。
|
||||
5. 完全访问模式下,上述高风险操作仅在连接非只读且未触发生产库保护时允许。
|
||||
6. 不重启 MCP 会话,切换任一策略后下一次请求立即应用新权限。
|
||||
7. allowlist 分别设置为全部、单个、多个、空集,确认列表与按 ID/名称解析符合交集语义。
|
||||
8. 对 Desktop 直连、需要 bridge 的连接和 Web 模式各执行一次三级权限验证。
|
||||
9. 直接向 bridge 传入 `allow_writes=true`、`allow_dangerous=true` 或 `skip_safety_check=true`,确认仍不能覆盖 DBX 中央策略。
|
||||
|
||||
## 已知边界
|
||||
|
||||
- 已经提交到数据库执行的单条语句或事务无法可靠撤销;策略约束下一条语句和后续请求。
|
||||
- MCP SQL 权限是应用层静态语句形状保护;此限制同样适用于只读模式,无法识别 `SELECT app_mutate_users()` 等用户自定义函数或 volatile 函数的所有副作用。
|
||||
- 数据读写不能阻止 Agent 先读取并枚举主键、再通过多次带条件语句逐条修改或删除全部数据。
|
||||
- 数据库账号最小权限仍是最终硬边界;MCP 策略不能替代数据库自身的授权、审计和凭据隔离。
|
||||
- `dbx_execute_and_show` 仅支持 SQL 连接;MongoDB 和 Redis 必须使用各自的执行工具。
|
||||
- 旧 MCP Server 不会读取中央策略,必须同时升级 DBX 应用与 MCP Server;当前生成配置不再为旧 Server 输出权限兼容环境变量。
|
||||
- MCP 策略不是数据库凭据吊销,不能阻止持有凭据的进程绕过 DBX 直接连接数据库。
|
||||
- Web 的 MCP 来源 header 用于区分 DBX Web UI 与 MCP 执行路径;真正的外部访问控制仍由现有 Web 认证负责。
|
||||
|
|
@ -25,6 +25,8 @@ npm install -g @dbx-app/cli
|
|||
npm install -g @dbx-app/mcp-server
|
||||
```
|
||||
|
||||
MCP connection access and execution permissions are configured centrally in **DBX Settings → MCP**. The client examples intentionally contain no permission or connection-scope environment variables.
|
||||
|
||||
## Suggested Learning Path
|
||||
|
||||
1. Read [Getting Started](https://dbxio.com/en/docs/getting-started)
|
||||
|
|
|
|||
|
|
@ -238,6 +238,55 @@ test("evaluateMongoWriteSafety blocks empty-filter find-and-modify unless danger
|
|||
assert.equal(evaluateMongoWriteSafety(command, { allowWrites: true, allowDangerous: true }).allowed, true);
|
||||
});
|
||||
|
||||
test("evaluateMongoWriteSafety fails closed for opaque or effectively unbounded filters", () => {
|
||||
for (const source of [
|
||||
'db.users.deleteMany({"$nor":[{"$expr":false}]})',
|
||||
'db.users.deleteMany({"$or":[{"id":{"$exists":true}},{"id":{"$exists":false}}]})',
|
||||
'db.users.deleteMany({"$or":[{"id":{"$exists":true}},{"id":{"$not":{"$exists":true}}}]})',
|
||||
'db.users.deleteMany({"$or":[{"id":{"$eq":1}},{"id":{"$ne":1}}]})',
|
||||
'db.users.deleteMany({"$or":[{"$and":[{"id":{"$eq":1}}]},{"id":{"$ne":1}}]})',
|
||||
'db.users.deleteMany({"$or":[{"$and":[{"id":{"$eq":1}},{}]},{"id":{"$ne":1}}]})',
|
||||
'db.users.deleteMany({"$or":[{"$and":[{"id":{"$eq":1}},{"x":{"$exists":true}}]},{"id":{"$ne":1}},{"x":{"$exists":false}}]})',
|
||||
'db.users.deleteMany({"$or":[{"id":1},{"id":{"$ne":1}}]})',
|
||||
'db.users.deleteMany({"$or":[{"id":{"$gt":1}},{"id":{"$lte":1}}]})',
|
||||
'db.users.deleteMany({"$or":[{"id":{"$gte":1}},{"id":{"$lt":1}}]})',
|
||||
'db.users.deleteMany({"$or":[{"id":{"$in":[1,2]}},{"id":{"$nin":[2,1]}}]})',
|
||||
'db.users.deleteMany({"_id":{"$exists":true}})',
|
||||
'db.users.deleteMany({"id":{"$nin":[]}})',
|
||||
'db.users.deleteMany({"id":{"$elemMatch":{"value":1}}})',
|
||||
'db.users.deleteMany({"_id":{"$oid":"not-an-object-id"}})',
|
||||
'db.users.deleteMany({"sequence":{"$numberLong":"9223372036854775808"}})',
|
||||
'db.users.deleteMany({"created_at":{"$date":"2026-02-30T00:00:00Z"}})',
|
||||
'db.users.deleteMany({"name":{"$regex":".*"}})',
|
||||
'db.users.deleteMany({"$or":[{"_id":{"$oid":"507f1f77bcf86cd799439011"}},{"_id":{"$ne":{"$oid":"507f1f77bcf86cd799439011"}}}]})',
|
||||
'db.users.updateMany({"$where":"true"},{"$set":{"active":false}})',
|
||||
'db.users.deleteMany({"$or":[]})',
|
||||
'db.users.deleteMany({"$opaque":[{"id":1}]})',
|
||||
]) {
|
||||
const command = parseMongoWriteCommand(source);
|
||||
assert.ok(command, source);
|
||||
assert.equal(evaluateMongoWriteSafety(command, { allowWrites: true, allowDangerous: false }).allowed, false, source);
|
||||
assert.equal(evaluateMongoWriteSafety(command, { allowWrites: true, allowDangerous: true }).allowed, true, source);
|
||||
}
|
||||
|
||||
for (const source of [
|
||||
'db.users.deleteMany({"tenant_id":1})',
|
||||
'db.users.updateMany({"created_at":{"$gte":"2026-01-01"}},{"$set":{"active":false}})',
|
||||
'db.users.deleteMany({"$or":[{"tenant_id":1},{"tenant_id":2}]})',
|
||||
'db.users.deleteMany({"id":{"$ne":1}})',
|
||||
'db.users.deleteMany({"id":{"$in":[1,2]}})',
|
||||
'db.users.deleteMany({"id":{"$exists":true}})',
|
||||
'db.users.updateOne({_id:ObjectId("507f1f77bcf86cd799439011")},{"$set":{"active":true}})',
|
||||
'db.users.deleteMany({"sequence":NumberLong("9223372036854775807")})',
|
||||
'db.users.deleteMany({"created_at":ISODate("2026-01-01T00:00:00.000Z")})',
|
||||
'db.users.deleteMany({"tenant_id":1,"id":{"$nin":[]}})',
|
||||
]) {
|
||||
const command = parseMongoWriteCommand(source);
|
||||
assert.ok(command, source);
|
||||
assert.equal(evaluateMongoWriteSafety(command, { allowWrites: true, allowDangerous: false }).allowed, true, source);
|
||||
}
|
||||
});
|
||||
|
||||
test("parseMongoVersionCommand parses db.version", () => {
|
||||
assert.deepEqual(parseMongoVersionCommand("db.version();"), { kind: "version" });
|
||||
assert.equal(parseMongoVersionCommand("db.jobs.version()"), null);
|
||||
|
|
@ -422,18 +471,19 @@ test("parseMongoWriteCommand rejects invalid dropIndex/dropIndexes variants", ()
|
|||
test("evaluateMongoWriteSafety blocks collection drop unless dangerous writes are enabled", () => {
|
||||
const dropCollection = parseMongoWriteCommand("db.users.drop()");
|
||||
assert.ok(dropCollection);
|
||||
assert.match(evaluateMongoWriteSafety(dropCollection, { allowWrites: true }).reason || "", /DBX_MCP_ALLOW_DANGEROUS_SQL=1/);
|
||||
assert.match(evaluateMongoWriteSafety(dropCollection, { allowWrites: true }).reason || "", /high-risk operations.*DBX MCP settings/i);
|
||||
assert.equal(evaluateMongoWriteSafety(dropCollection, { allowWrites: true, allowDangerous: true }).allowed, true);
|
||||
});
|
||||
|
||||
test("evaluateMongoWriteSafety blocks dangerous dropIndexes shapes unless enabled", () => {
|
||||
test("evaluateMongoWriteSafety requires high-risk permission for schema changes", () => {
|
||||
const dropAll = parseMongoWriteCommand("db.users.dropIndexes()");
|
||||
assert.ok(dropAll);
|
||||
assert.match(evaluateMongoWriteSafety(dropAll, { allowWrites: true }).reason || "", /DBX_MCP_ALLOW_DANGEROUS_SQL=1/);
|
||||
assert.match(evaluateMongoWriteSafety(dropAll, { allowWrites: true }).reason || "", /high-risk operations.*DBX MCP settings/i);
|
||||
|
||||
const dropOne = parseMongoWriteCommand('db.users.dropIndexes("users_email_unique")');
|
||||
assert.ok(dropOne);
|
||||
assert.equal(evaluateMongoWriteSafety(dropOne, { allowWrites: true }).allowed, true);
|
||||
assert.equal(evaluateMongoWriteSafety(dropOne, { allowWrites: true }).allowed, false);
|
||||
assert.equal(evaluateMongoWriteSafety(dropOne, { allowWrites: true, allowDangerous: true }).allowed, true);
|
||||
});
|
||||
|
||||
test("parseMongoCountDocumentsCommand parses db collection countDocuments", () => {
|
||||
|
|
@ -740,16 +790,16 @@ test("splitMongoCommandRanges preserve document offsets for newline-separated co
|
|||
);
|
||||
});
|
||||
|
||||
test("evaluateMongoAggregateSafety blocks write stages unless MCP write flags allow them", () => {
|
||||
test("evaluateMongoAggregateSafety follows the DBX-managed MCP permission level", () => {
|
||||
const out = parseMongoAggregateCommand('db.products.aggregate([{"$out":"products_copy"}])');
|
||||
assert.ok(out);
|
||||
assert.equal(mongoAggregateWriteStage(out.pipeline), "$out");
|
||||
assert.match(evaluateMongoAggregateSafety(out, {}).reason || "", /DBX_MCP_ALLOW_WRITES=1/);
|
||||
assert.match(evaluateMongoAggregateSafety(out, {}).reason || "", /DBX MCP read-only policy/i);
|
||||
|
||||
const merge = parseMongoAggregateCommand('db.products.aggregate([{"$merge":{"into":"products_copy"}}])');
|
||||
assert.ok(merge);
|
||||
assert.equal(mongoAggregateWriteStage(merge.pipeline), "$merge");
|
||||
assert.match(evaluateMongoAggregateSafety(merge, { allowWrites: true }).reason || "", /DBX_MCP_ALLOW_DANGEROUS_SQL=1/);
|
||||
assert.match(evaluateMongoAggregateSafety(merge, { allowWrites: true }).reason || "", /high-risk operations.*DBX MCP settings/i);
|
||||
assert.equal(evaluateMongoAggregateSafety(merge, { allowWrites: true, allowDangerous: true }).allowed, true);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ The MCP protocol, connection loading, SQL safety, schema access, Redis support,
|
|||
- **Local, Web, and Docker modes** using the same tool interface
|
||||
- **Direct native execution** for supported SQL, Redis, and MongoDB connections
|
||||
- **Agent/JDBC database support** through DBX agent infrastructure when the required agent and JRE are installed
|
||||
- **SQL safety controls** for writes, destructive SQL, Redis commands, and MongoDB mutations
|
||||
- **Connection scoping** for limiting an MCP server to one connection or database
|
||||
- **DBX-managed access policy** with a connection allowlist and three execution modes
|
||||
- **SQL, Redis, and MongoDB safety controls** that reload the policy for every request
|
||||
- **Offline execution** through downloadable native binaries
|
||||
- **Optional desktop integration** for opening tables and displaying query results in DBX
|
||||
|
||||
|
|
@ -142,8 +142,8 @@ Ask the MCP client to:
|
|||
| Tool | Description |
|
||||
| --- | --- |
|
||||
| `dbx_list_connections` | List connections visible to the MCP session |
|
||||
| `dbx_add_connection` | Add a connection to local DBX storage |
|
||||
| `dbx_remove_connection` | Remove a connection from local DBX storage |
|
||||
| `dbx_add_connection` | Add a connection to DBX storage |
|
||||
| `dbx_remove_connection` | Remove a connection from DBX storage |
|
||||
| `dbx_list_tables` | List tables, views, or collections |
|
||||
| `dbx_describe_table` | Return columns and table metadata |
|
||||
| `dbx_get_schema_context` | Return compact schema context suitable for an AI model |
|
||||
|
|
@ -213,42 +213,43 @@ Point `DBX_DATA_DIR` at the portable `data` directory containing `dbx.db`:
|
|||
}
|
||||
```
|
||||
|
||||
## Connection Scoping
|
||||
## DBX-managed MCP Policy
|
||||
|
||||
Restrict one MCP server to a connection or database:
|
||||
DBX stores one authoritative policy under **Settings → MCP** and reloads it for every request:
|
||||
|
||||
| Permission mode | Allowed operations |
|
||||
| --- | --- |
|
||||
| Read only | Queries and metadata reads |
|
||||
| Data read/write | Regular inserts, effectively filtered updates/deletes, scoped MongoDB mutations, and ordinary Redis writes |
|
||||
| Full access | Also permits broad updates/deletes, DDL, `TRUNCATE`, MongoDB destructive operations, and Redis `FLUSH*` |
|
||||
|
||||
**Allowed connections** controls which stable connection IDs MCP can list or resolve. Connection-level read-only protection, production protection, database credentials, and the allowlist remain upper bounds in every mode.
|
||||
|
||||
Conditions such as `WHERE TRUE`, `WHERE 1 = 1`, `_id: {$exists: true}`, complementary predicates, and opaque MongoDB filters remain high risk. Unknown Redis commands also fail closed.
|
||||
|
||||
Legacy connection scope variables can still narrow the DBX allowlist for existing client configurations:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"dbx-production-readonly": {
|
||||
"dbx-production-scope": {
|
||||
"command": "dbx-mcp-server",
|
||||
"env": {
|
||||
"DBX_MCP_SCOPE_CONNECTION_NAME": "production-postgres",
|
||||
"DBX_MCP_SCOPE_DATABASE": "analytics",
|
||||
"DBX_MCP_ALLOW_WRITES": "0"
|
||||
"DBX_MCP_SCOPE_DATABASE": "analytics"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use either `DBX_MCP_SCOPE_CONNECTION_ID` or `DBX_MCP_SCOPE_CONNECTION_NAME`. The scoped database is optional.
|
||||
Use `DBX_MCP_SCOPE_CONNECTION_ID`, comma-separated `DBX_MCP_SCOPE_CONNECTION_IDS`, or `DBX_MCP_SCOPE_CONNECTION_NAME`. ID scopes take precedence over the name scope. The scoped database is optional.
|
||||
|
||||
## Safety
|
||||
|
||||
Regular writes are enabled by default. Force a read-only session with:
|
||||
Choose **Read only**, **Data read/write**, or **Full access** in DBX instead of placing permission flags in client configuration. Updated servers do not let `DBX_MCP_ALLOW_WRITES` or `DBX_MCP_ALLOW_DANGEROUS_SQL` widen the DBX policy. For upgrade compatibility, `DBX_MCP_ALLOW_WRITES=0` (or `false`) keeps MCP read-only until a central policy is saved for the first time; the legacy permission variables are ignored afterward.
|
||||
|
||||
```bash
|
||||
DBX_MCP_ALLOW_WRITES=0
|
||||
```
|
||||
|
||||
Dangerous operations such as `DROP`, `TRUNCATE`, `ALTER`, Redis `FLUSHALL`, or dangerous MongoDB mutations remain blocked unless explicitly enabled:
|
||||
|
||||
```bash
|
||||
DBX_MCP_ALLOW_DANGEROUS_SQL=1
|
||||
```
|
||||
|
||||
MongoDB update/delete operations require a non-empty filter unless dangerous operations are enabled. Aggregation stages such as `$out` and `$merge` are treated as writes.
|
||||
MongoDB update/delete operations require a verifiably effective filter unless Full access is enabled. Aggregation stages such as `$out` and `$merge` are treated as high-risk writes.
|
||||
|
||||
SQL text is not included in normal MCP errors or logged by default. Enable temporary diagnostics with `DBX_MCP_DEBUG_SQL=1` and disable it after troubleshooting.
|
||||
|
||||
|
|
@ -259,9 +260,9 @@ SQL text is not included in normal MCP errors or logged by default. Enable tempo
|
|||
| `DBX_DATA_DIR` | Override the local DBX data directory |
|
||||
| `DBX_WEB_URL` | Use a DBX Web/Docker backend |
|
||||
| `DBX_WEB_PASSWORD` | Authenticate to the DBX Web backend |
|
||||
| `DBX_MCP_ALLOW_WRITES` | Set to `0` to force read-only execution |
|
||||
| `DBX_MCP_ALLOW_DANGEROUS_SQL` | Set to `1` to allow dangerous SQL, Redis, and MongoDB operations |
|
||||
| `DBX_MCP_SCOPE_CONNECTION_ID` | Restrict tools to one connection ID |
|
||||
| `DBX_MCP_ALLOW_WRITES` | Upgrade compatibility only: `0`/`false` keeps an unconfigured policy read-only |
|
||||
| `DBX_MCP_SCOPE_CONNECTION_ID` | Compatibility scope for one connection ID |
|
||||
| `DBX_MCP_SCOPE_CONNECTION_IDS` | Compatibility scope for multiple connection IDs |
|
||||
| `DBX_MCP_SCOPE_CONNECTION_NAME` | Restrict tools to one connection name |
|
||||
| `DBX_MCP_SCOPE_DATABASE` | Restrict tools to one database |
|
||||
| `DBX_MCP_DEBUG_SQL` | Include SQL in temporary diagnostics |
|
||||
|
|
@ -329,8 +330,6 @@ Build a release binary:
|
|||
cargo build --release -p dbx-mcp --no-default-features
|
||||
```
|
||||
|
||||
The previous TypeScript MCP implementation remains in `packages/mcp-server/src` for migration tests and compatibility reference; it is not the npm runtime entrypoint.
|
||||
|
||||
## DBX CLI
|
||||
|
||||
`@dbx-app/cli` is a separate terminal-oriented package and currently remains TypeScript/Node.js based:
|
||||
|
|
@ -450,8 +449,8 @@ MCP 配置:
|
|||
| 工具 | 说明 |
|
||||
| --- | --- |
|
||||
| `dbx_list_connections` | 列出当前 MCP 会话可见的连接 |
|
||||
| `dbx_add_connection` | 添加本地连接配置 |
|
||||
| `dbx_remove_connection` | 删除本地连接配置 |
|
||||
| `dbx_add_connection` | 添加 DBX 连接配置 |
|
||||
| `dbx_remove_connection` | 删除 DBX 连接配置 |
|
||||
| `dbx_list_tables` | 列出表、视图或集合 |
|
||||
| `dbx_describe_table` | 获取字段和表结构 |
|
||||
| `dbx_get_schema_context` | 获取适合 AI 使用的紧凑 Schema 上下文 |
|
||||
|
|
@ -492,40 +491,43 @@ Web 模式不会读取本机 DBX 桌面存储,也不会暴露桌面 UI 工具
|
|||
|
||||
npm 和 GitHub Release 中的原生 MCP 文件不会捆绑所有厂商的专有 JDBC Driver。请先通过 DBX Driver Manager 安装对应 Agent 和 JRE,或提供兼容的 DBX Agent 目录。
|
||||
|
||||
### 连接作用域和只读模式
|
||||
### DBX 管理的 MCP 策略
|
||||
|
||||
DBX 在 **设置 → MCP** 中保存一份权威策略,并在每次请求时重新读取:
|
||||
|
||||
| 权限模式 | 允许的操作 |
|
||||
| --- | --- |
|
||||
| 只读 | 查询和元数据读取 |
|
||||
| 数据读写 | 普通插入、带有效过滤条件的更新/删除、范围明确的 MongoDB 修改和普通 Redis 写入 |
|
||||
| 完全访问 | 额外允许大范围更新/删除、DDL、`TRUNCATE`、MongoDB 破坏性操作和 Redis `FLUSH*` |
|
||||
|
||||
**允许访问的连接** 决定 MCP 可以列出和解析哪些稳定连接 ID。连接自身只读、生产库保护、数据库账号权限和 allowlist 在任何模式下都是权限上限。
|
||||
|
||||
`WHERE TRUE`、`WHERE 1 = 1`、`_id: {$exists: true}`、互补条件或不透明 MongoDB 过滤器仍按高风险处理。未知 Redis 命令也会失败关闭。
|
||||
|
||||
旧连接 scope 变量可继续兼容读取,但只能进一步收窄 DBX allowlist:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"dbx-production-readonly": {
|
||||
"dbx-production-scope": {
|
||||
"command": "dbx-mcp-server",
|
||||
"env": {
|
||||
"DBX_MCP_SCOPE_CONNECTION_NAME": "production-postgres",
|
||||
"DBX_MCP_SCOPE_DATABASE": "analytics",
|
||||
"DBX_MCP_ALLOW_WRITES": "0"
|
||||
"DBX_MCP_SCOPE_DATABASE": "analytics"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
作用域模式会隐藏连接增删和桌面 UI 工具。
|
||||
可使用 `DBX_MCP_SCOPE_CONNECTION_ID`、逗号分隔的 `DBX_MCP_SCOPE_CONNECTION_IDS` 或 `DBX_MCP_SCOPE_CONNECTION_NAME`。ID scope 优先于名称 scope;作用域模式会隐藏连接增删和桌面 UI 工具。
|
||||
|
||||
### SQL 和命令安全
|
||||
|
||||
默认允许常规 `INSERT`、`UPDATE`、`DELETE ... WHERE ...`。强制只读:
|
||||
请在 DBX 中选择 **只读**、**数据读写** 或 **完全访问**,不要在客户端配置中放置权限开关。新版 Server 不允许 `DBX_MCP_ALLOW_WRITES` 或 `DBX_MCP_ALLOW_DANGEROUS_SQL` 放宽 DBX 中央策略。为兼容升级,在中央策略首次保存前,`DBX_MCP_ALLOW_WRITES=0`(或 `false`)仍会保持 MCP 只读;策略保存后旧权限变量即被忽略。
|
||||
|
||||
```bash
|
||||
DBX_MCP_ALLOW_WRITES=0
|
||||
```
|
||||
|
||||
允许 `DROP`、`TRUNCATE`、`ALTER`、Redis `FLUSHALL` 或危险 MongoDB 操作:
|
||||
|
||||
```bash
|
||||
DBX_MCP_ALLOW_DANGEROUS_SQL=1
|
||||
```
|
||||
|
||||
MongoDB 更新和删除默认要求非空 filter;`$out`、`$merge` 聚合阶段按写操作处理。
|
||||
MongoDB 更新和删除在未启用完全访问时必须提供可验证有效的 filter;`$out`、`$merge` 聚合阶段按高风险写操作处理。
|
||||
|
||||
### 环境变量
|
||||
|
||||
|
|
@ -534,9 +536,9 @@ MongoDB 更新和删除默认要求非空 filter;`$out`、`$merge` 聚合阶
|
|||
| `DBX_DATA_DIR` | 覆盖本地 DBX 数据目录 |
|
||||
| `DBX_WEB_URL` | 使用 DBX Web/Docker 后端 |
|
||||
| `DBX_WEB_PASSWORD` | DBX Web 登录密码 |
|
||||
| `DBX_MCP_ALLOW_WRITES` | 设置为 `0` 强制只读 |
|
||||
| `DBX_MCP_ALLOW_DANGEROUS_SQL` | 设置为 `1` 允许危险操作 |
|
||||
| `DBX_MCP_SCOPE_CONNECTION_ID` | 限制到指定连接 ID |
|
||||
| `DBX_MCP_ALLOW_WRITES` | 仅用于升级兼容:`0`/`false` 使尚未配置的策略保持只读 |
|
||||
| `DBX_MCP_SCOPE_CONNECTION_ID` | 兼容旧配置:限制到指定连接 ID |
|
||||
| `DBX_MCP_SCOPE_CONNECTION_IDS` | 兼容旧配置:限制到多个连接 ID |
|
||||
| `DBX_MCP_SCOPE_CONNECTION_NAME` | 限制到指定连接名称 |
|
||||
| `DBX_MCP_SCOPE_DATABASE` | 限制到指定数据库 |
|
||||
| `DBX_MCP_DEBUG_SQL` | 临时输出 SQL 诊断信息 |
|
||||
|
|
@ -578,8 +580,6 @@ pnpm --filter @dbx-app/mcp-server test
|
|||
cargo build --release -p dbx-mcp --no-default-features
|
||||
```
|
||||
|
||||
旧 TypeScript MCP 源码仍保留在 `packages/mcp-server/src`,用于迁移测试和兼容参考,不再是 npm 的运行入口。
|
||||
|
||||
### DBX CLI
|
||||
|
||||
`@dbx-app/cli` 是独立的终端包,目前仍使用 TypeScript/Node.js:
|
||||
|
|
|
|||
|
|
@ -25,7 +25,11 @@ test("responds to initialize when invoked through an npm-style symlink", async (
|
|||
try {
|
||||
child = spawn(process.execPath, [bin.path], {
|
||||
cwd: packageDir,
|
||||
env: { ...process.env, DBX_MCP_BINARY: rustBinary },
|
||||
env: {
|
||||
...process.env,
|
||||
DBX_MCP_BINARY: rustBinary,
|
||||
DBX_DATA_DIR: bin.dir,
|
||||
},
|
||||
});
|
||||
|
||||
const responsePromise = readJsonRpcResponse(child, 5000);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use std::{
|
|||
sync::Arc,
|
||||
};
|
||||
|
||||
use dbx_core::storage::DesktopSettings;
|
||||
use dbx_core::storage::{DesktopSettings, McpGlobalPolicy, McpGlobalPolicyState};
|
||||
use tauri::{AppHandle, Manager, State, Window};
|
||||
|
||||
use super::connection::AppState;
|
||||
|
|
@ -75,6 +75,16 @@ pub async fn save_pinned_tree_node_ids(state: State<'_, Arc<AppState>>, ids: Vec
|
|||
state.storage.save_pinned_tree_node_ids(&ids).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_mcp_global_policy(state: State<'_, Arc<AppState>>) -> Result<McpGlobalPolicyState, String> {
|
||||
state.storage.load_mcp_global_policy().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_mcp_global_policy(state: State<'_, Arc<AppState>>, policy: McpGlobalPolicy) -> Result<(), String> {
|
||||
state.storage.save_mcp_global_policy(&policy).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_editor_settings(state: State<'_, Arc<AppState>>) -> Result<Option<serde_json::Value>, String> {
|
||||
state.storage.load_editor_settings().await
|
||||
|
|
|
|||
|
|
@ -7,10 +7,12 @@ use tokio::net::TcpListener;
|
|||
|
||||
use super::connection::AppState;
|
||||
|
||||
use super::connection::ensure_connection_writable;
|
||||
use dbx_core::storage::McpGlobalPolicy;
|
||||
|
||||
const BIND_ADDR: &str = "127.0.0.1:0";
|
||||
const MCP_BRIDGE_PORT_FILE: &str = "mcp-bridge-port";
|
||||
const MCP_EXECUTE_AND_SHOW_SQL_ONLY: &str =
|
||||
"UNSUPPORTED_OPERATION: MCP execute-and-show only supports SQL connections.";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OpenTableRequest {
|
||||
|
|
@ -28,8 +30,6 @@ struct ExecuteQueryRequest {
|
|||
database: Option<String>,
|
||||
sql: String,
|
||||
schema: Option<String>,
|
||||
allow_writes: Option<bool>,
|
||||
allow_dangerous: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
|
@ -174,7 +174,8 @@ struct RedisCommandRequest {
|
|||
connection_id: Option<String>,
|
||||
db: u32,
|
||||
command: String,
|
||||
skip_safety_check: Option<bool>,
|
||||
#[serde(rename = "skip_safety_check")]
|
||||
_skip_safety_check: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
|
|
@ -190,8 +191,7 @@ pub struct McpExecuteQueryEvent {
|
|||
pub connection_id: String,
|
||||
pub database: String,
|
||||
pub sql: String,
|
||||
pub allow_writes: bool,
|
||||
pub allow_dangerous: bool,
|
||||
pub results: Vec<dbx_core::db::QueryResult>,
|
||||
}
|
||||
|
||||
pub fn start(app_handle: AppHandle, state: Arc<AppState>, data_dir: PathBuf) {
|
||||
|
|
@ -290,6 +290,206 @@ fn find_config_by_name<'a>(
|
|||
configs.iter().find(|c| c.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
ensure_connection_in_mcp_scope, ensure_mcp_connection_sql_write_allowed, ensure_mcp_execute_and_show_supported,
|
||||
ensure_mcp_sql_database_switch_allowed, mongo_filter_is_effectively_unbounded, mongo_pipeline_has_write_stage,
|
||||
resolve_connection, resolve_mongo_database, resolve_mongo_target_values, write_port_file, AppState,
|
||||
};
|
||||
use dbx_core::models::connection::{ConnectionConfig, DatabaseType};
|
||||
use dbx_core::storage::{McpGlobalPolicy, Storage};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn mysql_config(read_only: bool) -> ConnectionConfig {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"id": "readonly-connection",
|
||||
"name": "Read-only connection",
|
||||
"db_type": "mysql",
|
||||
"host": "localhost",
|
||||
"port": 3306,
|
||||
"username": "tester",
|
||||
"password": "",
|
||||
"database": "test",
|
||||
"read_only": read_only
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_bridge_port_file_to_resolved_data_dir() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"dbx-mcp-bridge-port-test-{}-{}",
|
||||
std::process::id(),
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
let default_data_dir = root.join("default-app-data");
|
||||
let resolved_data_dir = root.join("resolved-data");
|
||||
std::fs::create_dir_all(&default_data_dir).unwrap();
|
||||
|
||||
let port_file = write_port_file(&resolved_data_dir, 49152).unwrap();
|
||||
|
||||
assert_eq!(port_file, resolved_data_dir.join("mcp-bridge-port"));
|
||||
assert_eq!(std::fs::read_to_string(port_file).unwrap(), "49152");
|
||||
assert!(!default_data_dir.join("mcp-bridge-port").exists());
|
||||
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mongo_database_uses_configured_default_for_missing_or_blank_request() {
|
||||
let configured = Some("sample_db".to_string());
|
||||
|
||||
assert_eq!(resolve_mongo_database(None, configured.clone()), "sample_db");
|
||||
assert_eq!(resolve_mongo_database(Some(String::new()), configured.clone()), "sample_db");
|
||||
assert_eq!(resolve_mongo_database(Some(" ".to_string()), configured), "sample_db");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mongo_database_preserves_explicit_target() {
|
||||
assert_eq!(resolve_mongo_database(Some("admin".to_string()), Some("sample_db".to_string())), "admin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mongo_target_keeps_connection_id_separate_from_database() {
|
||||
assert_eq!(
|
||||
resolve_mongo_target_values(
|
||||
"connection-id".to_string(),
|
||||
Some("sample_db".to_string()),
|
||||
Some("default_db".to_string()),
|
||||
),
|
||||
("connection-id".to_string(), "sample_db".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_and_show_accepts_sql_connections_only() {
|
||||
assert!(ensure_mcp_execute_and_show_supported(&DatabaseType::Mysql).is_ok());
|
||||
assert!(ensure_mcp_execute_and_show_supported(&DatabaseType::MongoDb).is_err());
|
||||
assert!(ensure_mcp_execute_and_show_supported(&DatabaseType::Redis).is_err());
|
||||
assert!(ensure_mcp_execute_and_show_supported(&DatabaseType::Elasticsearch).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_sql_checks_supplied_connection_read_only_flag() {
|
||||
let mut config = mysql_config(true);
|
||||
|
||||
assert!(ensure_mcp_connection_sql_write_allowed(&config, false).is_ok());
|
||||
let error = ensure_mcp_connection_sql_write_allowed(&config, true).unwrap_err();
|
||||
assert!(error.starts_with("CONNECTION_READ_ONLY:"));
|
||||
|
||||
config.read_only = false;
|
||||
assert!(ensure_mcp_connection_sql_write_allowed(&config, true).is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_connection_refreshes_read_only_from_storage() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"dbx-mcp-bridge-connection-refresh-test-{}-{}",
|
||||
std::process::id(),
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let storage = Storage::open(&root.join("storage.db")).await.unwrap();
|
||||
let mut config = mysql_config(false);
|
||||
storage.save_connections(&[config.clone()]).await.unwrap();
|
||||
let state = Arc::new(AppState::new_with_plugin_dir(storage, root.join("plugins")));
|
||||
|
||||
let initial = resolve_connection(&state, Some(&config.id), &config.name).await.unwrap();
|
||||
assert!(!initial.read_only);
|
||||
|
||||
config.read_only = true;
|
||||
state.storage.save_connections(&[config.clone()]).await.unwrap();
|
||||
let refreshed = resolve_connection(&state, Some(&config.id), &config.name).await.unwrap();
|
||||
assert!(refreshed.read_only);
|
||||
|
||||
drop(state);
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_sql_rejects_persistent_database_switches() {
|
||||
assert!(ensure_mcp_sql_database_switch_allowed(DatabaseType::Mysql, "SELECT 1").is_ok());
|
||||
assert_eq!(
|
||||
ensure_mcp_sql_database_switch_allowed(DatabaseType::Mysql, "USE production").unwrap_err(),
|
||||
"SQL_BLOCKED: MCP does not allow USE or persistent database switching."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_allowlist_distinguishes_all_subset_and_none() {
|
||||
let all = McpGlobalPolicy { read_only: false, allow_dangerous_sql: false, allowed_connection_ids: None };
|
||||
assert!(ensure_connection_in_mcp_scope(&all, "conn-1").is_ok());
|
||||
|
||||
let subset = McpGlobalPolicy {
|
||||
read_only: false,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: Some(vec!["conn-1".to_string()]),
|
||||
};
|
||||
assert!(ensure_connection_in_mcp_scope(&subset, "conn-1").is_ok());
|
||||
assert!(ensure_connection_in_mcp_scope(&subset, "conn-2").unwrap_err().starts_with("CONNECTION_OUT_OF_SCOPE:"));
|
||||
|
||||
let none =
|
||||
McpGlobalPolicy { read_only: false, allow_dangerous_sql: false, allowed_connection_ids: Some(Vec::new()) };
|
||||
assert!(ensure_connection_in_mcp_scope(&none, "conn-1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mongo_aggregate_write_stages_are_detected_structurally() {
|
||||
assert!(mongo_pipeline_has_write_stage(r#"[{"$match":{}},{"$out":"archive"}]"#));
|
||||
assert!(mongo_pipeline_has_write_stage(r#"[{"$merge":{"into":"archive"}}]"#));
|
||||
assert!(!mongo_pipeline_has_write_stage(r#"[{"$project":{"label":"$out"}}]"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mongo_filters_distinguish_guarded_and_unbounded_writes() {
|
||||
for filter in [
|
||||
"{}",
|
||||
r#"{"$comment":"all rows"}"#,
|
||||
r#"{"$expr":true}"#,
|
||||
r#"{"$or":[{}, {"id":1}]}"#,
|
||||
r#"{"$nor":[{"$expr":false}]}"#,
|
||||
r#"{"$or":[{"id":{"$exists":true}},{"id":{"$exists":false}}]}"#,
|
||||
r#"{"$or":[{"id":{"$exists":true}},{"id":{"$not":{"$exists":true}}}]}"#,
|
||||
r#"{"$or":[{"id":{"$eq":1}},{"id":{"$ne":1}}]}"#,
|
||||
r#"{"$or":[{"$and":[{"id":{"$eq":1}}]},{"id":{"$ne":1}}]}"#,
|
||||
r#"{"$or":[{"$and":[{"id":{"$eq":1}},{}]},{"id":{"$ne":1}}]}"#,
|
||||
r#"{"$or":[{"$and":[{"id":{"$eq":1}},{"x":{"$exists":true}}]},{"id":{"$ne":1}},{"x":{"$exists":false}}]}"#,
|
||||
r#"{"$or":[{"id":1},{"id":{"$ne":1}}]}"#,
|
||||
r#"{"$or":[{"id":{"$gt":1}},{"id":{"$lte":1}}]}"#,
|
||||
r#"{"$or":[{"id":{"$gte":1}},{"id":{"$lt":1}}]}"#,
|
||||
r#"{"$or":[{"id":{"$in":[1,2]}},{"id":{"$nin":[2,1]}}]}"#,
|
||||
r#"{"_id":{"$exists":true}}"#,
|
||||
r#"{"id":{"$nin":[]}}"#,
|
||||
r#"{"_id":{"$oid":"not-an-object-id"}}"#,
|
||||
r#"{"sequence":{"$numberLong":"9223372036854775808"}}"#,
|
||||
r#"{"created_at":{"$date":"2026-02-30T00:00:00Z"}}"#,
|
||||
r#"{"name":{"$regex":".*"}}"#,
|
||||
r#"{"$or":[{"_id":{"$oid":"507f1f77bcf86cd799439011"}},{"_id":{"$ne":{"$oid":"507f1f77bcf86cd799439011"}}}]}"#,
|
||||
r#"{"$and":[{"tenant_id":1},{"$nor":[{"archived":true}]}]}"#,
|
||||
r#"{"$or":[]}"#,
|
||||
r#"{"$opaque":[{"id":1}]}"#,
|
||||
] {
|
||||
assert!(mongo_filter_is_effectively_unbounded(filter), "{filter}");
|
||||
}
|
||||
for filter in [
|
||||
r#"{"id":1}"#,
|
||||
r#"{"created_at":{"$gte":"2026-01-01"}}"#,
|
||||
r#"{"$and":[{}, {"tenant_id":1}]}"#,
|
||||
r#"{"$or":[{"tenant_id":1},{"tenant_id":2}]}"#,
|
||||
r#"{"id":{"$ne":1}}"#,
|
||||
r#"{"id":{"$in":[1,2]}}"#,
|
||||
r#"{"id":{"$exists":true}}"#,
|
||||
r#"{"_id":{"$oid":"507f1f77bcf86cd799439011"}}"#,
|
||||
r#"{"sequence":{"$numberLong":"9223372036854775807"}}"#,
|
||||
r#"{"created_at":{"$date":"2026-01-01T00:00:00.000Z"}}"#,
|
||||
r#"{"tenant_id":1,"id":{"$nin":[]}}"#,
|
||||
] {
|
||||
assert!(!mongo_filter_is_effectively_unbounded(filter), "{filter}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn respond(stream: &mut tokio::net::TcpStream, status: &str, body: &str) {
|
||||
let resp = format!("HTTP/1.1 {status}\r\nContent-Length: {}\r\n\r\n{body}", body.len());
|
||||
let _ = stream.write_all(resp.as_bytes()).await;
|
||||
|
|
@ -314,12 +514,14 @@ async fn resolve_connection(
|
|||
connection_id: Option<&str>,
|
||||
connection_name: &str,
|
||||
) -> Result<crate::models::connection::ConnectionConfig, String> {
|
||||
let configs = state.storage.load_connections().await.map_err(|e| e.to_string())?;
|
||||
let policy = load_mcp_policy(state).await?;
|
||||
let configs = state.storage.load_connections().await.map_err(|e| mcp_policy_unavailable(e.to_string()))?;
|
||||
let config = if let Some(id) = connection_id.filter(|s| !s.is_empty()) {
|
||||
configs.iter().find(|c| c.id == id).ok_or_else(|| format!("Connection with id '{}' not found", id))?
|
||||
} else {
|
||||
find_config_by_name(&configs, connection_name).ok_or_else(|| "Connection not found".to_string())?
|
||||
};
|
||||
ensure_connection_in_mcp_scope(&policy, &config.id)?;
|
||||
let mut state_configs = state.configs.write().await;
|
||||
if !state_configs.contains_key(&config.id) {
|
||||
state_configs.insert(config.id.clone(), config.clone());
|
||||
|
|
@ -328,6 +530,422 @@ async fn resolve_connection(
|
|||
Ok(config.clone())
|
||||
}
|
||||
|
||||
async fn load_mcp_policy(state: &Arc<AppState>) -> Result<McpGlobalPolicy, String> {
|
||||
state
|
||||
.storage
|
||||
.load_mcp_global_policy()
|
||||
.await
|
||||
.map(|state| state.policy())
|
||||
.map_err(|error| mcp_policy_unavailable(error.to_string()))
|
||||
}
|
||||
|
||||
fn mcp_policy_unavailable(error: String) -> String {
|
||||
if error.starts_with("MCP_POLICY_UNAVAILABLE:") {
|
||||
error
|
||||
} else {
|
||||
format!("MCP_POLICY_UNAVAILABLE: {error}")
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_connection_in_mcp_scope(policy: &McpGlobalPolicy, connection_id: &str) -> Result<(), String> {
|
||||
if policy.allowed_connection_ids.as_ref().is_some_and(|allowed| !allowed.iter().any(|id| id == connection_id)) {
|
||||
return Err(format!(
|
||||
"CONNECTION_OUT_OF_SCOPE: connection '{connection_id}' is not allowed by DBX MCP settings"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_mcp_write_allowed(
|
||||
state: &Arc<AppState>,
|
||||
config: &crate::models::connection::ConnectionConfig,
|
||||
database: &str,
|
||||
action: &str,
|
||||
) -> Result<(), String> {
|
||||
ensure_mcp_write_allowed_with_risk(state, config, database, action, false).await
|
||||
}
|
||||
|
||||
async fn ensure_mcp_write_allowed_with_risk(
|
||||
state: &Arc<AppState>,
|
||||
config: &crate::models::connection::ConnectionConfig,
|
||||
database: &str,
|
||||
action: &str,
|
||||
dangerous: bool,
|
||||
) -> Result<(), String> {
|
||||
let policy = load_mcp_policy(state).await?;
|
||||
ensure_connection_in_mcp_scope(&policy, &config.id)?;
|
||||
if policy.read_only {
|
||||
return Err(format!("MCP_READ_ONLY: DBX MCP read-only mode is enabled. {action} blocked."));
|
||||
}
|
||||
if dangerous && !policy.allow_dangerous_sql {
|
||||
return Err(format!("SQL_BLOCKED: High-risk operation '{action}' is disabled in DBX MCP settings."));
|
||||
}
|
||||
if config.read_only {
|
||||
return Err(format!(
|
||||
"CONNECTION_READ_ONLY: connection '{}' has read-only protection enabled. {action} blocked.",
|
||||
config.name
|
||||
));
|
||||
}
|
||||
if dbx_core::production_safety::is_production_database(config, database) {
|
||||
return Err(format!("PRODUCTION_DATABASE_READ_ONLY: {action} blocked for production database '{database}'."));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_mcp_read_allowed_by_id(
|
||||
state: &Arc<AppState>,
|
||||
connection_id: &str,
|
||||
database: &str,
|
||||
) -> Result<(), String> {
|
||||
let policy = load_mcp_policy(state).await?;
|
||||
ensure_connection_in_mcp_scope(&policy, connection_id)?;
|
||||
let configs = state.storage.load_connections().await.map_err(|e| format!("MCP_POLICY_UNAVAILABLE: {e}"))?;
|
||||
let config = configs
|
||||
.iter()
|
||||
.find(|config| config.id == connection_id)
|
||||
.ok_or_else(|| format!("Connection with id '{connection_id}' not found"))?;
|
||||
check_visible_database(config, database)
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_mcp_write_allowed_by_id(
|
||||
state: &Arc<AppState>,
|
||||
connection_id: &str,
|
||||
database: &str,
|
||||
action: &str,
|
||||
) -> Result<(), String> {
|
||||
ensure_mcp_write_allowed_by_id_with_risk(state, connection_id, database, action, false).await
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_mcp_dangerous_write_allowed_by_id(
|
||||
state: &Arc<AppState>,
|
||||
connection_id: &str,
|
||||
database: &str,
|
||||
action: &str,
|
||||
) -> Result<(), String> {
|
||||
ensure_mcp_write_allowed_by_id_with_risk(state, connection_id, database, action, true).await
|
||||
}
|
||||
|
||||
async fn ensure_mcp_mongo_pipeline_target_allowed_by_id(
|
||||
state: &Arc<AppState>,
|
||||
connection_id: &str,
|
||||
database: &str,
|
||||
pipeline_json: &str,
|
||||
) -> Result<(), String> {
|
||||
let configs = state.storage.load_connections().await.map_err(|e| format!("MCP_POLICY_UNAVAILABLE: {e}"))?;
|
||||
let config = configs
|
||||
.iter()
|
||||
.find(|config| config.id == connection_id)
|
||||
.ok_or_else(|| format!("Connection with id '{connection_id}' not found"))?;
|
||||
if dbx_core::production_safety::mongo_pipeline_targets_production_database(config, database, pipeline_json) {
|
||||
return Err(
|
||||
"PRODUCTION_DATABASE_READ_ONLY: MongoDB aggregate write targeting production scope is blocked.".to_string()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_mcp_write_allowed_by_id_with_risk(
|
||||
state: &Arc<AppState>,
|
||||
connection_id: &str,
|
||||
database: &str,
|
||||
action: &str,
|
||||
dangerous: bool,
|
||||
) -> Result<(), String> {
|
||||
let configs = state.storage.load_connections().await.map_err(|e| format!("MCP_POLICY_UNAVAILABLE: {e}"))?;
|
||||
let config = configs
|
||||
.iter()
|
||||
.find(|config| config.id == connection_id)
|
||||
.ok_or_else(|| format!("Connection with id '{connection_id}' not found"))?;
|
||||
ensure_mcp_write_allowed_with_risk(state, config, database, action, dangerous).await
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_mcp_mongo_filtered_write_allowed_by_id(
|
||||
state: &Arc<AppState>,
|
||||
connection_id: &str,
|
||||
database: &str,
|
||||
action: &str,
|
||||
filter_json: &str,
|
||||
) -> Result<(), String> {
|
||||
if mongo_filter_is_effectively_unbounded(filter_json) {
|
||||
ensure_mcp_dangerous_write_allowed_by_id(state, connection_id, database, action).await
|
||||
} else {
|
||||
ensure_mcp_write_allowed_by_id(state, connection_id, database, action).await
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_mcp_mongo_aggregate_allowed_by_id(
|
||||
state: &Arc<AppState>,
|
||||
connection_id: &str,
|
||||
database: &str,
|
||||
pipeline_json: &str,
|
||||
) -> Result<(), String> {
|
||||
if !mongo_pipeline_has_write_stage(pipeline_json) {
|
||||
return ensure_mcp_read_allowed_by_id(state, connection_id, database).await;
|
||||
}
|
||||
ensure_mcp_dangerous_write_allowed_by_id(state, connection_id, database, "MongoDB aggregate write").await?;
|
||||
ensure_mcp_mongo_pipeline_target_allowed_by_id(state, connection_id, database, pipeline_json).await
|
||||
}
|
||||
|
||||
fn mongo_pipeline_has_write_stage(pipeline_json: &str) -> bool {
|
||||
serde_json::from_str::<serde_json::Value>(pipeline_json)
|
||||
.ok()
|
||||
.and_then(|value| value.as_array().cloned())
|
||||
.is_some_and(|stages| {
|
||||
stages.iter().any(|stage| {
|
||||
stage
|
||||
.as_object()
|
||||
.is_some_and(|document| document.contains_key("$out") || document.contains_key("$merge"))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn mongo_filter_is_effectively_unbounded(filter_json: &str) -> bool {
|
||||
serde_json::from_str::<serde_json::Value>(filter_json)
|
||||
.ok()
|
||||
.as_ref()
|
||||
.map_or(true, |value| mongo_filter_contains_opaque_logic(value) || mongo_filter_value_is_unbounded(value))
|
||||
}
|
||||
|
||||
fn mongo_filter_contains_opaque_logic(value: &serde_json::Value) -> bool {
|
||||
let Some(filter) = value.as_object() else {
|
||||
return true;
|
||||
};
|
||||
filter.iter().any(|(key, value)| match key.as_str() {
|
||||
"$comment" => false,
|
||||
"$where" | "$expr" | "$nor" => true,
|
||||
"$and" | "$or" => {
|
||||
let Some(clauses) = value.as_array() else {
|
||||
return true;
|
||||
};
|
||||
clauses.is_empty()
|
||||
|| clauses.iter().any(|clause| !clause.is_object() || mongo_filter_contains_opaque_logic(clause))
|
||||
|| (key == "$or"
|
||||
&& clauses
|
||||
.iter()
|
||||
.any(|clause| clause.as_object().is_some_and(|document| document.contains_key("$and"))))
|
||||
|| (key == "$or" && mongo_or_has_complementary_field_clauses(clauses))
|
||||
}
|
||||
_ => key.starts_with('$') || mongo_field_predicate_contains_opaque_logic(value),
|
||||
})
|
||||
}
|
||||
|
||||
fn mongo_field_predicate_contains_opaque_logic(value: &serde_json::Value) -> bool {
|
||||
let Some(predicate) = value.as_object() else {
|
||||
return false;
|
||||
};
|
||||
if mongo_extended_json_scalar_literal_is_valid(value) {
|
||||
return false;
|
||||
}
|
||||
let has_operator = predicate.keys().any(|key| key.starts_with('$'));
|
||||
has_operator
|
||||
&& predicate.keys().any(|key| {
|
||||
!matches!(key.as_str(), "$eq" | "$ne" | "$gt" | "$gte" | "$lt" | "$lte" | "$in" | "$nin" | "$exists")
|
||||
})
|
||||
}
|
||||
|
||||
fn mongo_extended_json_scalar_literal_is_valid(value: &serde_json::Value) -> bool {
|
||||
let Some(wrapper) = value.as_object().filter(|wrapper| wrapper.len() == 1) else {
|
||||
return false;
|
||||
};
|
||||
if let Some(value) = wrapper.get("$oid").and_then(serde_json::Value::as_str) {
|
||||
return value.len() == 24 && value.bytes().all(|byte| byte.is_ascii_hexdigit());
|
||||
}
|
||||
if let Some(value) = wrapper.get("$numberLong").and_then(serde_json::Value::as_str) {
|
||||
return value.parse::<i64>().is_ok();
|
||||
}
|
||||
wrapper
|
||||
.get("$date")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|value| chrono::DateTime::parse_from_rfc3339(value).is_ok())
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum MongoFieldOperator {
|
||||
Eq,
|
||||
Ne,
|
||||
Gt,
|
||||
Gte,
|
||||
Lt,
|
||||
Lte,
|
||||
In,
|
||||
Nin,
|
||||
Exists,
|
||||
}
|
||||
|
||||
struct MongoPureFieldPredicate<'a> {
|
||||
field: &'a str,
|
||||
operator: MongoFieldOperator,
|
||||
operand: &'a serde_json::Value,
|
||||
}
|
||||
|
||||
fn mongo_or_has_complementary_field_clauses(clauses: &[serde_json::Value]) -> bool {
|
||||
clauses.iter().enumerate().any(|(index, clause)| {
|
||||
let Some(predicate) = mongo_pure_field_predicate(clause) else {
|
||||
return false;
|
||||
};
|
||||
clauses[index + 1..]
|
||||
.iter()
|
||||
.filter_map(mongo_pure_field_predicate)
|
||||
.any(|other| mongo_field_predicates_are_complementary(&predicate, &other))
|
||||
})
|
||||
}
|
||||
|
||||
fn mongo_pure_field_predicate(value: &serde_json::Value) -> Option<MongoPureFieldPredicate<'_>> {
|
||||
let filter = value.as_object()?;
|
||||
let mut entries = filter.iter().filter(|(key, _)| key.as_str() != "$comment");
|
||||
let (field, predicate) = entries.next()?;
|
||||
if entries.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
if field == "$and" {
|
||||
let clauses = predicate.as_array()?;
|
||||
let mut bounded = clauses.iter().filter(|clause| !mongo_filter_value_is_unbounded(clause));
|
||||
let clause = bounded.next()?;
|
||||
if bounded.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
return mongo_pure_field_predicate(clause);
|
||||
}
|
||||
if field == "$or" {
|
||||
let clauses = predicate.as_array()?;
|
||||
return (clauses.len() == 1).then(|| mongo_pure_field_predicate(&clauses[0])).flatten();
|
||||
}
|
||||
if field.starts_with('$') {
|
||||
return None;
|
||||
}
|
||||
let Some(operator_document) = predicate.as_object() else {
|
||||
return Some(MongoPureFieldPredicate { field, operator: MongoFieldOperator::Eq, operand: predicate });
|
||||
};
|
||||
if mongo_extended_json_scalar_literal_is_valid(predicate)
|
||||
|| !operator_document.keys().any(|key| key.starts_with('$'))
|
||||
{
|
||||
return Some(MongoPureFieldPredicate { field, operator: MongoFieldOperator::Eq, operand: predicate });
|
||||
}
|
||||
let mut operators = operator_document.iter();
|
||||
let (operator, operand) = operators.next()?;
|
||||
if operators.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
let operator = match operator.as_str() {
|
||||
"$eq" => MongoFieldOperator::Eq,
|
||||
"$ne" => MongoFieldOperator::Ne,
|
||||
"$gt" => MongoFieldOperator::Gt,
|
||||
"$gte" => MongoFieldOperator::Gte,
|
||||
"$lt" => MongoFieldOperator::Lt,
|
||||
"$lte" => MongoFieldOperator::Lte,
|
||||
"$in" => MongoFieldOperator::In,
|
||||
"$nin" => MongoFieldOperator::Nin,
|
||||
"$exists" => MongoFieldOperator::Exists,
|
||||
_ => return None,
|
||||
};
|
||||
Some(MongoPureFieldPredicate { field, operator, operand })
|
||||
}
|
||||
|
||||
fn mongo_field_predicates_are_complementary(
|
||||
left: &MongoPureFieldPredicate<'_>,
|
||||
right: &MongoPureFieldPredicate<'_>,
|
||||
) -> bool {
|
||||
if left.field != right.field {
|
||||
return false;
|
||||
}
|
||||
use MongoFieldOperator::{Eq, Exists, Gt, Gte, In, Lt, Lte, Ne, Nin};
|
||||
match (left.operator, right.operator) {
|
||||
(Exists, Exists) => {
|
||||
left.operand.as_bool().zip(right.operand.as_bool()).is_some_and(|(left, right)| left != right)
|
||||
}
|
||||
(In, Nin) | (Nin, In) => mongo_json_sets_equal(left.operand, right.operand),
|
||||
(Eq, Ne) | (Ne, Eq) | (Gt, Lte) | (Lte, Gt) | (Gte, Lt) | (Lt, Gte) => left.operand == right.operand,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn mongo_json_sets_equal(left: &serde_json::Value, right: &serde_json::Value) -> bool {
|
||||
let (Some(left), Some(right)) = (left.as_array(), right.as_array()) else {
|
||||
return false;
|
||||
};
|
||||
left.iter().all(|value| right.contains(value)) && right.iter().all(|value| left.contains(value))
|
||||
}
|
||||
|
||||
fn mongo_filter_value_is_unbounded(value: &serde_json::Value) -> bool {
|
||||
let Some(filter) = value.as_object() else {
|
||||
return true;
|
||||
};
|
||||
if filter.is_empty() || filter.contains_key("$where") || filter.contains_key("$expr") {
|
||||
return true;
|
||||
}
|
||||
filter.iter().all(|(key, value)| match key.as_str() {
|
||||
"$comment" => true,
|
||||
"$and" => value
|
||||
.as_array()
|
||||
.map_or(true, |clauses| clauses.is_empty() || clauses.iter().all(mongo_filter_value_is_unbounded)),
|
||||
"$or" => value
|
||||
.as_array()
|
||||
.map_or(true, |clauses| clauses.is_empty() || clauses.iter().any(mongo_filter_value_is_unbounded)),
|
||||
"$nor" => true,
|
||||
_ if mongo_field_predicate_is_empty_nin(value) => true,
|
||||
"_id" if mongo_field_predicate_is_exists_true(value) => true,
|
||||
_ => key.starts_with('$'),
|
||||
})
|
||||
}
|
||||
|
||||
fn mongo_field_predicate_is_empty_nin(value: &serde_json::Value) -> bool {
|
||||
value.as_object().is_some_and(|predicate| {
|
||||
predicate.len() == 1 && predicate.get("$nin").and_then(serde_json::Value::as_array).is_some_and(Vec::is_empty)
|
||||
})
|
||||
}
|
||||
|
||||
fn mongo_field_predicate_is_exists_true(value: &serde_json::Value) -> bool {
|
||||
value.as_object().is_some_and(|predicate| {
|
||||
predicate.len() == 1 && predicate.get("$exists").and_then(serde_json::Value::as_bool) == Some(true)
|
||||
})
|
||||
}
|
||||
|
||||
async fn ensure_mcp_sql_allowed(
|
||||
state: &Arc<AppState>,
|
||||
config: &crate::models::connection::ConnectionConfig,
|
||||
database: &str,
|
||||
sql: &str,
|
||||
) -> Result<(), String> {
|
||||
let policy = load_mcp_policy(state).await?;
|
||||
ensure_connection_in_mcp_scope(&policy, &config.id)?;
|
||||
ensure_mcp_sql_database_switch_allowed(config.db_type, sql)?;
|
||||
let is_write = dbx_core::query_execution_sql::is_write_sql_for_database(sql, config.db_type);
|
||||
if policy.read_only && is_write {
|
||||
return Err("MCP_READ_ONLY: DBX MCP read-only mode is enabled. SQL write blocked.".to_string());
|
||||
}
|
||||
if !policy.allow_dangerous_sql && dbx_core::sql_risk::is_dangerous_sql_for_database(sql, config.db_type) {
|
||||
return Err("SQL_BLOCKED: High-risk SQL is disabled in DBX MCP settings.".to_string());
|
||||
}
|
||||
ensure_mcp_connection_sql_write_allowed(config, is_write)?;
|
||||
if is_write && dbx_core::production_safety::targets_production_database(config, database, sql) {
|
||||
return Err("PRODUCTION_DATABASE_READ_ONLY: SQL write targeting production scope is blocked.".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_mcp_sql_database_switch_allowed(
|
||||
database_type: crate::models::connection::DatabaseType,
|
||||
sql: &str,
|
||||
) -> Result<(), String> {
|
||||
if dbx_core::sql_risk::mcp_sql_has_forbidden_database_switch(sql, database_type) {
|
||||
return Err("SQL_BLOCKED: MCP does not allow USE or persistent database switching.".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_mcp_connection_sql_write_allowed(
|
||||
config: &crate::models::connection::ConnectionConfig,
|
||||
is_write: bool,
|
||||
) -> Result<(), String> {
|
||||
if is_write && config.read_only {
|
||||
return Err(format!(
|
||||
"CONNECTION_READ_ONLY: connection '{}' has read-only protection enabled. SQL write blocked.",
|
||||
config.name
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_visible_database(config: &crate::models::connection::ConnectionConfig, database: &str) -> Result<(), String> {
|
||||
if let Some(ref visible) = config.visible_databases {
|
||||
if !visible.is_empty() && !visible.iter().any(|v| v == database) {
|
||||
|
|
@ -411,17 +1029,60 @@ async fn handle_execute_query(app: &AppHandle, state: &Arc<AppState>, body: &str
|
|||
return;
|
||||
}
|
||||
};
|
||||
let event = McpExecuteQueryEvent {
|
||||
connection_id: config.id.clone(),
|
||||
database: req.database.unwrap_or_else(|| config.database.clone().unwrap_or_default()),
|
||||
sql: req.sql,
|
||||
allow_writes: req.allow_writes.unwrap_or(false),
|
||||
allow_dangerous: req.allow_dangerous.unwrap_or(false),
|
||||
let database = req.database.unwrap_or_else(|| config.database.clone().unwrap_or_default());
|
||||
if let Err(error) = ensure_mcp_execute_and_show_supported(&config.db_type) {
|
||||
respond(stream, "400 Bad Request", error).await;
|
||||
return;
|
||||
}
|
||||
// Check the complete batch first so a session-changing statement cannot
|
||||
// hide a later production write, then recheck every statement immediately
|
||||
// before it reaches the database.
|
||||
if let Err(e) = ensure_mcp_sql_allowed(state, &config, &database, &req.sql).await {
|
||||
respond(stream, "403 Forbidden", &e).await;
|
||||
return;
|
||||
}
|
||||
let statements = if config.db_type == crate::models::connection::DatabaseType::SqlServer {
|
||||
dbx_core::sql::split_sql_batches(&req.sql)
|
||||
} else {
|
||||
dbx_core::sql::split_sql_statements_for_database(&req.sql, config.db_type)
|
||||
};
|
||||
let statements = if statements.is_empty() { vec![req.sql.clone()] } else { statements };
|
||||
let mut results = Vec::with_capacity(statements.len());
|
||||
for statement in statements {
|
||||
let current = match resolve_connection(state, Some(&config.id), &config.name).await {
|
||||
Ok(config) => config,
|
||||
Err(e) => {
|
||||
respond(stream, "403 Forbidden", &e).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(e) = ensure_mcp_sql_allowed(state, ¤t, &database, &statement).await {
|
||||
respond(stream, "403 Forbidden", &e).await;
|
||||
return;
|
||||
}
|
||||
match dbx_core::query::execute_sql_statement(state, ¤t.id, &database, &statement, None, None).await {
|
||||
Ok(result) => results.push(result),
|
||||
Err(e) => {
|
||||
respond(stream, "500 Internal Server Error", &format!("QUERY_ERROR: {e}")).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let event = McpExecuteQueryEvent { connection_id: config.id.clone(), database, sql: req.sql, results };
|
||||
let _ = app.emit("mcp-execute-query", &event);
|
||||
respond(stream, "200 OK", "ok").await;
|
||||
}
|
||||
|
||||
fn ensure_mcp_execute_and_show_supported(
|
||||
database_type: &crate::models::connection::DatabaseType,
|
||||
) -> Result<(), &'static str> {
|
||||
if dbx_core::query_execution_sql::supports_sql_query(*database_type) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(MCP_EXECUTE_AND_SHOW_SQL_ONLY)
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_list_tables_data(state: &Arc<AppState>, body: &str, stream: &mut tokio::net::TcpStream) {
|
||||
let req: ListTablesRequest = match serde_json::from_str(body) {
|
||||
Ok(r) => r,
|
||||
|
|
@ -607,6 +1268,11 @@ async fn handle_mongo_aggregate_documents_data(state: &Arc<AppState>, body: &str
|
|||
else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = ensure_mcp_mongo_aggregate_allowed_by_id(state, &connection_id, &database, &req.pipeline_json).await
|
||||
{
|
||||
respond_error(stream, "403 Forbidden", &e).await;
|
||||
return;
|
||||
}
|
||||
match dbx_core::mongo_ops::mongo_aggregate_documents_core(
|
||||
state,
|
||||
&connection_id,
|
||||
|
|
@ -664,7 +1330,7 @@ async fn handle_mongo_create_index_data(state: &Arc<AppState>, body: &str, strea
|
|||
else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = ensure_connection_writable(state, &connection_id, "Create index").await {
|
||||
if let Err(e) = ensure_mcp_dangerous_write_allowed_by_id(state, &connection_id, &database, "Create index").await {
|
||||
respond_error(stream, "403 Forbidden", &e).await;
|
||||
return;
|
||||
}
|
||||
|
|
@ -696,7 +1362,7 @@ async fn handle_mongo_drop_indexes_data(state: &Arc<AppState>, body: &str, strea
|
|||
else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = ensure_connection_writable(state, &connection_id, "Drop indexes").await {
|
||||
if let Err(e) = ensure_mcp_dangerous_write_allowed_by_id(state, &connection_id, &database, "Drop indexes").await {
|
||||
respond_error(stream, "403 Forbidden", &e).await;
|
||||
return;
|
||||
}
|
||||
|
|
@ -728,7 +1394,8 @@ async fn handle_mongo_drop_collection_data(state: &Arc<AppState>, body: &str, st
|
|||
else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = ensure_connection_writable(state, &connection_id, "Drop collection").await {
|
||||
if let Err(e) = ensure_mcp_dangerous_write_allowed_by_id(state, &connection_id, &database, "Drop collection").await
|
||||
{
|
||||
respond_error(stream, "403 Forbidden", &e).await;
|
||||
return;
|
||||
}
|
||||
|
|
@ -751,7 +1418,7 @@ async fn handle_mongo_insert_documents_data(state: &Arc<AppState>, body: &str, s
|
|||
else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = ensure_connection_writable(state, &connection_id, "Insert").await {
|
||||
if let Err(e) = ensure_mcp_write_allowed_by_id(state, &connection_id, &database, "Insert").await {
|
||||
respond_error(stream, "403 Forbidden", &e).await;
|
||||
return;
|
||||
}
|
||||
|
|
@ -782,7 +1449,12 @@ async fn handle_mongo_update_documents_data(state: &Arc<AppState>, body: &str, s
|
|||
else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = ensure_connection_writable(state, &connection_id, "Update").await {
|
||||
let policy_check = if mongo_filter_is_effectively_unbounded(&req.filter_json) {
|
||||
ensure_mcp_dangerous_write_allowed_by_id(state, &connection_id, &database, "Update").await
|
||||
} else {
|
||||
ensure_mcp_write_allowed_by_id(state, &connection_id, &database, "Update").await
|
||||
};
|
||||
if let Err(e) = policy_check {
|
||||
respond_error(stream, "403 Forbidden", &e).await;
|
||||
return;
|
||||
}
|
||||
|
|
@ -816,7 +1488,12 @@ async fn handle_mongo_delete_documents_data(state: &Arc<AppState>, body: &str, s
|
|||
else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = ensure_connection_writable(state, &connection_id, "Delete").await {
|
||||
let policy_check = if mongo_filter_is_effectively_unbounded(&req.filter_json) {
|
||||
ensure_mcp_dangerous_write_allowed_by_id(state, &connection_id, &database, "Delete").await
|
||||
} else {
|
||||
ensure_mcp_write_allowed_by_id(state, &connection_id, &database, "Delete").await
|
||||
};
|
||||
if let Err(e) = policy_check {
|
||||
respond_error(stream, "403 Forbidden", &e).await;
|
||||
return;
|
||||
}
|
||||
|
|
@ -855,20 +1532,25 @@ async fn handle_redis_execute_command_data(state: &Arc<AppState>, body: &str, st
|
|||
respond_error(stream, "403 Forbidden", &e).await;
|
||||
return;
|
||||
}
|
||||
if let Some(name) = dbx_core::query::connection_readonly_name(state, &config.id).await {
|
||||
let cmd_name = req.command.split_whitespace().next().unwrap_or("");
|
||||
if dbx_core::db::redis_driver::classify_command(cmd_name)
|
||||
!= dbx_core::db::redis_driver::RedisCommandSafety::Allowed
|
||||
{
|
||||
respond_error(
|
||||
stream,
|
||||
"403 Forbidden",
|
||||
&format!(
|
||||
"Read-only mode: connection '{}' has read-only protection enabled. Command '{}' blocked.",
|
||||
name, cmd_name
|
||||
),
|
||||
)
|
||||
.await;
|
||||
let argv = match dbx_core::db::redis_driver::parse_command_argv(&req.command) {
|
||||
Ok(argv) => argv,
|
||||
Err(error) => {
|
||||
respond_error(stream, "400 Bad Request", &format!("Invalid Redis command: {error}")).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let cmd_name = argv[0].to_ascii_uppercase();
|
||||
let safety = dbx_core::db::redis_driver::classify_command(&cmd_name);
|
||||
let centrally_approved_high_risk = safety == dbx_core::db::redis_driver::RedisCommandSafety::Blocked;
|
||||
if safety != dbx_core::db::redis_driver::RedisCommandSafety::Allowed {
|
||||
let policy_check = if centrally_approved_high_risk {
|
||||
ensure_mcp_write_allowed_with_risk(state, &config, &database, &format!("Redis command '{cmd_name}'"), true)
|
||||
.await
|
||||
} else {
|
||||
ensure_mcp_write_allowed(state, &config, &database, &format!("Redis command '{cmd_name}'")).await
|
||||
};
|
||||
if let Err(e) = policy_check {
|
||||
respond_error(stream, "403 Forbidden", &e).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -877,7 +1559,7 @@ async fn handle_redis_execute_command_data(state: &Arc<AppState>, body: &str, st
|
|||
&config.id,
|
||||
req.db,
|
||||
&req.command,
|
||||
req.skip_safety_check.unwrap_or(false),
|
||||
centrally_approved_high_risk,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
|
@ -906,66 +1588,51 @@ async fn handle_execute_query_data(state: &Arc<AppState>, body: &str, stream: &m
|
|||
respond_error(stream, "403 Forbidden", &e).await;
|
||||
return;
|
||||
}
|
||||
// Read-only check: reject if the connection has read-only protection and the SQL is a write
|
||||
if let Err(e) = dbx_core::query::check_read_only_for_connection(state, &config.id, &req.sql).await {
|
||||
if let Err(e) = ensure_mcp_sql_allowed(state, &config, &database, &req.sql).await {
|
||||
respond_error(stream, "403 Forbidden", &e).await;
|
||||
return;
|
||||
}
|
||||
match dbx_core::query::execute_sql_statement(state, &config.id, &database, &req.sql, req.schema.as_deref(), None)
|
||||
let statements = if config.db_type == crate::models::connection::DatabaseType::SqlServer {
|
||||
dbx_core::sql::split_sql_batches(&req.sql)
|
||||
} else {
|
||||
dbx_core::sql::split_sql_statements_for_database(&req.sql, config.db_type)
|
||||
};
|
||||
let statements = if statements.is_empty() { vec![req.sql] } else { statements };
|
||||
let mut last_result = None;
|
||||
for statement in statements {
|
||||
// Re-read both policy and connection settings immediately before every
|
||||
// statement so a settings change can stop the remainder of the batch.
|
||||
let current = match resolve_connection(state, Some(&config.id), &config.name).await {
|
||||
Ok(config) => config,
|
||||
Err(e) => {
|
||||
respond_error(stream, "403 Forbidden", &e).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(e) = ensure_mcp_sql_allowed(state, ¤t, &database, &statement).await {
|
||||
respond_error(stream, "403 Forbidden", &e).await;
|
||||
return;
|
||||
}
|
||||
match dbx_core::query::execute_sql_statement(
|
||||
state,
|
||||
¤t.id,
|
||||
&database,
|
||||
&statement,
|
||||
req.schema.as_deref(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => respond_json(stream, &result).await,
|
||||
Err(e) => respond_error(stream, "500 Internal Server Error", &e).await,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{resolve_mongo_database, resolve_mongo_target_values, write_port_file};
|
||||
|
||||
#[test]
|
||||
fn writes_bridge_port_file_to_resolved_data_dir() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"dbx-mcp-bridge-port-test-{}-{}",
|
||||
std::process::id(),
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
let default_data_dir = root.join("default-app-data");
|
||||
let resolved_data_dir = root.join("resolved-data");
|
||||
std::fs::create_dir_all(&default_data_dir).unwrap();
|
||||
|
||||
let port_file = write_port_file(&resolved_data_dir, 49152).unwrap();
|
||||
|
||||
assert_eq!(port_file, resolved_data_dir.join("mcp-bridge-port"));
|
||||
assert_eq!(std::fs::read_to_string(port_file).unwrap(), "49152");
|
||||
assert!(!default_data_dir.join("mcp-bridge-port").exists());
|
||||
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mongo_database_uses_configured_default_for_missing_or_blank_request() {
|
||||
let configured = Some("sample_db".to_string());
|
||||
|
||||
assert_eq!(resolve_mongo_database(None, configured.clone()), "sample_db");
|
||||
assert_eq!(resolve_mongo_database(Some(String::new()), configured.clone()), "sample_db");
|
||||
assert_eq!(resolve_mongo_database(Some(" ".to_string()), configured), "sample_db");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mongo_database_preserves_explicit_target() {
|
||||
assert_eq!(resolve_mongo_database(Some("admin".to_string()), Some("sample_db".to_string())), "admin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mongo_target_keeps_connection_id_separate_from_database() {
|
||||
assert_eq!(
|
||||
resolve_mongo_target_values(
|
||||
"connection-id".to_string(),
|
||||
Some("sample_db".to_string()),
|
||||
Some("default_db".to_string()),
|
||||
),
|
||||
("connection-id".to_string(), "sample_db".to_string())
|
||||
);
|
||||
{
|
||||
Ok(result) => last_result = Some(result),
|
||||
Err(e) => {
|
||||
respond_error(stream, "500 Internal Server Error", &e).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(result) = last_result {
|
||||
respond_json(stream, &result).await;
|
||||
} else {
|
||||
respond_error(stream, "500 Internal Server Error", "No SQL statement to execute").await;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,7 +99,11 @@ pub async fn mongo_find_documents(
|
|||
projection: Option<String>,
|
||||
sort: Option<String>,
|
||||
execution_id: Option<String>,
|
||||
mcp_request: Option<bool>,
|
||||
) -> Result<MongoDocumentResult, String> {
|
||||
if mcp_request == Some(true) {
|
||||
crate::commands::mcp_bridge::ensure_mcp_read_allowed_by_id(state.inner(), &connection_id, &database).await?;
|
||||
}
|
||||
crate::commands::document_cmd::document_find_documents(
|
||||
state,
|
||||
connection_id,
|
||||
|
|
@ -126,8 +130,12 @@ pub async fn mongo_find_one(
|
|||
projection: Option<String>,
|
||||
options: Option<String>,
|
||||
execution_id: Option<String>,
|
||||
mcp_request: Option<bool>,
|
||||
) -> Result<MongoDocumentResult, String> {
|
||||
let app = state.inner().clone();
|
||||
if mcp_request == Some(true) {
|
||||
crate::commands::mcp_bridge::ensure_mcp_read_allowed_by_id(&app, &connection_id, &database).await?;
|
||||
}
|
||||
run_cancellable(
|
||||
&app,
|
||||
execution_id,
|
||||
|
|
@ -153,8 +161,12 @@ pub async fn mongo_count_documents(
|
|||
filter: Option<String>,
|
||||
mode: Option<String>,
|
||||
execution_id: Option<String>,
|
||||
mcp_request: Option<bool>,
|
||||
) -> Result<u64, String> {
|
||||
let app = state.inner().clone();
|
||||
if mcp_request == Some(true) {
|
||||
crate::commands::mcp_bridge::ensure_mcp_read_allowed_by_id(&app, &connection_id, &database).await?;
|
||||
}
|
||||
crate::commands::document_cmd::run_cancellable(
|
||||
&app,
|
||||
execution_id,
|
||||
|
|
@ -176,8 +188,12 @@ pub async fn mongo_server_version(
|
|||
connection_id: String,
|
||||
database: String,
|
||||
execution_id: Option<String>,
|
||||
mcp_request: Option<bool>,
|
||||
) -> Result<String, String> {
|
||||
let app = state.inner().clone();
|
||||
if mcp_request == Some(true) {
|
||||
crate::commands::mcp_bridge::ensure_mcp_read_allowed_by_id(&app, &connection_id, &database).await?;
|
||||
}
|
||||
run_cancellable(&app, execution_id, dbx_core::mongo_ops::mongo_server_version_core(&app, &connection_id, &database))
|
||||
.await
|
||||
}
|
||||
|
|
@ -190,8 +206,12 @@ pub async fn mongo_collection_stats(
|
|||
collection: String,
|
||||
scale: Option<serde_json::Number>,
|
||||
execution_id: Option<String>,
|
||||
mcp_request: Option<bool>,
|
||||
) -> Result<dbx_core::db::mongo_driver::MongoCollectionStatsResult, String> {
|
||||
let app = state.inner().clone();
|
||||
if mcp_request == Some(true) {
|
||||
crate::commands::mcp_bridge::ensure_mcp_read_allowed_by_id(&app, &connection_id, &database).await?;
|
||||
}
|
||||
run_cancellable(
|
||||
&app,
|
||||
execution_id,
|
||||
|
|
@ -210,8 +230,18 @@ pub async fn mongo_aggregate_documents(
|
|||
max_rows: Option<usize>,
|
||||
options_json: Option<String>,
|
||||
execution_id: Option<String>,
|
||||
mcp_request: Option<bool>,
|
||||
) -> Result<MongoDocumentResult, String> {
|
||||
let app = state.inner().clone();
|
||||
if mcp_request == Some(true) {
|
||||
crate::commands::mcp_bridge::ensure_mcp_mongo_aggregate_allowed_by_id(
|
||||
&app,
|
||||
&connection_id,
|
||||
&database,
|
||||
&pipeline_json,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
run_cancellable(
|
||||
&app,
|
||||
execution_id,
|
||||
|
|
@ -237,8 +267,12 @@ pub async fn mongo_distinct(
|
|||
field: String,
|
||||
filter: Option<String>,
|
||||
execution_id: Option<String>,
|
||||
mcp_request: Option<bool>,
|
||||
) -> Result<MongoDocumentResult, String> {
|
||||
let app = state.inner().clone();
|
||||
if mcp_request == Some(true) {
|
||||
crate::commands::mcp_bridge::ensure_mcp_read_allowed_by_id(&app, &connection_id, &database).await?;
|
||||
}
|
||||
run_cancellable(
|
||||
&app,
|
||||
execution_id,
|
||||
|
|
@ -262,7 +296,17 @@ pub async fn mongo_create_index(
|
|||
collection: String,
|
||||
keys_json: String,
|
||||
options_json: Option<String>,
|
||||
mcp_request: Option<bool>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
if mcp_request == Some(true) {
|
||||
crate::commands::mcp_bridge::ensure_mcp_dangerous_write_allowed_by_id(
|
||||
state.inner(),
|
||||
&connection_id,
|
||||
&database,
|
||||
"Create index",
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
ensure_connection_writable(&state, &connection_id, "Create index").await?;
|
||||
let name = dbx_core::mongo_ops::mongo_create_index_core(
|
||||
&state,
|
||||
|
|
@ -284,7 +328,17 @@ pub async fn mongo_drop_indexes(
|
|||
collection: String,
|
||||
indexes_json: Option<String>,
|
||||
single: bool,
|
||||
mcp_request: Option<bool>,
|
||||
) -> Result<dbx_core::db::mongo_driver::MongoDropIndexesResult, String> {
|
||||
if mcp_request == Some(true) {
|
||||
crate::commands::mcp_bridge::ensure_mcp_dangerous_write_allowed_by_id(
|
||||
state.inner(),
|
||||
&connection_id,
|
||||
&database,
|
||||
"Drop indexes",
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
ensure_connection_writable(&state, &connection_id, "Drop indexes").await?;
|
||||
dbx_core::mongo_ops::mongo_drop_indexes_core(
|
||||
&state,
|
||||
|
|
@ -324,7 +378,12 @@ pub async fn mongo_insert_documents(
|
|||
database: String,
|
||||
collection: String,
|
||||
docs_json: String,
|
||||
mcp_request: Option<bool>,
|
||||
) -> Result<u64, String> {
|
||||
if mcp_request == Some(true) {
|
||||
crate::commands::mcp_bridge::ensure_mcp_write_allowed_by_id(state.inner(), &connection_id, &database, "Insert")
|
||||
.await?;
|
||||
}
|
||||
ensure_connection_writable(&state, &connection_id, "Insert").await?;
|
||||
dbx_core::mongo_ops::mongo_insert_documents_core(&state, &connection_id, &database, &collection, &docs_json).await
|
||||
}
|
||||
|
|
@ -361,7 +420,18 @@ pub async fn mongo_update_documents(
|
|||
update_json: String,
|
||||
many: bool,
|
||||
options_json: Option<String>,
|
||||
mcp_request: Option<bool>,
|
||||
) -> Result<u64, String> {
|
||||
if mcp_request == Some(true) {
|
||||
crate::commands::mcp_bridge::ensure_mcp_mongo_filtered_write_allowed_by_id(
|
||||
state.inner(),
|
||||
&connection_id,
|
||||
&database,
|
||||
"Update",
|
||||
&filter_json,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
ensure_connection_writable(&state, &connection_id, "Update").await?;
|
||||
dbx_core::mongo_ops::mongo_update_documents_core(
|
||||
&state,
|
||||
|
|
@ -397,7 +467,18 @@ pub async fn mongo_delete_documents(
|
|||
collection: String,
|
||||
filter_json: String,
|
||||
many: bool,
|
||||
mcp_request: Option<bool>,
|
||||
) -> Result<u64, String> {
|
||||
if mcp_request == Some(true) {
|
||||
crate::commands::mcp_bridge::ensure_mcp_mongo_filtered_write_allowed_by_id(
|
||||
state.inner(),
|
||||
&connection_id,
|
||||
&database,
|
||||
"Delete",
|
||||
&filter_json,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
ensure_connection_writable(&state, &connection_id, "Delete").await?;
|
||||
dbx_core::mongo_ops::mongo_delete_documents_core(&state, &connection_id, &database, &collection, &filter_json, many)
|
||||
.await
|
||||
|
|
@ -412,7 +493,18 @@ pub async fn mongo_find_one_and_update(
|
|||
filter_json: String,
|
||||
update_json: String,
|
||||
options_json: Option<String>,
|
||||
mcp_request: Option<bool>,
|
||||
) -> Result<MongoDocumentResult, String> {
|
||||
if mcp_request == Some(true) {
|
||||
crate::commands::mcp_bridge::ensure_mcp_mongo_filtered_write_allowed_by_id(
|
||||
state.inner(),
|
||||
&connection_id,
|
||||
&database,
|
||||
"Update",
|
||||
&filter_json,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
ensure_connection_writable(&state, &connection_id, "Update").await?;
|
||||
dbx_core::mongo_ops::mongo_find_one_and_update_core(
|
||||
&state,
|
||||
|
|
@ -435,7 +527,18 @@ pub async fn mongo_find_one_and_replace(
|
|||
filter_json: String,
|
||||
replacement_json: String,
|
||||
options_json: Option<String>,
|
||||
mcp_request: Option<bool>,
|
||||
) -> Result<MongoDocumentResult, String> {
|
||||
if mcp_request == Some(true) {
|
||||
crate::commands::mcp_bridge::ensure_mcp_mongo_filtered_write_allowed_by_id(
|
||||
state.inner(),
|
||||
&connection_id,
|
||||
&database,
|
||||
"Update",
|
||||
&filter_json,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
ensure_connection_writable(&state, &connection_id, "Update").await?;
|
||||
dbx_core::mongo_ops::mongo_find_one_and_replace_core(
|
||||
&state,
|
||||
|
|
@ -457,7 +560,18 @@ pub async fn mongo_find_one_and_delete(
|
|||
collection: String,
|
||||
filter_json: String,
|
||||
options_json: Option<String>,
|
||||
mcp_request: Option<bool>,
|
||||
) -> Result<MongoDocumentResult, String> {
|
||||
if mcp_request == Some(true) {
|
||||
crate::commands::mcp_bridge::ensure_mcp_mongo_filtered_write_allowed_by_id(
|
||||
state.inner(),
|
||||
&connection_id,
|
||||
&database,
|
||||
"Delete",
|
||||
&filter_json,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
ensure_connection_writable(&state, &connection_id, "Delete").await?;
|
||||
dbx_core::mongo_ops::mongo_find_one_and_delete_core(
|
||||
&state,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ use tauri::State;
|
|||
|
||||
use crate::commands::connection::{ensure_connection_writable, AppState};
|
||||
use dbx_core::db::redis_driver::{
|
||||
RedisCollectionPage, RedisCommandResult, RedisCommandSafety, RedisDatabaseInfo, RedisScanResult, RedisValue,
|
||||
classify_command, parse_command_argv, RedisCollectionPage, RedisCommandResult, RedisCommandSafety,
|
||||
RedisDatabaseInfo, RedisScanResult, RedisValue,
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
|
|
@ -295,10 +296,12 @@ pub async fn redis_execute_command(
|
|||
command: String,
|
||||
skip_safety_check: Option<bool>,
|
||||
) -> Result<RedisCommandResult, String> {
|
||||
let argv = parse_command_argv(&command).map_err(|error| format!("Invalid Redis command: {error}"))?;
|
||||
let cmd_name = argv[0].to_ascii_uppercase();
|
||||
let safety = classify_command(&cmd_name);
|
||||
// In read-only mode, only allow safe read commands through the raw command interface
|
||||
if let Some(name) = dbx_core::query::connection_readonly_name(&state, &connection_id).await {
|
||||
let cmd_name = command.split_whitespace().next().unwrap_or("");
|
||||
if dbx_core::db::redis_driver::classify_command(cmd_name) != RedisCommandSafety::Allowed {
|
||||
if safety != RedisCommandSafety::Allowed {
|
||||
return Err(format!(
|
||||
"Read-only mode: connection '{}' has read-only protection enabled. Command '{}' blocked.",
|
||||
name, cmd_name
|
||||
|
|
|
|||
|
|
@ -1046,6 +1046,8 @@ pub fn run() {
|
|||
commands::app_settings::get_driver_store_path,
|
||||
commands::app_settings::load_pinned_tree_node_ids,
|
||||
commands::app_settings::save_pinned_tree_node_ids,
|
||||
commands::app_settings::load_mcp_global_policy,
|
||||
commands::app_settings::save_mcp_global_policy,
|
||||
commands::app_settings::load_editor_settings,
|
||||
commands::app_settings::save_editor_settings,
|
||||
commands::app_settings::load_open_tabs_state,
|
||||
|
|
|
|||
Loading…
Reference in New Issue