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