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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user