From 1ceeaad8a4e92ae256cce0507a9f16af67585a9b Mon Sep 17 00:00:00 2001 From: Conastin Date: Fri, 11 Sep 2026 17:25:19 +0800 Subject: [PATCH] fix(mcp): keep manual database scope entries saved and visible --- .../settings/McpDatabaseScopePicker.vue | 10 ++- .../__tests__/McpDatabaseScopePicker.spec.ts | 67 +++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/components/settings/McpDatabaseScopePicker.vue b/apps/desktop/src/components/settings/McpDatabaseScopePicker.vue index eb01ec8970..face0c23dc 100644 --- a/apps/desktop/src/components/settings/McpDatabaseScopePicker.vue +++ b/apps/desktop/src/components/settings/McpDatabaseScopePicker.vue @@ -52,7 +52,9 @@ const selectedDatabases = computed(() => selectedPolicy.value?.allowedDatabases const selectedDatabasePolicies = computed(() => selectedPolicy.value?.databasePolicies ?? []); const displayedDatabases = computed(() => { const query = search.value.trim().toLocaleLowerCase(); - return (databasesByConnection.value[selectedConnectionId.value] ?? []).filter((database) => !query || database.toLocaleLowerCase().includes(query)); + // 加载缓存是会话级的:把已持久化的库名并入列表,重开对话框后授权项仍完整可见。 + const loaded = databasesByConnection.value[selectedConnectionId.value] ?? []; + return [...new Set([...selectedDatabases.value, ...loaded])].sort((left, right) => left.localeCompare(right)).filter((database) => !query || database.toLocaleLowerCase().includes(query)); }); const connectionPageCount = computed(() => Math.max(1, Math.ceil(scopedConnections.value.length / pageSize.value))); const databasePageCount = computed(() => Math.max(1, Math.ceil(displayedDatabases.value.length / pageSize.value))); @@ -151,6 +153,8 @@ function databasePolicyMode(database: string): ExecutionMode | "inherit" { } function addManualDatabase() { + // busy 期间 updateSelectedPolicy 会被丢弃;提前拦截,避免库名进了本地列表却没进策略。 + if (props.disabled || props.busy) return; const database = manualDatabase.value.trim(); if (!database) return; const loaded = databasesByConnection.value[selectedConnectionId.value] ?? []; @@ -322,8 +326,8 @@ watch(databasePageCount, () => setDatabasePage(databasePage.value));

{{ t("settings.mcpDatabaseScopeSelectedSummary", { count: selectedDatabases.length }) }}

- - + +

{{ t("settings.mcpDatabaseLoadFailed", { error: loadError }) }}

diff --git a/apps/desktop/src/components/settings/__tests__/McpDatabaseScopePicker.spec.ts b/apps/desktop/src/components/settings/__tests__/McpDatabaseScopePicker.spec.ts index 0816c07c5a..b0fc5c6535 100644 --- a/apps/desktop/src/components/settings/__tests__/McpDatabaseScopePicker.spec.ts +++ b/apps/desktop/src/components/settings/__tests__/McpDatabaseScopePicker.spec.ts @@ -1,6 +1,9 @@ // @vitest-environment happy-dom // MCP 数据库范围选择器回归测试: // 加载资源列表前先 ensureConnected(修复未连接的 SQL 连接报 Connection not found)。 +// 1) 正常流程:选择“仅指定数据库”→ 手工添加库名 → emit 携带 allowedDatabases。 +// 2) busy/disabled 期间手工添加被整体拦截,库名不再进入本地列表(修复静默丢失)。 +// 3) 重开对话框后已持久化的 allowedDatabases 重新显示并勾选(修复不回显)。 import { createApp, nextTick, type App } from "vue"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -182,4 +185,68 @@ describe("McpDatabaseScopePicker", () => { expect(second.root.textContent).toContain("ORA-12170"); expect(mocks.listDatabases).not.toHaveBeenCalled(); }); + + it("正常流程:选择 selected 后手工添加库名应 emit 携带 allowedDatabases", async () => { + // 阶段一:初始无策略,点击“仅指定数据库” + const first = mountHarness([]); + await nextTick(); + clickScopeRadio(first.root, "selected"); + await nextTick(); + expect(first.emitted.policies.length).toBe(1); + expect(first.emitted.policies[0][0].databaseScope).toBe("selected"); + first.app.unmount(); + harness = null; + + // 阶段二:模拟父组件 store 已回写 scope=selected,再手工添加库名 + const second = mountHarness([emptyPolicy("oracle-1", "selected")]); + await nextTick(); + await addManualDatabase(second.root, "mydb"); + + expect(second.emitted.policies.length).toBe(1); + const policy = second.emitted.policies[0][0]; + expect(policy.databaseScope).toBe("selected"); + expect(policy.allowedDatabases).toEqual(["mydb"]); + }); + + it("竞态场景:disabled 期间手工添加被整体拦截,库名不再进入本地列表", async () => { + const { emitted, root } = mountHarness([emptyPolicy("oracle-1", "selected")], true); + await nextTick(); + await addManualDatabase(root, "mydb"); + + expect(emitted.policies.length).toBe(0); + expect(root.textContent).not.toContain("mydb"); + // 手工输入框本身也应被禁用 + expect(manualInput(root).hasAttribute("disabled")).toBe(true); + }); + + it("重开对话框场景:已持久化的 allowedDatabases 重新显示在列表中并处于勾选状态", async () => { + // 模拟:策略已保存 scope=selected + allowedDatabases=["SAVEDDB"],加载缓存为空(新会话) + const policy: McpConnectionPolicy = { ...emptyPolicy("oracle-1", "selected"), allowedDatabases: ["SAVEDDB"] }; + const { root } = mountHarness([policy]); + await nextTick(); + + expect(root.textContent).toContain("SAVEDDB"); + expect(root.textContent).not.toContain("settings.mcpDatabaseEmptyHint"); + const checkbox = Array.from(root.querySelectorAll('input[type="checkbox"]')).find((i) => i.value === "SAVEDDB" || i.closest("label")?.textContent?.includes("SAVEDDB")); + expect(checkbox?.checked).toBe(true); + }); + + it("加载列表与已持久化库名取并集:手工添加过但服务器上不存在的库名仍可见", async () => { + mocks.listDatabases.mockResolvedValue([{ name: "SERVER_DB" }]); + const first = mountHarness([]); + await nextTick(); + clickScopeRadio(first.root, "selected"); + await nextTick(); + first.app.unmount(); + harness = null; + + const second = mountHarness([{ ...emptyPolicy("oracle-1", "selected"), allowedDatabases: ["MANUAL_DB"] }]); + await nextTick(); + loadButton(second.root).click(); + await new Promise((r) => setTimeout(r, 10)); + await nextTick(); + + expect(second.root.textContent).toContain("SERVER_DB"); + expect(second.root.textContent).toContain("MANUAL_DB"); + }); });