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

+72 -1
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { EXECUTE_MODE_CURRENT_DEFAULT_VERSION, normalizeDesktopSettings, normalizeEditorSettings } from "@/stores/settingsStore";
import { EXECUTE_MODE_CURRENT_DEFAULT_VERSION, normalizeDesktopSettings, normalizeEditorSettings, normalizeMcpGlobalPolicy } from "@/stores/settingsStore";
import { createPinia, setActivePinia } from "pinia";
import type { AiConfigItem } from "@/types/ai";
@@ -135,6 +135,37 @@ describe("normalizeDesktopSettings", () => {
});
});
describe("normalizeMcpGlobalPolicy", () => {
it("defaults to all connections with writes allowed", () => {
expect(normalizeMcpGlobalPolicy(undefined)).toEqual({
readOnly: false,
allowDangerousSql: false,
allowedConnectionIds: null,
configured: false,
});
});
it("normalizes and deduplicates an explicit connection allowlist", () => {
expect(
normalizeMcpGlobalPolicy({
readOnly: true,
allowDangerousSql: true,
allowedConnectionIds: [" connection-1 ", "connection-1", "", "connection-2"],
configured: true,
}),
).toEqual({
readOnly: true,
allowDangerousSql: true,
allowedConnectionIds: ["connection-1", "connection-2"],
configured: true,
});
});
it("preserves an empty allowlist as deny all", () => {
expect(normalizeMcpGlobalPolicy({ allowedConnectionIds: [] }).allowedConnectionIds).toEqual([]);
});
});
describe("normalizeEditorSettings - continueOnErrorOnBatch", () => {
it("defaults continueOnErrorOnBatch to false", () => {
expect(normalizeEditorSettings({}).continueOnErrorOnBatch).toBe(false);
@@ -186,6 +217,46 @@ function makeTestConfig(overrides: Partial<AiConfigItem> & { id: string }): AiCo
} as AiConfigItem;
}
describe("settingsStore MCP policy persistence", () => {
beforeEach(() => {
vi.resetModules();
setActivePinia(createPinia());
});
it("rolls an optimistic policy update back when persistence fails", async () => {
let rejectSave!: (reason?: unknown) => void;
const saveMcpGlobalPolicy = vi.fn(
() =>
new Promise<void>((_resolve, reject) => {
rejectSave = reject;
}),
);
vi.doMock("@/lib/backend/api", () => ({ saveMcpGlobalPolicy }));
const { useSettingsStore } = await import("@/stores/settingsStore");
const store = useSettingsStore();
const previous = {
readOnly: true,
allowDangerousSql: false,
allowedConnectionIds: ["connection-1"],
configured: true,
};
store.mcpGlobalPolicy = previous;
const update = store.updateMcpGlobalPolicy({ readOnly: false, allowedConnectionIds: [] });
expect(store.mcpGlobalPolicy).toEqual({
readOnly: false,
allowDangerousSql: false,
allowedConnectionIds: [],
configured: true,
});
rejectSave(new Error("save failed"));
await expect(update).rejects.toThrow("save failed");
expect(store.mcpGlobalPolicy).toEqual(previous);
});
});
// --- activeModel lifecycle tests ---
describe("settingsStore activeModel lifecycle", () => {
+22
View File
@@ -1054,6 +1054,27 @@ export const useQueryStore = defineStore("query", () => {
return id;
}
function showExecutedQueryResults(connectionId: string, database: string, sql: string, queryResults: QueryResult[]) {
const id = createTab(connectionId, database, undefined, "query", undefined, sql);
const tab = tabs.value.find((item) => item.id === id);
if (!tab) return id;
const results = markQueryResultsRowsRaw(queryResults);
const firstDataResult = results.findIndex((result) => result.columns.length > 0);
const activeIndex = firstDataResult >= 0 ? firstDataResult : 0;
tab.lastExecutedSql = sql;
tab.resultBaseSql = sql;
tab.results = results.length > 1 ? results : undefined;
tab.activeResultIndex = results.length > 1 ? activeIndex : undefined;
tab.result = results[activeIndex];
tab.isExecuting = false;
tab.isCancelling = false;
tab.executionId = undefined;
tab.queryExecutionStartedAt = undefined;
if (tab.result) touchResult(tab);
return id;
}
function refreshExternalSqlFileTitles() {
const externalTabs = tabs.value.filter((tab) => tab.mode === "query" && tab.externalSqlPath);
const titles = externalSqlFileDisplayTitles(externalTabs.map((tab) => tab.externalSqlPath!));
@@ -4338,6 +4359,7 @@ export const useQueryStore = defineStore("query", () => {
hasDirtyTabs,
isConfirmingAppClose,
createTab,
showExecutedQueryResults,
switchTab,
closeTab,
forceClosePendingTab,
+55
View File
@@ -34,6 +34,13 @@ export interface DesktopSettings {
sidebar_table_page_size?: number | null;
}
export interface McpGlobalPolicy {
readOnly: boolean;
allowDangerousSql: boolean;
allowedConnectionIds: string[] | null;
configured: boolean;
}
export type DesktopIconTheme = "default" | "black";
export type InterfaceLayout = "separated" | "classic";
@@ -63,6 +70,23 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
sidebar_table_page_size: DEFAULT_SIDEBAR_TABLE_PAGE_SIZE,
};
export const DEFAULT_MCP_GLOBAL_POLICY: McpGlobalPolicy = {
readOnly: false,
allowDangerousSql: false,
allowedConnectionIds: null,
configured: false,
};
export function normalizeMcpGlobalPolicy(policy: Partial<McpGlobalPolicy> | null | undefined): McpGlobalPolicy {
const allowedConnectionIds = policy?.allowedConnectionIds === null || policy?.allowedConnectionIds === undefined ? null : [...new Set(policy.allowedConnectionIds.filter((id): id is string => typeof id === "string" && id.trim().length > 0).map((id) => id.trim()))];
return {
readOnly: policy?.readOnly === true,
allowDangerousSql: policy?.allowDangerousSql === true,
allowedConnectionIds,
configured: policy?.configured === true,
};
}
export function normalizeDesktopSettings(settings: Partial<DesktopSettings> | null | undefined): DesktopSettings {
const iconTheme = settings?.icon_theme === "black" ? "black" : DEFAULT_DESKTOP_SETTINGS.icon_theme;
const sidebarTablePageSize = typeof settings?.sidebar_table_page_size === "number" && settings.sidebar_table_page_size > 0 ? settings.sidebar_table_page_size : DEFAULT_DESKTOP_SETTINGS.sidebar_table_page_size;
@@ -878,7 +902,9 @@ export const useSettingsStore = defineStore("settings", () => {
const isAiConfigLoaded = ref(false);
const aiConfigs = ref<AiConfigItem[]>([]);
const desktopSettings = ref<DesktopSettings>({ ...DEFAULT_DESKTOP_SETTINGS });
const mcpGlobalPolicy = ref<McpGlobalPolicy>({ ...DEFAULT_MCP_GLOBAL_POLICY });
const isDesktopSettingsLoaded = ref(false);
const isMcpGlobalPolicyLoaded = ref(false);
const isEditorSettingsLoaded = ref(false);
const editorSettings = ref<EditorSettings>(normalizeEditorSettings({}));
@@ -951,6 +977,32 @@ export const useSettingsStore = defineStore("settings", () => {
}
}
async function initMcpGlobalPolicy(force = false) {
if (isMcpGlobalPolicyLoaded.value && !force) return;
mcpGlobalPolicy.value = normalizeMcpGlobalPolicy(await api.loadMcpGlobalPolicy());
isMcpGlobalPolicyLoaded.value = true;
}
async function updateMcpGlobalPolicy(partial: Partial<Omit<McpGlobalPolicy, "configured">>) {
const previous = mcpGlobalPolicy.value;
const next = normalizeMcpGlobalPolicy({
...previous,
...partial,
configured: true,
});
mcpGlobalPolicy.value = next;
try {
await api.saveMcpGlobalPolicy({
readOnly: next.readOnly,
allowDangerousSql: next.allowDangerousSql,
allowedConnectionIds: next.allowedConnectionIds,
});
} catch (error) {
mcpGlobalPolicy.value = previous;
throw error;
}
}
async function initAiConfigs(): Promise<void> {
if (isAiConfigLoaded.value) return;
@@ -1219,10 +1271,13 @@ export const useSettingsStore = defineStore("settings", () => {
isEditorSettingsLoaded,
editorSettings,
desktopSettings,
mcpGlobalPolicy,
initEditorSettings,
updateEditorSettings,
initDesktopSettings,
updateDesktopSettings,
initMcpGlobalPolicy,
updateMcpGlobalPolicy,
updateColumnFormatter,
upsertCustomColumnFormatter,
deleteCustomColumnFormatter,