feat(mcp): centralize connection access policies
This commit is contained in:
56 files changed
+6393
-617
No files matched your search
@@ -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
@@ -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: "重新檢查",
|
||||
|
||||
+57
-33
@@ -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> {
|
||||
|
||||
+72
-1
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user