feat(mcp): override execution policy per database

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-09-02 17:39:07 +08:00
1 parent 1706081802
commit c48ebda197
24 files changed
+1279 -307

No files matched your search

@@ -90,6 +90,7 @@ import {
type UpdateDownloadSource,
type CustomThemeColors,
type CustomTheme,
type McpConnectionPolicy,
type ClickTableNavigationTarget,
type SqlCompletionTriggerMode,
SIDEBAR_INDENT_MIN,
@@ -1926,6 +1927,7 @@ function resetSettingsSearchState() {
settingsSearchActiveIndex.value = 0;
pendingSettingsSearchResult = null;
shortcutSearchQuery.value = "";
mcpPermissionPreviewSearchQuery.value = "";
clearSettingsSearchHighlight();
}
@@ -2219,7 +2221,16 @@ async function saveMcpPolicy(partial: {
allowDangerousSql?: boolean;
allowedConnectionIds?: string[] | null;
allowedToolNames?: string[] | null;
connectionPolicies?: { connectionId: string; readOnly: boolean; allowDangerousSql: boolean; executionModeConfigured: boolean; databaseScope: "all" | "selected" | "none"; allowedDatabases: string[] }[];
connectionPolicies?: {
connectionId: string;
readOnly: boolean;
allowDangerousSql: boolean;
executionModeConfigured: boolean;
executionModePolicyVersion: number | null;
databaseScope: "all" | "selected" | "none";
allowedDatabases: string[];
databasePolicies: { databaseName: string; readOnly: boolean; allowDangerousSql: boolean }[];
}[];
queryTimeoutSecs?: number | null;
}) {
if (mcpPolicyControlsDisabled.value) return;
@@ -2327,16 +2338,87 @@ function mcpConnectionExecutionMode(connectionId: string): McpConnectionExecutio
return rule.allowDangerousSql ? "high_risk_write" : "safe_write";
}
function mcpExecutionModeRank(mode: McpConnectionExecutionMode): number {
return mode === "read_only" ? 0 : mode === "safe_write" ? 1 : 2;
}
function migrateLegacyMcpConnectionPolicy(policy: McpConnectionPolicy) {
const connectionMode = mcpConnectionExecutionMode(policy.connectionId);
const legacyMode = connectionMode === "inherit" ? mcpExecutionMode.value : connectionMode;
const effectiveMode = mcpExecutionModeRank(legacyMode) < mcpExecutionModeRank(mcpExecutionMode.value) ? legacyMode : mcpExecutionMode.value;
const databasePolicies = policy.databasePolicies.map((databasePolicy) => {
const databaseMode: McpConnectionExecutionMode = databasePolicy.readOnly ? "read_only" : databasePolicy.allowDangerousSql ? "high_risk_write" : "safe_write";
const effectiveDatabaseMode = mcpExecutionModeRank(databaseMode) < mcpExecutionModeRank(effectiveMode) ? databaseMode : effectiveMode;
return {
...databasePolicy,
readOnly: effectiveDatabaseMode === "read_only",
allowDangerousSql: effectiveDatabaseMode === "high_risk_write",
};
});
return {
readOnly: effectiveMode === "read_only",
allowDangerousSql: effectiveMode === "high_risk_write",
executionModeConfigured: true,
databasePolicies,
};
}
function mcpExecutionModeLabel(mode: McpConnectionExecutionMode): string {
return t(mode === "read_only" ? "settings.mcpExecutionModeReadOnly" : mode === "safe_write" ? "settings.mcpExecutionModeSafeWrite" : "settings.mcpExecutionModeHighRiskWrite");
}
function mcpEffectiveExecutionMode(connectionMode: McpConnectionExecutionMode | "inherit", databaseMode: McpConnectionExecutionMode | "inherit"): McpConnectionExecutionMode {
return databaseMode !== "inherit" ? databaseMode : connectionMode !== "inherit" ? connectionMode : mcpExecutionMode.value;
}
const mcpPermissionPreviewSearchQuery = ref("");
const mcpPermissionPreviewRows = computed(() =>
mcpConnectionPolicyConnections.value.flatMap((connection) => {
const rule = settingsStore.mcpGlobalPolicy.connectionPolicies.find((item) => item.connectionId === connection.id);
const connectionMode = mcpConnectionExecutionMode(connection.id);
if (rule?.databaseScope === "none") return [];
const databases = rule?.databaseScope === "selected" ? rule.allowedDatabases : [t("settings.mcpDatabaseScopeSummaryAll")];
return databases.map((database) => {
const databasePolicy = rule?.databasePolicies.find((item) => item.databaseName === database);
const databaseMode: McpConnectionExecutionMode | "inherit" = !databasePolicy ? "inherit" : databasePolicy.readOnly ? "read_only" : databasePolicy.allowDangerousSql ? "high_risk_write" : "safe_write";
return {
connection: connection.name,
database,
connectionMode,
databaseMode,
effectiveMode: mcpEffectiveExecutionMode(connectionMode, databaseMode),
};
});
}),
);
const filteredMcpPermissionPreviewRows = computed(() => {
const query = mcpPermissionPreviewSearchQuery.value.trim().toLocaleLowerCase();
if (!query) return mcpPermissionPreviewRows.value;
return mcpPermissionPreviewRows.value.filter((row) => [row.connection, row.database].some((value) => value.toLocaleLowerCase().includes(query)));
});
function onMcpConnectionExecutionModeChange(connectionId: string, mode: McpConnectionExecutionMode | "inherit") {
const existing = settingsStore.mcpGlobalPolicy.connectionPolicies.find((item) => item.connectionId === connectionId);
const rules = settingsStore.mcpGlobalPolicy.connectionPolicies.filter((item) => item.connectionId !== connectionId);
const migrated = existing && existing.executionModePolicyVersion !== 1 ? migrateLegacyMcpConnectionPolicy(existing) : null;
const selectedMode =
migrated && mode === "inherit"
? migrated
: {
readOnly: mode === "read_only",
allowDangerousSql: mode === "high_risk_write",
executionModeConfigured: mode !== "inherit",
databasePolicies: migrated?.databasePolicies ?? existing?.databasePolicies ?? [],
};
const next = {
connectionId,
readOnly: mode === "read_only",
allowDangerousSql: mode === "high_risk_write",
executionModeConfigured: mode !== "inherit",
readOnly: selectedMode.readOnly,
allowDangerousSql: selectedMode.allowDangerousSql,
executionModeConfigured: selectedMode.executionModeConfigured,
executionModePolicyVersion: 1,
databaseScope: existing?.databaseScope ?? ("all" as const),
allowedDatabases: existing?.allowedDatabases ?? [],
databasePolicies: selectedMode.databasePolicies,
};
if (next.executionModeConfigured || next.databaseScope !== "all") rules.push(next);
void saveMcpPolicy({ connectionPolicies: rules });
@@ -7893,6 +7975,7 @@ onUnmounted(() => {
:connections="mcpSelectableConnections"
:allowed-connection-ids="mcpAllowedConnectionIds"
:connection-policies="settingsStore.mcpGlobalPolicy.connectionPolicies"
:global-execution-mode="mcpExecutionMode"
:disabled="mcpPolicyControlsDisabled"
:busy="mcpPolicyLoading || mcpPolicySaving"
@update:connection-policies="onMcpConnectionPoliciesChange"
@@ -7943,6 +8026,44 @@ onUnmounted(() => {
</template>
<template #capabilities>
<div class="space-y-4">
<section class="space-y-2 rounded-md border bg-background p-3">
<div>
<p class="text-sm font-medium">{{ t("settings.mcpPermissionPreviewTitle") }}</p>
<p class="text-xs text-muted-foreground">{{ t("settings.mcpPermissionPreviewDescription") }}</p>
</div>
<template v-if="mcpPermissionPreviewRows.length">
<div class="relative">
<Search class="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input v-model="mcpPermissionPreviewSearchQuery" autocomplete="off" :placeholder="t('settings.mcpPermissionPreviewSearchPlaceholder')" class="h-8 pl-9 text-xs" />
</div>
<div class="max-h-72 overflow-auto overscroll-contain rounded border">
<table class="w-full min-w-[42rem] border-separate border-spacing-0 text-xs">
<thead class="text-muted-foreground">
<tr>
<th scope="col" class="sticky top-0 z-10 bg-muted px-3 py-2 text-left font-medium shadow-[0_1px_0_hsl(var(--border))]">{{ t("settings.mcpPermissionPreviewConnection") }}</th>
<th scope="col" class="sticky top-0 z-10 bg-muted px-3 py-2 text-left font-medium shadow-[0_1px_0_hsl(var(--border))]">{{ t("settings.mcpPermissionPreviewDatabase") }}</th>
<th scope="col" class="sticky top-0 z-10 bg-muted px-3 py-2 text-left font-medium shadow-[0_1px_0_hsl(var(--border))]">{{ t("settings.mcpPermissionPreviewConnectionDefault") }}</th>
<th scope="col" class="sticky top-0 z-10 bg-muted px-3 py-2 text-left font-medium shadow-[0_1px_0_hsl(var(--border))]">{{ t("settings.mcpPermissionPreviewDatabaseOverride") }}</th>
<th scope="col" class="sticky top-0 z-10 bg-muted px-3 py-2 text-left font-medium shadow-[0_1px_0_hsl(var(--border))]">{{ t("settings.mcpPermissionPreviewEffective") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in filteredMcpPermissionPreviewRows" :key="`${row.connection}:${row.database}`" class="border-t">
<td class="px-3 py-2 font-medium">{{ row.connection }}</td>
<td class="px-3 py-2 font-mono">{{ row.database }}</td>
<td class="px-3 py-2">{{ row.connectionMode === "inherit" ? t("settings.mcpConnectionPolicyInherit") : mcpExecutionModeLabel(row.connectionMode) }}</td>
<td class="px-3 py-2">{{ row.databaseMode === "inherit" ? t("settings.mcpDatabasePolicyInherit") : mcpExecutionModeLabel(row.databaseMode) }}</td>
<td class="px-3 py-2 font-medium">{{ mcpExecutionModeLabel(row.effectiveMode) }}</td>
</tr>
<tr v-if="filteredMcpPermissionPreviewRows.length === 0">
<td colspan="5" class="px-3 py-8 text-center text-muted-foreground">{{ t("settings.mcpPermissionPreviewSearchNoResults") }}</td>
</tr>
</tbody>
</table>
</div>
</template>
<p v-else class="text-xs text-muted-foreground">{{ t("settings.mcpPermissionPreviewEmpty") }}</p>
</section>
<section class="space-y-2">
<div>
<p class="text-sm font-medium">{{ t("settings.mcpToolPermissionsTitle") }}</p>
@@ -10,6 +10,8 @@ import type { McpConnectionPolicy } from "@/stores/settingsStore";
import type { ConnectionConfig } from "@/types/database";
type DatabaseScope = McpConnectionPolicy["databaseScope"];
type DatabaseExecutionMode = "inherit" | "read_only" | "safe_write" | "high_risk_write";
type ExecutionMode = Exclude<DatabaseExecutionMode, "inherit">;
const { t } = useI18n();
@@ -20,6 +22,7 @@ const props = withDefaults(
connections: readonly ConnectionConfig[];
allowedConnectionIds: readonly string[] | null;
connectionPolicies: readonly McpConnectionPolicy[];
globalExecutionMode: ExecutionMode;
disabled?: boolean;
busy?: boolean;
}>(),
@@ -45,6 +48,7 @@ const selectedConnection = computed(() => scopedConnections.value.find((connecti
const selectedPolicy = computed(() => props.connectionPolicies.find((policy) => policy.connectionId === selectedConnectionId.value));
const selectedScope = computed<DatabaseScope>(() => selectedPolicy.value?.databaseScope ?? "all");
const selectedDatabases = computed(() => selectedPolicy.value?.allowedDatabases ?? []);
const selectedDatabasePolicies = computed(() => selectedPolicy.value?.databasePolicies ?? []);
const displayedDatabases = computed(() => {
const query = search.value.trim().toLocaleLowerCase();
return (databasesByConnection.value[selectedConnectionId.value] ?? []).filter((database) => !query || database.toLocaleLowerCase().includes(query));
@@ -77,21 +81,29 @@ function policyFor(connectionId: string): McpConnectionPolicy {
readOnly: false,
allowDangerousSql: false,
executionModeConfigured: false,
executionModePolicyVersion: null,
databaseScope: "all",
allowedDatabases: [],
databasePolicies: [],
}
);
}
function updateSelectedPolicy(patch: Partial<McpConnectionPolicy>) {
if (props.disabled || !selectedConnectionId.value) return;
const next = { ...policyFor(selectedConnectionId.value), ...patch };
const existing = policyFor(selectedConnectionId.value);
const migration = existing.executionModePolicyVersion === 1 ? {} : { ...promoteLegacyConnectionDefault(existing), executionModePolicyVersion: 1 as const };
const next = { ...existing, ...migration, ...patch };
const policies = props.connectionPolicies.filter((policy) => policy.connectionId !== selectedConnectionId.value);
emit("update:connectionPolicies", [...policies, next]);
}
function setScope(scope: DatabaseScope) {
updateSelectedPolicy({ databaseScope: scope, allowedDatabases: scope === "selected" ? selectedDatabases.value : [] });
updateSelectedPolicy({
databaseScope: scope,
allowedDatabases: scope === "selected" ? selectedDatabases.value : [],
databasePolicies: scope === "selected" ? selectedDatabasePolicies.value.filter((policy) => selectedDatabases.value.includes(policy.databaseName)) : [],
});
}
function setConnectionPage(page: number) {
@@ -111,7 +123,77 @@ function selectConnection(connectionId: string) {
function toggleDatabase(database: string, checked: boolean) {
const databases = checked ? [...new Set([...selectedDatabases.value, database])] : selectedDatabases.value.filter((item) => item !== database);
updateSelectedPolicy({ databaseScope: "selected", allowedDatabases: databases });
updateSelectedPolicy({
databaseScope: "selected",
allowedDatabases: databases,
databasePolicies: selectedDatabasePolicies.value.filter((policy) => databases.includes(policy.databaseName)),
});
}
function databaseExecutionMode(database: string): DatabaseExecutionMode {
const policy = selectedDatabasePolicies.value.find((item) => item.databaseName === database);
if (!policy) return "inherit";
if (policy.readOnly) return "read_only";
return policy.allowDangerousSql ? "high_risk_write" : "safe_write";
}
function connectionExecutionMode(): DatabaseExecutionMode {
const policy = selectedPolicy.value;
if (!policy || !policy.executionModeConfigured) return "inherit";
if (policy.readOnly) return "read_only";
return policy.allowDangerousSql ? "high_risk_write" : "safe_write";
}
function effectiveDatabaseExecutionMode(database: string): ExecutionMode {
const databaseMode = databaseExecutionMode(database);
if (databaseMode !== "inherit") return databaseMode;
const connectionMode = connectionExecutionMode();
return connectionMode === "inherit" ? props.globalExecutionMode : connectionMode;
}
function executionModeLabel(mode: ExecutionMode): string {
return t(mode === "read_only" ? "settings.mcpExecutionModeReadOnly" : mode === "safe_write" ? "settings.mcpExecutionModeSafeWrite" : "settings.mcpExecutionModeHighRiskWrite");
}
function executionModeRank(mode: DatabaseExecutionMode): number {
return mode === "read_only" ? 0 : mode === "safe_write" ? 1 : mode === "high_risk_write" ? 2 : 3;
}
function promoteLegacyConnectionDefault(policy: McpConnectionPolicy): Pick<McpConnectionPolicy, "readOnly" | "allowDangerousSql" | "executionModeConfigured" | "databasePolicies"> {
if (policy.executionModePolicyVersion === 1) {
return { readOnly: policy.readOnly, allowDangerousSql: policy.allowDangerousSql, executionModeConfigured: policy.executionModeConfigured, databasePolicies: policy.databasePolicies };
}
const legacyMode = connectionExecutionMode();
const effectiveMode = executionModeRank(legacyMode) < executionModeRank(props.globalExecutionMode) ? legacyMode : props.globalExecutionMode;
const databasePolicies = policy.databasePolicies.map((databasePolicy) => {
const databaseMode = databasePolicy.readOnly ? "read_only" : databasePolicy.allowDangerousSql ? "high_risk_write" : "safe_write";
const effectiveDatabaseMode = executionModeRank(databaseMode) < executionModeRank(effectiveMode) ? databaseMode : effectiveMode;
return {
...databasePolicy,
readOnly: effectiveDatabaseMode === "read_only",
allowDangerousSql: effectiveDatabaseMode === "high_risk_write",
};
});
return {
readOnly: effectiveMode === "read_only",
allowDangerousSql: effectiveMode === "high_risk_write",
executionModeConfigured: true,
databasePolicies,
};
}
function setDatabaseExecutionMode(database: string, mode: DatabaseExecutionMode) {
const existing = policyFor(selectedConnectionId.value);
const migrated = promoteLegacyConnectionDefault(existing);
const policies = migrated.databasePolicies.filter((policy) => policy.databaseName !== database);
if (mode !== "inherit") {
policies.push({
databaseName: database,
readOnly: mode === "read_only",
allowDangerousSql: mode === "high_risk_write",
});
}
updateSelectedPolicy({ ...promoteLegacyConnectionDefault(existing), databasePolicies: policies, executionModePolicyVersion: 1 });
}
function addManualDatabase() {
@@ -185,12 +267,21 @@ watch(databasePageCount, () => setDatabasePage(databasePage.value));
</label>
</div>
<div class="space-y-1">
<button v-for="connection in pagedScopedConnections" :key="connection.id" type="button" class="w-full rounded-md px-2 py-2 text-left transition-colors hover:bg-muted" :class="selectedConnectionId === connection.id ? 'bg-muted' : ''" @click="selectConnection(connection.id)">
<div class="flex items-center justify-between gap-2">
<button
v-for="connection in pagedScopedConnections"
:key="connection.id"
type="button"
class="w-full cursor-pointer rounded-md px-2 py-2 text-left transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
:class="selectedConnectionId === connection.id ? 'bg-muted' : ''"
:aria-current="selectedConnectionId === connection.id ? 'true' : undefined"
@pointerdown="selectConnection(connection.id)"
@click="selectConnection(connection.id)"
>
<div class="pointer-events-none flex items-center justify-between gap-2">
<span class="min-w-0 truncate text-sm font-medium">{{ connection.name }}</span>
<Badge variant="outline" class="shrink-0 text-[10px] font-normal">{{ scopeSummary(connection) }}</Badge>
</div>
<p class="mt-0.5 truncate font-mono text-[10px] text-muted-foreground">{{ connection.db_type }} · {{ connection.host || connection.database || connection.id }}</p>
<p class="pointer-events-none mt-0.5 truncate font-mono text-[10px] text-muted-foreground">{{ connection.db_type }} · {{ connection.host || connection.database || connection.id }}</p>
</button>
</div>
<div v-if="connectionPageCount > 1" class="mt-2 flex items-center justify-center gap-2 border-t pt-2 text-xs text-muted-foreground">
@@ -246,6 +337,16 @@ watch(databasePageCount, () => setDatabasePage(databasePage.value));
<label v-for="database in pagedDatabases" :key="database" class="flex cursor-pointer items-center gap-2 border-b px-3 py-2 text-xs last:border-b-0 hover:bg-muted/50">
<input type="checkbox" :checked="selectedDatabases.includes(database)" :disabled="disabled || busy" @change="toggleDatabase(database, ($event.target as HTMLInputElement).checked)" />
<span class="min-w-0 flex-1 truncate font-mono">{{ database }}</span>
<div v-if="selectedDatabases.includes(database)" class="flex shrink-0 items-center gap-1.5">
<span class="text-[11px] text-muted-foreground">{{ t("settings.mcpDatabasePolicySetting") }}</span>
<select :value="databaseExecutionMode(database)" :disabled="disabled || busy" class="h-7 max-w-32 rounded border bg-background px-1.5 text-[11px]" @click.stop @change="setDatabaseExecutionMode(database, ($event.target as HTMLSelectElement).value as DatabaseExecutionMode)">
<option value="inherit">{{ t("settings.mcpDatabasePolicyInherit") }}</option>
<option value="read_only">{{ t("settings.mcpConnectionPolicyReadOnly") }}</option>
<option value="safe_write">{{ t("settings.mcpConnectionPolicySafeWrite") }}</option>
<option value="high_risk_write">{{ t("settings.mcpConnectionPolicyHighRiskWrite") }}</option>
</select>
<Badge variant="secondary" class="text-[10px] font-normal">{{ t("settings.mcpDatabasePolicyEffective", { mode: executionModeLabel(effectiveDatabaseExecutionMode(database)) }) }}</Badge>
</div>
<Check v-if="selectedDatabases.includes(database)" class="h-3.5 w-3.5 text-green-600" />
</label>
<div v-if="databasePageCount > 1" class="flex items-center justify-center gap-2 border-t px-3 py-2 text-xs text-muted-foreground">