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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user