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

+57 -33
View File
@@ -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");
});
});
+2
View File
@@ -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");
+14
View File
@@ -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;
+15
View File
@@ -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;
+33 -26
View File
@@ -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];
}
+163 -26
View File
@@ -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> {