feat(mcp): centralize connection access policies

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-07-20 22:47:40 +08:00
1 parent 29f493f7da
commit d642d01eac
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>
@@ -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");
});
});