feat(mcp): centralize connection access policies
This commit is contained in:
56 files changed
+6393
-617
No files matched your search
+57
-33
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildMcpCodexConfig, buildMcpJsonConfig, buildMcpOpenCodeConfig, buildMcpVsCodeConfig, type McpEnvEntry } from "@/lib/mcp/mcpConfigTemplates";
|
||||
import { buildMcpCherryStudioConfig, buildMcpCodexConfig, buildMcpJsonConfig, buildMcpOpenCodeConfig, buildMcpVsCodeConfig, mcpWebBackendUrl } from "@/lib/mcp/mcpConfigTemplates";
|
||||
|
||||
describe("MCP config templates", () => {
|
||||
it("builds the standard mcpServers JSON used by Claude, Cursor, TRAE, and Windsurf", () => {
|
||||
@@ -14,21 +14,8 @@ describe("MCP config templates", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("adds DBX MCP env entries to standard JSON configs", () => {
|
||||
const env: McpEnvEntry[] = [
|
||||
["DBX_MCP_ALLOW_WRITES", "0"],
|
||||
["DBX_MCP_ALLOW_DANGEROUS_SQL", "1"],
|
||||
];
|
||||
const config = JSON.parse(buildMcpJsonConfig(env));
|
||||
|
||||
expect(config.mcpServers.dbx.env).toEqual({
|
||||
DBX_MCP_ALLOW_WRITES: "0",
|
||||
DBX_MCP_ALLOW_DANGEROUS_SQL: "1",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds standard JSON configs with a direct node launch command", () => {
|
||||
const config = JSON.parse(buildMcpJsonConfig([], { command: "C:\\Program Files\\nodejs\\node.exe", args: ["C:\\Users\\zhiyo\\AppData\\Roaming\\npm\\node_modules\\@dbx-app\\mcp-server\\dist\\index.js"] }));
|
||||
const config = JSON.parse(buildMcpJsonConfig({ command: "C:\\Program Files\\nodejs\\node.exe", args: ["C:\\Users\\zhiyo\\AppData\\Roaming\\npm\\node_modules\\@dbx-app\\mcp-server\\dist\\index.js"] }));
|
||||
|
||||
expect(config).toEqual({
|
||||
mcpServers: {
|
||||
@@ -40,24 +27,42 @@ describe("MCP config templates", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("builds VS Code MCP config with the servers root", () => {
|
||||
const config = JSON.parse(buildMcpVsCodeConfig([["DBX_MCP_ALLOW_WRITES", "0"]]));
|
||||
it("includes Web runtime settings without restoring permission environment variables", () => {
|
||||
const launch = {
|
||||
command: "dbx-mcp-server",
|
||||
env: {
|
||||
DBX_WEB_URL: "https://dbx.example.com/tools/dbx",
|
||||
DBX_WEB_PASSWORD: "your-web-login-password",
|
||||
},
|
||||
};
|
||||
|
||||
expect(JSON.parse(buildMcpJsonConfig(launch))).toEqual({
|
||||
mcpServers: { dbx: { command: "dbx-mcp-server", env: launch.env } },
|
||||
});
|
||||
expect(buildMcpCodexConfig(launch)).toContain('[mcp_servers.dbx.env]\nDBX_WEB_URL = "https://dbx.example.com/tools/dbx"');
|
||||
expect(JSON.parse(buildMcpOpenCodeConfig(launch)).mcp.dbx.environment).toEqual(launch.env);
|
||||
expect(buildMcpJsonConfig(launch)).not.toContain("DBX_MCP_ALLOW_WRITES");
|
||||
});
|
||||
|
||||
it("keeps a deployed Web base path in DBX_WEB_URL", () => {
|
||||
expect(mcpWebBackendUrl("https://dbx.example.com", "/tools/dbx/api")).toBe("https://dbx.example.com/tools/dbx");
|
||||
});
|
||||
|
||||
it("builds VS Code MCP config with the servers root and no policy environment", () => {
|
||||
const config = JSON.parse(buildMcpVsCodeConfig());
|
||||
|
||||
expect(config).toEqual({
|
||||
servers: {
|
||||
dbx: {
|
||||
type: "stdio",
|
||||
command: "dbx-mcp-server",
|
||||
env: {
|
||||
DBX_MCP_ALLOW_WRITES: "0",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("builds VS Code config with a direct node launch command", () => {
|
||||
const config = JSON.parse(buildMcpVsCodeConfig([["DBX_MCP_ALLOW_WRITES", "0"]], { command: "node", args: ["C:\\dbx\\mcp\\dist\\index.js"] }));
|
||||
const config = JSON.parse(buildMcpVsCodeConfig({ command: "node", args: ["C:\\dbx\\mcp\\dist\\index.js"] }));
|
||||
|
||||
expect(config).toEqual({
|
||||
servers: {
|
||||
@@ -65,40 +70,59 @@ describe("MCP config templates", () => {
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
args: ["C:\\dbx\\mcp\\dist\\index.js"],
|
||||
env: {
|
||||
DBX_MCP_ALLOW_WRITES: "0",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("builds Codex TOML config with env entries", () => {
|
||||
expect(buildMcpCodexConfig([["DBX_MCP_ALLOW_WRITES", "0"]])).toBe(["[mcp_servers.dbx]", 'command = "dbx-mcp-server"', "", "[mcp_servers.dbx.env]", 'DBX_MCP_ALLOW_WRITES = "0"'].join("\n"));
|
||||
it("builds the Cherry Studio stdio configuration", () => {
|
||||
const config = JSON.parse(
|
||||
buildMcpCherryStudioConfig({
|
||||
command: "/opt/homebrew/bin/node",
|
||||
args: ["/opt/dbx/mcp-server/dist/index.js"],
|
||||
env: { DBX_WEB_URL: "https://dbx.example.com" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(config).toEqual({
|
||||
mcpServers: {
|
||||
dbx: {
|
||||
name: "dbx",
|
||||
description: "",
|
||||
baseUrl: "",
|
||||
command: "/opt/homebrew/bin/node",
|
||||
args: ["/opt/dbx/mcp-server/dist/index.js"],
|
||||
env: { DBX_WEB_URL: "https://dbx.example.com" },
|
||||
isActive: true,
|
||||
type: "stdio",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("builds Codex TOML config without policy environment", () => {
|
||||
expect(buildMcpCodexConfig()).toBe(["[mcp_servers.dbx]", 'command = "dbx-mcp-server"'].join("\n"));
|
||||
});
|
||||
|
||||
it("builds Codex TOML config with a direct node launch command", () => {
|
||||
expect(buildMcpCodexConfig([["DBX_MCP_ALLOW_WRITES", "0"]], { command: "node", args: ["C:\\dbx\\mcp\\dist\\index.js"] })).toBe(["[mcp_servers.dbx]", 'command = "node"', 'args = ["C:\\\\dbx\\\\mcp\\\\dist\\\\index.js"]', "", "[mcp_servers.dbx.env]", 'DBX_MCP_ALLOW_WRITES = "0"'].join("\n"));
|
||||
expect(buildMcpCodexConfig({ command: "node", args: ["C:\\dbx\\mcp\\dist\\index.js"] })).toBe(["[mcp_servers.dbx]", 'command = "node"', 'args = ["C:\\\\dbx\\\\mcp\\\\dist\\\\index.js"]'].join("\n"));
|
||||
});
|
||||
|
||||
it("builds OpenCode config using environment entries", () => {
|
||||
const config = JSON.parse(buildMcpOpenCodeConfig([["DBX_MCP_ALLOW_DANGEROUS_SQL", "1"]]));
|
||||
it("builds OpenCode config without policy environment", () => {
|
||||
const config = JSON.parse(buildMcpOpenCodeConfig());
|
||||
|
||||
expect(config).toEqual({
|
||||
mcp: {
|
||||
dbx: {
|
||||
type: "local",
|
||||
command: ["dbx-mcp-server"],
|
||||
environment: {
|
||||
DBX_MCP_ALLOW_DANGEROUS_SQL: "1",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("builds OpenCode config with a direct node launch command", () => {
|
||||
const config = JSON.parse(buildMcpOpenCodeConfig([], { command: "node", args: ["C:\\dbx\\mcp\\dist\\index.js"] }));
|
||||
const config = JSON.parse(buildMcpOpenCodeConfig({ command: "node", args: ["C:\\dbx\\mcp\\dist\\index.js"] }));
|
||||
|
||||
expect(config).toEqual({
|
||||
mcp: {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { groupMcpScopeConnections, isMcpPolicyMutationBlocked, matchesMcpSearchQuery, MCP_CAPABILITY_ROWS, MCP_EXECUTION_MODE_COLUMNS, mcpExecutionModeFromPolicy, mcpPolicyFieldsForExecutionMode, toggleMcpAllowedConnectionId, updateMcpAllowedConnectionIds } from "@/lib/mcp/mcpPolicySelection";
|
||||
|
||||
const settingsDialogSource = readFileSync(new URL("../../../components/editor/EditorSettingsDialog.vue", import.meta.url), "utf8");
|
||||
const scopePickerSource = readFileSync(new URL("../../../components/settings/McpConnectionScopePicker.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("MCP execution permission selection", () => {
|
||||
it("maps the persisted policy to the three UI modes", () => {
|
||||
expect(mcpExecutionModeFromPolicy({ readOnly: true, allowDangerousSql: false })).toBe("read_only");
|
||||
expect(mcpExecutionModeFromPolicy({ readOnly: false, allowDangerousSql: false })).toBe("safe_write");
|
||||
expect(mcpExecutionModeFromPolicy({ readOnly: false, allowDangerousSql: true })).toBe("high_risk_write");
|
||||
});
|
||||
|
||||
it("treats read-only as authoritative when legacy state also allows dangerous SQL", () => {
|
||||
expect(mcpExecutionModeFromPolicy({ readOnly: true, allowDangerousSql: true })).toBe("read_only");
|
||||
});
|
||||
|
||||
it("maps every UI mode to a complete atomic policy update", () => {
|
||||
expect(mcpPolicyFieldsForExecutionMode("read_only")).toEqual({ readOnly: true, allowDangerousSql: false });
|
||||
expect(mcpPolicyFieldsForExecutionMode("safe_write")).toEqual({ readOnly: false, allowDangerousSql: false });
|
||||
expect(mcpPolicyFieldsForExecutionMode("high_risk_write")).toEqual({ readOnly: false, allowDangerousSql: true });
|
||||
});
|
||||
|
||||
it("presents the stable internal modes as three user-facing columns", () => {
|
||||
expect(MCP_EXECUTION_MODE_COLUMNS.map((column) => column.mode)).toEqual(["read_only", "safe_write", "high_risk_write"]);
|
||||
});
|
||||
|
||||
it("shows the risk-based capability boundary without changing enforcement semantics", () => {
|
||||
expect(MCP_CAPABILITY_ROWS).toEqual([
|
||||
{ labelKey: "settings.mcpCapabilityRead", read_only: true, safe_write: true, high_risk_write: true },
|
||||
{ labelKey: "settings.mcpCapabilityScopedMutation", read_only: false, safe_write: true, high_risk_write: true },
|
||||
{ labelKey: "settings.mcpCapabilityBroadMutation", read_only: false, safe_write: false, high_risk_write: true },
|
||||
{ labelKey: "settings.mcpCapabilitySchemaAdmin", read_only: false, safe_write: false, high_risk_write: true },
|
||||
{ labelKey: "settings.mcpCapabilityConnectionManagement", read_only: false, safe_write: true, high_risk_write: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCP policy connection selection", () => {
|
||||
it("turns allow-all into an explicit list when one connection is removed", () => {
|
||||
expect(toggleMcpAllowedConnectionId(null, ["one", "two", "three"], "two", false)).toEqual(["one", "three"]);
|
||||
});
|
||||
|
||||
it("updates an existing explicit allowlist", () => {
|
||||
expect(toggleMcpAllowedConnectionId(["one"], ["one", "two"], "two", true)).toEqual(["one", "two"]);
|
||||
expect(toggleMcpAllowedConnectionId(["one", "two"], ["one", "two"], "one", false)).toEqual(["two"]);
|
||||
});
|
||||
|
||||
it("groups live and unavailable connections without losing source order", () => {
|
||||
const one = { id: "one", name: "One" };
|
||||
const two = { id: "two", name: "Two" };
|
||||
const three = { id: "three", name: "Three" };
|
||||
expect(groupMcpScopeConnections([one, two, three], ["three", "missing", "one"])).toEqual({
|
||||
allowed: [one, three],
|
||||
available: [two],
|
||||
unavailableAllowedIds: ["missing"],
|
||||
});
|
||||
expect(groupMcpScopeConnections([one, two], null)).toEqual({
|
||||
allowed: [one, two],
|
||||
available: [],
|
||||
unavailableAllowedIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("applies batch additions and removals while preserving unavailable IDs", () => {
|
||||
expect(updateMcpAllowedConnectionIds(["one", "missing"], ["one", "two", "three"], ["two", "three"], true)).toEqual(["one", "missing", "two", "three"]);
|
||||
expect(updateMcpAllowedConnectionIds(null, ["one", "two", "three"], ["one", "three"], false)).toEqual(["two"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCP policy settings state", () => {
|
||||
it("blocks mutations while loading, saving, or displaying a load error", () => {
|
||||
expect(isMcpPolicyMutationBlocked({ loading: true, saving: false, loadError: "" })).toBe(true);
|
||||
expect(isMcpPolicyMutationBlocked({ loading: false, saving: true, loadError: "" })).toBe(true);
|
||||
expect(isMcpPolicyMutationBlocked({ loading: false, saving: false, loadError: "unavailable" })).toBe(true);
|
||||
expect(isMcpPolicyMutationBlocked({ loading: false, saving: false, loadError: "" })).toBe(false);
|
||||
});
|
||||
|
||||
it("guards the mutation entry point and wires the shared disabled state to policy controls", () => {
|
||||
expect(settingsDialogSource).toContain("if (mcpPolicyControlsDisabled.value) return;");
|
||||
expect(settingsDialogSource).toContain(':disabled="mcpPolicyControlsDisabled"');
|
||||
expect(settingsDialogSource).toContain('@update:allowed-connection-ids="onMcpAllowedConnectionIdsChange"');
|
||||
expect(settingsDialogSource).toContain('<fieldset :disabled="mcpPolicyControlsDisabled"');
|
||||
|
||||
const loadingStart = settingsDialogSource.indexOf("mcpPolicyLoading.value = true;");
|
||||
const policyLoad = settingsDialogSource.indexOf("await settingsStore.initMcpGlobalPolicy(true);");
|
||||
const loadingEnd = settingsDialogSource.indexOf("mcpPolicyLoading.value = false;", policyLoad);
|
||||
expect(loadingStart).toBeGreaterThan(-1);
|
||||
expect(loadingStart).toBeLessThan(policyLoad);
|
||||
expect(loadingEnd).toBeGreaterThan(policyLoad);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCP connection search", () => {
|
||||
it("matches case-insensitively across text, numeric fields, and connection IDs", () => {
|
||||
const values = ["MySQL Local", "mysql", "127.0.0.1", 3306, "app_db", "connection-ABC"];
|
||||
expect(matchesMcpSearchQuery(" mysql ", values)).toBe(true);
|
||||
expect(matchesMcpSearchQuery("3306", values)).toBe(true);
|
||||
expect(matchesMcpSearchQuery("connection-abc", values)).toBe(true);
|
||||
expect(matchesMcpSearchQuery("postgres", values)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats an empty query as a match and can search unavailable IDs", () => {
|
||||
expect(matchesMcpSearchQuery(" ", [null, undefined])).toBe(true);
|
||||
expect(matchesMcpSearchQuery("missing-id", ["missing-id-123", "Previously selected connection (unavailable)"])).toBe(true);
|
||||
});
|
||||
|
||||
it("uses responsive allowed and available panes with an allowed-first compact view", () => {
|
||||
expect(scopePickerSource).toContain('const compactPane = ref<ScopePane>("allowed")');
|
||||
expect(scopePickerSource).toContain('data-scope-pane="available"');
|
||||
expect(scopePickerSource).toContain('data-scope-pane="allowed"');
|
||||
expect(scopePickerSource).toContain("@container mcp-scope (min-width: 42rem)");
|
||||
expect(scopePickerSource).toContain("filteredUnavailableAllowedIds");
|
||||
});
|
||||
});
|
||||
@@ -251,6 +251,8 @@ export const saveAiProviderConfig = forward("saveAiProviderConfig");
|
||||
export const loadAiProviderConfigs = forward("loadAiProviderConfigs");
|
||||
export const loadDesktopSettings = forward("loadDesktopSettings");
|
||||
export const saveDesktopSettings = forward("saveDesktopSettings");
|
||||
export const loadMcpGlobalPolicy = forward("loadMcpGlobalPolicy");
|
||||
export const saveMcpGlobalPolicy = forward("saveMcpGlobalPolicy");
|
||||
export const completeAppClose = forward("completeAppClose");
|
||||
export const requestAppClose = forward("requestAppClose");
|
||||
export const setDriverStoreDir = forward("setDriverStoreDir");
|
||||
|
||||
@@ -54,6 +54,7 @@ import type {
|
||||
UpgradeAllAgentDriversResult,
|
||||
AgentUpdateBlocker,
|
||||
DesktopSettings,
|
||||
McpGlobalPolicy,
|
||||
SavedSqlSyncRequest,
|
||||
DriverInstallProgress,
|
||||
JavaRuntimeConfig,
|
||||
@@ -1167,6 +1168,19 @@ export async function saveDesktopSettings(settings: DesktopSettings): Promise<vo
|
||||
safeLocalStorageSet(DESKTOP_SETTINGS_STORAGE_KEY, JSON.stringify({ ...DEFAULT_DESKTOP_SETTINGS, ...settings }));
|
||||
}
|
||||
|
||||
export async function loadMcpGlobalPolicy(): Promise<McpGlobalPolicy> {
|
||||
return get("/api/app-settings/mcp-policy");
|
||||
}
|
||||
|
||||
export async function saveMcpGlobalPolicy(policy: Omit<McpGlobalPolicy, "configured">): Promise<void> {
|
||||
const res = await fetch(apiUrl("/api/app-settings/mcp-policy"), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(policy),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
}
|
||||
|
||||
export interface OpenTabsStatePayload {
|
||||
tabs: unknown[];
|
||||
activeTabId: string | null;
|
||||
|
||||
@@ -166,6 +166,13 @@ export interface DesktopSettings {
|
||||
sidebar_table_page_size?: number | null;
|
||||
}
|
||||
|
||||
export interface McpGlobalPolicy {
|
||||
readOnly: boolean;
|
||||
allowDangerousSql: boolean;
|
||||
allowedConnectionIds: string[] | null;
|
||||
configured: boolean;
|
||||
}
|
||||
|
||||
export interface SavedSqlSyncEntry {
|
||||
folderName?: string;
|
||||
fileName: string;
|
||||
@@ -448,6 +455,14 @@ export async function saveDesktopSettings(settings: DesktopSettings): Promise<vo
|
||||
return invoke("save_desktop_settings", { settings });
|
||||
}
|
||||
|
||||
export async function loadMcpGlobalPolicy(): Promise<McpGlobalPolicy> {
|
||||
return invoke("load_mcp_global_policy");
|
||||
}
|
||||
|
||||
export async function saveMcpGlobalPolicy(policy: Omit<McpGlobalPolicy, "configured">): Promise<void> {
|
||||
return invoke("save_mcp_global_policy", { policy });
|
||||
}
|
||||
|
||||
export interface OpenTabsStatePayload {
|
||||
tabs: unknown[];
|
||||
activeTabId: string | null;
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
export type McpEnvEntry = readonly [key: string, value: string];
|
||||
|
||||
export interface McpLaunchConfig {
|
||||
command: string;
|
||||
args?: readonly string[];
|
||||
env?: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
const DEFAULT_MCP_LAUNCH_CONFIG: McpLaunchConfig = {
|
||||
command: "dbx-mcp-server",
|
||||
};
|
||||
|
||||
function envObject(envEntries: readonly McpEnvEntry[]): Record<string, string> {
|
||||
return Object.fromEntries(envEntries);
|
||||
}
|
||||
|
||||
function launchConfig(config?: McpLaunchConfig): McpLaunchConfig {
|
||||
return config ?? DEFAULT_MCP_LAUNCH_CONFIG;
|
||||
}
|
||||
@@ -23,6 +18,9 @@ function withLaunchConfig(dbx: Record<string, unknown>, config?: McpLaunchConfig
|
||||
if (launch.args && launch.args.length > 0) {
|
||||
dbx.args = [...launch.args];
|
||||
}
|
||||
if (launch.env && Object.keys(launch.env).length > 0) {
|
||||
dbx.env = { ...launch.env };
|
||||
}
|
||||
return dbx;
|
||||
}
|
||||
|
||||
@@ -30,43 +28,53 @@ function tomlStringArray(values: readonly string[]): string {
|
||||
return `[${values.map((value) => JSON.stringify(value)).join(", ")}]`;
|
||||
}
|
||||
|
||||
export function buildMcpJsonConfig(envEntries: readonly McpEnvEntry[] = [], config?: McpLaunchConfig): string {
|
||||
export function mcpWebBackendUrl(origin: string, apiPath: string): string {
|
||||
return new URL(apiPath, origin).toString().replace(/\/api\/?$/, "");
|
||||
}
|
||||
|
||||
export function buildMcpJsonConfig(config?: McpLaunchConfig): string {
|
||||
const dbx: Record<string, unknown> = {
|
||||
...withLaunchConfig({}, config),
|
||||
};
|
||||
|
||||
if (envEntries.length > 0) {
|
||||
dbx.env = envObject(envEntries);
|
||||
}
|
||||
|
||||
return JSON.stringify({ mcpServers: { dbx } }, null, 2);
|
||||
}
|
||||
|
||||
export function buildMcpVsCodeConfig(envEntries: readonly McpEnvEntry[] = [], config?: McpLaunchConfig): string {
|
||||
export function buildMcpVsCodeConfig(config?: McpLaunchConfig): string {
|
||||
const dbx: Record<string, unknown> = {
|
||||
type: "stdio",
|
||||
...withLaunchConfig({}, config),
|
||||
};
|
||||
|
||||
if (envEntries.length > 0) {
|
||||
dbx.env = envObject(envEntries);
|
||||
}
|
||||
|
||||
return JSON.stringify({ servers: { dbx } }, null, 2);
|
||||
}
|
||||
|
||||
export function buildMcpCodexConfig(envEntries: readonly McpEnvEntry[] = [], config?: McpLaunchConfig): string {
|
||||
export function buildMcpCherryStudioConfig(config?: McpLaunchConfig): string {
|
||||
const launch = launchConfig(config);
|
||||
const dbx: Record<string, unknown> = {
|
||||
name: "dbx",
|
||||
description: "",
|
||||
baseUrl: "",
|
||||
command: launch.command,
|
||||
args: [...(launch.args ?? [])],
|
||||
env: { ...launch.env },
|
||||
isActive: true,
|
||||
type: "stdio",
|
||||
};
|
||||
|
||||
return JSON.stringify({ mcpServers: { dbx } }, null, 2);
|
||||
}
|
||||
|
||||
export function buildMcpCodexConfig(config?: McpLaunchConfig): string {
|
||||
const launch = launchConfig(config);
|
||||
const lines = ["[mcp_servers.dbx]", `command = ${JSON.stringify(launch.command)}`];
|
||||
|
||||
if (launch.args && launch.args.length > 0) {
|
||||
lines.push(`args = ${tomlStringArray(launch.args)}`);
|
||||
}
|
||||
|
||||
if (envEntries.length > 0) {
|
||||
lines.push("");
|
||||
lines.push("[mcp_servers.dbx.env]");
|
||||
for (const [key, value] of envEntries) {
|
||||
if (launch.env && Object.keys(launch.env).length > 0) {
|
||||
lines.push("", "[mcp_servers.dbx.env]");
|
||||
for (const [key, value] of Object.entries(launch.env)) {
|
||||
lines.push(`${key} = ${JSON.stringify(value)}`);
|
||||
}
|
||||
}
|
||||
@@ -74,15 +82,14 @@ export function buildMcpCodexConfig(envEntries: readonly McpEnvEntry[] = [], con
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function buildMcpOpenCodeConfig(envEntries: readonly McpEnvEntry[] = [], config?: McpLaunchConfig): string {
|
||||
export function buildMcpOpenCodeConfig(config?: McpLaunchConfig): string {
|
||||
const launch = launchConfig(config);
|
||||
const dbx: Record<string, unknown> = {
|
||||
type: "local",
|
||||
command: [launch.command, ...(launch.args ?? [])],
|
||||
};
|
||||
|
||||
if (envEntries.length > 0) {
|
||||
dbx.environment = envObject(envEntries);
|
||||
if (launch.env && Object.keys(launch.env).length > 0) {
|
||||
dbx.environment = { ...launch.env };
|
||||
}
|
||||
|
||||
return JSON.stringify({ mcp: { dbx } }, null, 2);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
export type McpExecutionMode = "read_only" | "safe_write" | "high_risk_write";
|
||||
|
||||
export interface McpCapabilityRow {
|
||||
labelKey: string;
|
||||
read_only: boolean;
|
||||
safe_write: boolean;
|
||||
high_risk_write: boolean;
|
||||
}
|
||||
|
||||
export const MCP_EXECUTION_MODE_COLUMNS: ReadonlyArray<{ mode: McpExecutionMode; labelKey: string }> = [
|
||||
{ mode: "read_only", labelKey: "settings.mcpExecutionModeReadOnly" },
|
||||
{ mode: "safe_write", labelKey: "settings.mcpExecutionModeSafeWrite" },
|
||||
{ mode: "high_risk_write", labelKey: "settings.mcpExecutionModeHighRiskWrite" },
|
||||
];
|
||||
|
||||
export const MCP_CAPABILITY_ROWS: readonly McpCapabilityRow[] = [
|
||||
{ labelKey: "settings.mcpCapabilityRead", read_only: true, safe_write: true, high_risk_write: true },
|
||||
{ labelKey: "settings.mcpCapabilityScopedMutation", read_only: false, safe_write: true, high_risk_write: true },
|
||||
{ labelKey: "settings.mcpCapabilityBroadMutation", read_only: false, safe_write: false, high_risk_write: true },
|
||||
{ labelKey: "settings.mcpCapabilitySchemaAdmin", read_only: false, safe_write: false, high_risk_write: true },
|
||||
{ labelKey: "settings.mcpCapabilityConnectionManagement", read_only: false, safe_write: true, high_risk_write: true },
|
||||
];
|
||||
|
||||
export interface McpExecutionPolicyFields {
|
||||
readOnly: boolean;
|
||||
allowDangerousSql: boolean;
|
||||
}
|
||||
|
||||
export interface McpPolicyMutationState {
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
loadError: string;
|
||||
}
|
||||
|
||||
export interface McpScopeConnectionLike {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface McpScopeConnectionGroups<T extends McpScopeConnectionLike> {
|
||||
allowed: T[];
|
||||
available: T[];
|
||||
unavailableAllowedIds: string[];
|
||||
}
|
||||
|
||||
export function isMcpPolicyMutationBlocked(state: McpPolicyMutationState): boolean {
|
||||
return state.loading || state.saving || state.loadError.length > 0;
|
||||
}
|
||||
|
||||
export function matchesMcpSearchQuery(rawQuery: string, values: readonly unknown[]): boolean {
|
||||
const query = rawQuery.trim().toLocaleLowerCase();
|
||||
if (!query) return true;
|
||||
return values.some((value) => value !== null && value !== undefined && String(value).toLocaleLowerCase().includes(query));
|
||||
}
|
||||
|
||||
export function groupMcpScopeConnections<T extends McpScopeConnectionLike>(connections: readonly T[], allowedConnectionIds: readonly string[] | null): McpScopeConnectionGroups<T> {
|
||||
if (allowedConnectionIds === null) {
|
||||
return {
|
||||
allowed: [...connections],
|
||||
available: [],
|
||||
unavailableAllowedIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
const allowedIds = new Set(allowedConnectionIds);
|
||||
const connectionIds = new Set(connections.map((connection) => connection.id));
|
||||
return {
|
||||
allowed: connections.filter((connection) => allowedIds.has(connection.id)),
|
||||
available: connections.filter((connection) => !allowedIds.has(connection.id)),
|
||||
unavailableAllowedIds: allowedConnectionIds.filter((id) => !connectionIds.has(id)),
|
||||
};
|
||||
}
|
||||
|
||||
export function mcpExecutionModeFromPolicy(policy: McpExecutionPolicyFields): McpExecutionMode {
|
||||
if (policy.readOnly) return "read_only";
|
||||
return policy.allowDangerousSql ? "high_risk_write" : "safe_write";
|
||||
}
|
||||
|
||||
export function mcpPolicyFieldsForExecutionMode(mode: McpExecutionMode): McpExecutionPolicyFields {
|
||||
switch (mode) {
|
||||
case "read_only":
|
||||
return { readOnly: true, allowDangerousSql: false };
|
||||
case "safe_write":
|
||||
return { readOnly: false, allowDangerousSql: false };
|
||||
case "high_risk_write":
|
||||
return { readOnly: false, allowDangerousSql: true };
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleMcpAllowedConnectionId(current: readonly string[] | null, availableConnectionIds: readonly string[], connectionId: string, allowed: boolean): string[] {
|
||||
return updateMcpAllowedConnectionIds(current, availableConnectionIds, [connectionId], allowed);
|
||||
}
|
||||
|
||||
export function updateMcpAllowedConnectionIds(current: readonly string[] | null, availableConnectionIds: readonly string[], connectionIds: readonly string[], allowed: boolean): string[] {
|
||||
const selected = new Set(current === null ? availableConnectionIds : current);
|
||||
for (const connectionId of connectionIds) {
|
||||
if (allowed) selected.add(connectionId);
|
||||
else selected.delete(connectionId);
|
||||
}
|
||||
return [...selected];
|
||||
}
|
||||
@@ -598,25 +598,15 @@ export function evaluateMongoWriteSafety(command: MongoWriteCommand, options: Mo
|
||||
if (!options.allowWrites) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "MCP MongoDB execution is read-only by default. Set DBX_MCP_ALLOW_WRITES=1 to allow write commands.",
|
||||
reason: "MCP MongoDB execution is read-only under the current DBX policy.",
|
||||
};
|
||||
}
|
||||
if (!options.allowDangerous && (command.kind === "update" || command.kind === "delete" || command.kind === "findOneAndUpdate" || command.kind === "findOneAndReplace" || command.kind === "findOneAndDelete") && isEmptyJsonObject(command.filter)) {
|
||||
const filter = mongoWriteFilter(command);
|
||||
const highRisk = filter !== null ? mongoFilterIsEffectivelyUnbounded(filter) : command.kind !== "insert";
|
||||
if (!options.allowDangerous && highRisk) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "MongoDB update/delete commands must include a non-empty filter unless DBX_MCP_ALLOW_DANGEROUS_SQL=1 is set.",
|
||||
};
|
||||
}
|
||||
if (!options.allowDangerous && mongoDropIndexesRequiresDangerous(command)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "MongoDB dropIndexes() without a specific single index requires DBX_MCP_ALLOW_DANGEROUS_SQL=1.",
|
||||
};
|
||||
}
|
||||
if (!options.allowDangerous && command.kind === "dropCollection") {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "MongoDB drop() requires DBX_MCP_ALLOW_DANGEROUS_SQL=1.",
|
||||
reason: `MongoDB ${command.kind} requires high-risk operations to be enabled in DBX MCP settings.`,
|
||||
};
|
||||
}
|
||||
return { allowed: true };
|
||||
@@ -643,13 +633,13 @@ export function evaluateMongoAggregateSafety(command: MongoAggregateCommand, opt
|
||||
if (!options.allowWrites) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `MongoDB aggregate stage "${writeStage}" writes data. Set DBX_MCP_ALLOW_WRITES=1 to allow write commands.`,
|
||||
reason: `MongoDB aggregate stage "${writeStage}" is blocked by the current DBX MCP read-only policy.`,
|
||||
};
|
||||
}
|
||||
if (!options.allowDangerous) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `MongoDB aggregate stage "${writeStage}" is dangerous. Set DBX_MCP_ALLOW_DANGEROUS_SQL=1 to allow it.`,
|
||||
reason: `MongoDB aggregate stage "${writeStage}" requires high-risk operations to be enabled in DBX MCP settings.`,
|
||||
};
|
||||
}
|
||||
return { allowed: true };
|
||||
@@ -1179,17 +1169,164 @@ function isNonEmptyRecord(value: unknown): value is Record<string, unknown> {
|
||||
return isRecord(value) && Object.keys(value).length > 0;
|
||||
}
|
||||
|
||||
function isEmptyJsonObject(json: string): boolean {
|
||||
const parsed = parseNormalizedJson(json);
|
||||
return isRecord(parsed) && Object.keys(parsed).length === 0;
|
||||
function mongoWriteFilter(command: MongoWriteCommand): string | null {
|
||||
switch (command.kind) {
|
||||
case "update":
|
||||
case "delete":
|
||||
case "findOneAndUpdate":
|
||||
case "findOneAndReplace":
|
||||
case "findOneAndDelete":
|
||||
return command.filter;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function mongoDropIndexesRequiresDangerous(command: MongoWriteCommand): boolean {
|
||||
if (command.kind !== "dropIndexes") return false;
|
||||
if (!command.indexes) return true;
|
||||
const parsed = parseNormalizedJson(command.indexes);
|
||||
if (parsed === "*") return true;
|
||||
return Array.isArray(parsed) && parsed.length > 1;
|
||||
function mongoFilterIsEffectivelyUnbounded(json: string): boolean {
|
||||
const parsed = parseNormalizedJson(json);
|
||||
return !isRecord(parsed) || mongoFilterContainsOpaqueLogic(parsed) || mongoFilterObjectIsUnbounded(parsed);
|
||||
}
|
||||
|
||||
function mongoFilterContainsOpaqueLogic(filter: Record<string, unknown>): boolean {
|
||||
return Object.entries(filter).some(([key, value]) => {
|
||||
if (key === "$comment") return false;
|
||||
if (key === "$where" || key === "$expr" || key === "$nor") return true;
|
||||
if (key === "$and" || key === "$or") {
|
||||
if (!Array.isArray(value) || value.length === 0 || value.some((clause) => !isRecord(clause))) return true;
|
||||
if (value.some((clause) => mongoFilterContainsOpaqueLogic(clause as Record<string, unknown>))) return true;
|
||||
if (key === "$or" && value.some((clause) => isRecord(clause) && Object.prototype.hasOwnProperty.call(clause, "$and"))) return true;
|
||||
return key === "$or" && mongoOrHasComplementaryFieldClauses(value);
|
||||
}
|
||||
return key.startsWith("$") || mongoFieldPredicateContainsOpaqueLogic(value);
|
||||
});
|
||||
}
|
||||
|
||||
const MONGO_SAFE_FIELD_OPERATORS = new Set(["$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$in", "$nin", "$exists"]);
|
||||
|
||||
function mongoFieldPredicateContainsOpaqueLogic(value: unknown): boolean {
|
||||
if (!isRecord(value)) return false;
|
||||
if (mongoExtendedJsonScalarLiteralIsValid(value)) return false;
|
||||
const keys = Object.keys(value);
|
||||
if (!keys.some((key) => key.startsWith("$"))) return false;
|
||||
return keys.some((key) => !key.startsWith("$") || !MONGO_SAFE_FIELD_OPERATORS.has(key));
|
||||
}
|
||||
|
||||
interface MongoPureFieldPredicate {
|
||||
field: string;
|
||||
operator: string;
|
||||
operand: unknown;
|
||||
}
|
||||
|
||||
function mongoOrHasComplementaryFieldClauses(clauses: unknown[]): boolean {
|
||||
const predicates = clauses.map(mongoPureFieldPredicate).filter((value): value is MongoPureFieldPredicate => value !== null);
|
||||
return predicates.some((predicate, index) => predicates.slice(index + 1).some((other) => mongoFieldPredicatesAreComplementary(predicate, other)));
|
||||
}
|
||||
|
||||
function mongoPureFieldPredicate(value: unknown): MongoPureFieldPredicate | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const entries = Object.entries(value).filter(([key]) => key !== "$comment");
|
||||
if (entries.length !== 1) return null;
|
||||
const [field, predicate] = entries[0]!;
|
||||
if (field === "$and" && Array.isArray(predicate)) {
|
||||
const boundedClauses = predicate.filter((clause) => isRecord(clause) && !mongoFilterObjectIsUnbounded(clause));
|
||||
return boundedClauses.length === 1 ? mongoPureFieldPredicate(boundedClauses[0]) : null;
|
||||
}
|
||||
if (field === "$or" && Array.isArray(predicate) && predicate.length === 1) {
|
||||
return mongoPureFieldPredicate(predicate[0]);
|
||||
}
|
||||
if (field.startsWith("$")) return null;
|
||||
if (!isRecord(predicate) || mongoExtendedJsonScalarLiteralIsValid(predicate) || !Object.keys(predicate).some((key) => key.startsWith("$"))) {
|
||||
return { field, operator: "$eq", operand: predicate };
|
||||
}
|
||||
const operators = Object.entries(predicate);
|
||||
if (operators.length !== 1 || !MONGO_SAFE_FIELD_OPERATORS.has(operators[0]![0])) return null;
|
||||
return { field, operator: operators[0]![0], operand: operators[0]![1] };
|
||||
}
|
||||
|
||||
function mongoFieldPredicatesAreComplementary(left: MongoPureFieldPredicate, right: MongoPureFieldPredicate): boolean {
|
||||
if (left.field !== right.field) return false;
|
||||
if (left.operator === "$exists" && right.operator === "$exists") {
|
||||
return typeof left.operand === "boolean" && typeof right.operand === "boolean" && left.operand !== right.operand;
|
||||
}
|
||||
const pair = `${left.operator}/${right.operator}`;
|
||||
if (pair === "$in/$nin" || pair === "$nin/$in") return mongoJsonSetsEqual(left.operand, right.operand);
|
||||
if (!["$eq/$ne", "$ne/$eq", "$gt/$lte", "$lte/$gt", "$gte/$lt", "$lt/$gte"].includes(pair)) return false;
|
||||
return mongoJsonValuesEqual(left.operand, right.operand);
|
||||
}
|
||||
|
||||
function mongoJsonSetsEqual(left: unknown, right: unknown): boolean {
|
||||
if (!Array.isArray(left) || !Array.isArray(right)) return false;
|
||||
return left.every((value) => right.some((other) => mongoJsonValuesEqual(value, other))) && right.every((value) => left.some((other) => mongoJsonValuesEqual(value, other)));
|
||||
}
|
||||
|
||||
function mongoJsonValuesEqual(left: unknown, right: unknown): boolean {
|
||||
if (Object.is(left, right)) return true;
|
||||
if (Array.isArray(left) || Array.isArray(right)) {
|
||||
return Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((value, index) => mongoJsonValuesEqual(value, right[index]));
|
||||
}
|
||||
if (!isRecord(left) || !isRecord(right)) return false;
|
||||
const leftKeys = Object.keys(left).sort();
|
||||
const rightKeys = Object.keys(right).sort();
|
||||
return leftKeys.length === rightKeys.length && leftKeys.every((key, index) => key === rightKeys[index] && mongoJsonValuesEqual(left[key], right[key]));
|
||||
}
|
||||
|
||||
function mongoExtendedJsonScalarLiteralIsValid(value: Record<string, unknown>): boolean {
|
||||
const entries = Object.entries(value);
|
||||
if (entries.length !== 1) return false;
|
||||
const [key, scalar] = entries[0]!;
|
||||
if (key === "$oid") return typeof scalar === "string" && /^[0-9a-fA-F]{24}$/.test(scalar);
|
||||
if (key === "$numberLong") return typeof scalar === "string" && mongoInt64StringIsValid(scalar);
|
||||
return key === "$date" && typeof scalar === "string" && mongoRfc3339DateIsValid(scalar);
|
||||
}
|
||||
|
||||
function mongoInt64StringIsValid(value: string): boolean {
|
||||
if (!/^-?\d+$/.test(value)) return false;
|
||||
try {
|
||||
const parsed = BigInt(value);
|
||||
return parsed >= -9223372036854775808n && parsed <= 9223372036854775807n;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function mongoRfc3339DateIsValid(value: string): boolean {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-](\d{2}):(\d{2}))$/.exec(value);
|
||||
if (!match) return false;
|
||||
const [, yearText, monthText, dayText, hourText, minuteText, secondText, offsetHourText, offsetMinuteText] = match;
|
||||
const year = Number(yearText);
|
||||
const month = Number(monthText);
|
||||
const day = Number(dayText);
|
||||
const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
||||
const daysInMonth = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1] ?? 0;
|
||||
return day >= 1 && day <= daysInMonth && Number(hourText) <= 23 && Number(minuteText) <= 59 && Number(secondText) <= 59 && (offsetHourText === undefined || (Number(offsetHourText) <= 23 && Number(offsetMinuteText) <= 59));
|
||||
}
|
||||
|
||||
function mongoFilterObjectIsUnbounded(filter: Record<string, unknown>): boolean {
|
||||
const entries = Object.entries(filter);
|
||||
if (entries.length === 0) return true;
|
||||
if (entries.some(([key]) => key === "$where" || key === "$expr")) return true;
|
||||
|
||||
return entries.every(([key, value]) => {
|
||||
if (key === "$comment") return true;
|
||||
if (key === "$and") {
|
||||
return !Array.isArray(value) || value.every((clause) => !isRecord(clause) || mongoFilterObjectIsUnbounded(clause));
|
||||
}
|
||||
if (key === "$or") {
|
||||
return !Array.isArray(value) || value.length === 0 || value.some((clause) => !isRecord(clause) || mongoFilterObjectIsUnbounded(clause));
|
||||
}
|
||||
if (key === "$nor") return true;
|
||||
if (mongoFieldPredicateIsEmptyNin(value)) return true;
|
||||
if (key === "_id" && mongoFieldPredicateIsExistsTrue(value)) return true;
|
||||
return key.startsWith("$");
|
||||
});
|
||||
}
|
||||
|
||||
function mongoFieldPredicateIsEmptyNin(value: unknown): boolean {
|
||||
return isRecord(value) && Object.keys(value).length === 1 && Array.isArray(value.$nin) && value.$nin.length === 0;
|
||||
}
|
||||
|
||||
function mongoFieldPredicateIsExistsTrue(value: unknown): boolean {
|
||||
return isRecord(value) && Object.keys(value).length === 1 && value.$exists === true;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
|
||||
Reference in new issue
Block a user