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

@@ -0,0 +1,380 @@
<script setup lang="ts">
import { computed, nextTick, ref, useId, watch } from "vue";
import { AlertTriangle, Minus, Plus, Search } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import TruncatedTextTooltip from "@/components/ui/TruncatedTextTooltip.vue";
import { groupMcpScopeConnections, matchesMcpSearchQuery, updateMcpAllowedConnectionIds } from "@/lib/mcp/mcpPolicySelection";
import type { ConnectionConfig } from "@/types/database";
type ScopePane = "available" | "allowed";
type ScopeMode = "all" | "selected";
const props = withDefaults(
defineProps<{
connections: readonly ConnectionConfig[];
allowedConnectionIds: readonly string[] | null;
disabled?: boolean;
busy?: boolean;
}>(),
{
disabled: false,
busy: false,
},
);
const emit = defineEmits<{
"update:allowedConnectionIds": [value: string[] | null];
}>();
const { t } = useI18n();
const pickerId = useId();
const rootRef = ref<HTMLElement>();
const searchQuery = ref("");
const compactPane = ref<ScopePane>("allowed");
const announcement = ref("");
const pendingFocus = ref<{ pane: ScopePane; index: number } | null>(null);
const connectionIds = computed(() => props.connections.map((connection) => connection.id));
const groups = computed(() => groupMcpScopeConnections(props.connections, props.allowedConnectionIds));
const scopeMode = computed<ScopeMode>(() => (props.allowedConnectionIds === null ? "all" : "selected"));
const searchActive = computed(() => searchQuery.value.trim().length > 0);
function connectionMatchesSearch(connection: ConnectionConfig): boolean {
return matchesMcpSearchQuery(searchQuery.value, [connection.name, connection.db_type, connection.host, connection.port, connection.database, connection.id]);
}
const filteredAvailableConnections = computed(() => groups.value.available.filter(connectionMatchesSearch));
const filteredAllowedConnections = computed(() => groups.value.allowed.filter(connectionMatchesSearch));
const filteredUnavailableAllowedIds = computed(() => groups.value.unavailableAllowedIds.filter((id) => matchesMcpSearchQuery(searchQuery.value, [id, t("settings.mcpScopeConnectionUnavailable")])));
const allowedVisibleCount = computed(() => filteredAllowedConnections.value.length + filteredUnavailableAllowedIds.value.length);
const allowedTotalCount = computed(() => groups.value.allowed.length + groups.value.unavailableAllowedIds.length);
const availableVisibleCount = computed(() => filteredAvailableConnections.value.length);
const availableTotalCount = computed(() => groups.value.available.length);
const allowedSummary = computed(() => (props.allowedConnectionIds === null ? t("settings.mcpScopeAllSummary", { count: props.connections.length }) : t("settings.mcpScopeSelectedSummary", { selected: groups.value.allowed.length, total: props.connections.length })));
const policyKey = computed(() => (props.allowedConnectionIds === null ? "*" : props.allowedConnectionIds.join("\u0000")));
function paneCount(visible: number, total: number): string {
return searchActive.value ? `${visible}/${total}` : String(total);
}
function connectionAddress(connection: ConnectionConfig): string {
const address = connection.host ? `${connection.host}:${connection.port}` : "";
return [address, connection.database].filter(Boolean).join(" · ") || connection.id;
}
function emitAllowedConnectionIds(value: string[] | null) {
if (props.disabled) return;
emit("update:allowedConnectionIds", value);
}
function setScopeMode(mode: ScopeMode) {
if (mode === scopeMode.value || props.disabled) return;
emitAllowedConnectionIds(mode === "all" ? null : [...connectionIds.value]);
}
function restorePendingFocus() {
const pending = pendingFocus.value;
if (!pending || props.busy) return;
void nextTick(() => {
if (props.busy) return;
const pane = rootRef.value?.querySelector<HTMLElement>(`[data-scope-pane="${pending.pane}"]`);
const actions = [...(pane?.querySelectorAll<HTMLButtonElement>("[data-scope-action]") ?? [])];
const target = actions[Math.min(pending.index, Math.max(0, actions.length - 1))];
if (target) target.focus();
else rootRef.value?.focus();
pendingFocus.value = null;
});
}
function updateConnections(connectionIdsToUpdate: readonly string[], allowed: boolean, event?: MouseEvent) {
if (props.disabled || connectionIdsToUpdate.length === 0) return;
const sourcePane: ScopePane = allowed ? "available" : "allowed";
if (event?.currentTarget instanceof HTMLElement) {
const pane = event.currentTarget.closest<HTMLElement>("[data-scope-pane]");
const actions = [...(pane?.querySelectorAll<HTMLButtonElement>("[data-scope-action]") ?? [])];
pendingFocus.value = { pane: sourcePane, index: Math.max(0, actions.indexOf(event.currentTarget as HTMLButtonElement)) };
}
emitAllowedConnectionIds(updateMcpAllowedConnectionIds(props.allowedConnectionIds, connectionIds.value, connectionIdsToUpdate, allowed));
restorePendingFocus();
}
function addVisibleConnections() {
updateConnections(
filteredAvailableConnections.value.map((connection) => connection.id),
true,
);
}
function removeVisibleConnections() {
updateConnections([...filteredAllowedConnections.value.map((connection) => connection.id), ...filteredUnavailableAllowedIds.value], false);
}
watch(
() => props.busy,
(busy) => {
if (!busy) restorePendingFocus();
},
);
watch([policyKey, () => groups.value.allowed.length], () => {
announcement.value = t("settings.mcpScopeUpdatedAnnouncement", { selected: groups.value.allowed.length, total: props.connections.length });
});
</script>
<template>
<div ref="rootRef" tabindex="-1" class="mcp-scope-picker space-y-3 rounded-md border bg-muted/20 p-3 outline-none" :aria-busy="busy">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0 space-y-1">
<div class="flex flex-wrap items-center gap-2">
<div class="text-sm font-medium">{{ t("settings.mcpScopeConnection") }}</div>
<Badge variant="outline" class="rounded-md font-normal">{{ allowedSummary }}</Badge>
</div>
<p class="text-xs text-muted-foreground">{{ t("settings.mcpScopeConnectionDescription") }}</p>
</div>
</div>
<fieldset :disabled="disabled" class="grid grid-cols-2 rounded-md bg-muted p-1" :aria-label="t('settings.mcpScopeMode')">
<label
:class="[
'flex min-h-11 cursor-pointer flex-col items-center justify-center rounded px-2 py-1.5 text-center transition-colors has-[:focus-visible]:ring-[3px] has-[:focus-visible]:ring-ring/50',
scopeMode === 'all' ? 'bg-background text-foreground shadow-sm dark:bg-input/30' : 'text-muted-foreground hover:text-foreground',
disabled ? 'cursor-not-allowed opacity-50' : '',
]"
>
<input class="sr-only" type="radio" name="mcp-scope-mode" value="all" :checked="scopeMode === 'all'" @change="setScopeMode('all')" />
<span class="text-sm font-medium">{{ t("settings.mcpScopeModeAll") }}</span>
<span class="mcp-scope-mode-description text-[11px] leading-tight text-muted-foreground">{{ t("settings.mcpScopeModeAllDescription") }}</span>
</label>
<label
:class="[
'flex min-h-11 cursor-pointer flex-col items-center justify-center rounded px-2 py-1.5 text-center transition-colors has-[:focus-visible]:ring-[3px] has-[:focus-visible]:ring-ring/50',
scopeMode === 'selected' ? 'bg-background text-foreground shadow-sm dark:bg-input/30' : 'text-muted-foreground hover:text-foreground',
disabled ? 'cursor-not-allowed opacity-50' : '',
]"
>
<input class="sr-only" type="radio" name="mcp-scope-mode" value="selected" :checked="scopeMode === 'selected'" @change="setScopeMode('selected')" />
<span class="text-sm font-medium">{{ t("settings.mcpScopeModeSelected") }}</span>
<span class="mcp-scope-mode-description text-[11px] leading-tight text-muted-foreground">{{ t("settings.mcpScopeModeSelectedDescription") }}</span>
</label>
</fieldset>
<div class="relative">
<Search class="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input v-model="searchQuery" class="h-9 pl-8" :disabled="disabled" :aria-label="t('settings.mcpConnectionSearchPlaceholder')" :placeholder="t('settings.mcpConnectionSearchPlaceholder')" />
</div>
<div class="mcp-scope-mobile-tabs grid grid-cols-2 rounded-md bg-muted p-1" role="tablist" :aria-label="t('settings.mcpScopeConnection')">
<button
:id="`${pickerId}-allowed-tab`"
type="button"
role="tab"
data-scope-tab="allowed"
class="min-w-0 rounded px-2 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
:class="compactPane === 'allowed' ? 'bg-background text-foreground shadow-sm dark:bg-input/30' : 'text-muted-foreground'"
:aria-selected="compactPane === 'allowed'"
:aria-controls="`${pickerId}-allowed-pane`"
@click="compactPane = 'allowed'"
>
{{ t("settings.mcpScopeAllowedPane") }} {{ allowedTotalCount }}
</button>
<button
:id="`${pickerId}-available-tab`"
type="button"
role="tab"
data-scope-tab="available"
class="min-w-0 rounded px-2 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
:class="compactPane === 'available' ? 'bg-background text-foreground shadow-sm dark:bg-input/30' : 'text-muted-foreground'"
:aria-selected="compactPane === 'available'"
:aria-controls="`${pickerId}-available-pane`"
@click="compactPane = 'available'"
>
{{ t("settings.mcpScopeAvailablePane") }} {{ availableTotalCount }}
</button>
</div>
<div class="mcp-scope-transfer overflow-hidden rounded-md border bg-background">
<section :id="`${pickerId}-available-pane`" data-scope-pane="available" class="mcp-scope-pane min-w-0 flex-col" :class="{ 'mcp-scope-pane--active': compactPane === 'available' }" role="region" :aria-label="t('settings.mcpScopeAvailablePane')">
<div class="flex min-h-10 items-center justify-between gap-2 border-b px-2.5 py-1.5">
<div class="flex min-w-0 items-center gap-2 text-sm font-medium">
<span class="truncate">{{ t("settings.mcpScopeAvailablePane") }}</span>
<Badge variant="secondary" class="rounded-md font-normal">{{ paneCount(availableVisibleCount, availableTotalCount) }}</Badge>
</div>
<Button
type="button"
size="xs"
variant="ghost"
data-scope-batch="available"
:title="t(searchActive ? 'settings.mcpScopeAddMatches' : 'settings.mcpScopeAddAll', { count: availableVisibleCount })"
:aria-label="t(searchActive ? 'settings.mcpScopeAddMatches' : 'settings.mcpScopeAddAll', { count: availableVisibleCount })"
:disabled="disabled || availableVisibleCount === 0"
@click="addVisibleConnections"
>
<Plus />
<span class="mcp-scope-batch-label">{{ t(searchActive ? "settings.mcpScopeAddMatches" : "settings.mcpScopeAddAll", { count: availableVisibleCount }) }}</span>
</Button>
</div>
<div class="h-64 overflow-y-auto p-1.5">
<div v-if="filteredAvailableConnections.length === 0" class="flex h-full items-center justify-center px-3 text-center text-sm text-muted-foreground">
{{ searchActive ? t("settings.mcpConnectionSearchEmpty") : t("settings.mcpScopeAvailableEmpty") }}
</div>
<div v-for="connection in filteredAvailableConnections" :key="connection.id" class="mcp-scope-connection-row grid min-h-12 items-center gap-2 rounded px-2 py-1.5 hover:bg-muted/60">
<div class="min-w-0">
<TruncatedTextTooltip :text="connection.name" class="block text-sm font-medium" />
<TruncatedTextTooltip :text="connectionAddress(connection)" class="mt-0.5 block font-mono text-[11px] text-muted-foreground" />
</div>
<div class="flex min-w-0 items-center justify-center">
<Badge :title="connection.db_type.toUpperCase()" variant="outline" class="max-w-full justify-center rounded px-1.5 py-0 text-[10px] font-normal uppercase">
<span class="truncate">{{ connection.db_type }}</span>
</Badge>
</div>
<Button
type="button"
size="icon-sm"
variant="ghost"
class="justify-self-end"
data-scope-action
:data-connection-id="connection.id"
:title="t('settings.mcpScopeAddConnection', { name: connection.name })"
:aria-label="t('settings.mcpScopeAddConnection', { name: connection.name })"
:disabled="disabled"
@click="updateConnections([connection.id], true, $event)"
>
<Plus />
</Button>
</div>
</div>
</section>
<section :id="`${pickerId}-allowed-pane`" data-scope-pane="allowed" class="mcp-scope-pane min-w-0 flex-col" :class="{ 'mcp-scope-pane--active': compactPane === 'allowed' }" role="region" :aria-label="t('settings.mcpScopeAllowedPane')">
<div class="flex min-h-10 items-center justify-between gap-2 border-b px-2.5 py-1.5">
<div class="flex min-w-0 items-center gap-2 text-sm font-medium">
<span class="truncate">{{ t("settings.mcpScopeAllowedPane") }}</span>
<Badge variant="secondary" class="rounded-md font-normal">{{ paneCount(allowedVisibleCount, allowedTotalCount) }}</Badge>
</div>
<Button
type="button"
size="xs"
variant="ghost"
data-scope-batch="allowed"
:title="t(searchActive ? 'settings.mcpScopeRemoveMatches' : 'settings.mcpScopeRemoveAll', { count: allowedVisibleCount })"
:aria-label="t(searchActive ? 'settings.mcpScopeRemoveMatches' : 'settings.mcpScopeRemoveAll', { count: allowedVisibleCount })"
:disabled="disabled || allowedVisibleCount === 0"
@click="removeVisibleConnections"
>
<Minus />
<span class="mcp-scope-batch-label">{{ t(searchActive ? "settings.mcpScopeRemoveMatches" : "settings.mcpScopeRemoveAll", { count: allowedVisibleCount }) }}</span>
</Button>
</div>
<div class="h-64 overflow-y-auto p-1.5">
<div v-if="filteredAllowedConnections.length === 0 && filteredUnavailableAllowedIds.length === 0" class="flex h-full items-center justify-center px-3 text-center text-sm text-muted-foreground">
{{ searchActive ? t("settings.mcpConnectionSearchEmpty") : t("settings.mcpScopeAllowedEmpty") }}
</div>
<div v-for="connection in filteredAllowedConnections" :key="connection.id" class="mcp-scope-connection-row grid min-h-12 items-center gap-2 rounded px-2 py-1.5 hover:bg-muted/60">
<div class="min-w-0">
<TruncatedTextTooltip :text="connection.name" class="block text-sm font-medium" />
<TruncatedTextTooltip :text="connectionAddress(connection)" class="mt-0.5 block font-mono text-[11px] text-muted-foreground" />
</div>
<div class="flex min-w-0 items-center justify-center">
<Badge :title="connection.db_type.toUpperCase()" variant="outline" class="max-w-full justify-center rounded px-1.5 py-0 text-[10px] font-normal uppercase">
<span class="truncate">{{ connection.db_type }}</span>
</Badge>
</div>
<Button
type="button"
size="icon-sm"
variant="ghost"
class="justify-self-end"
data-scope-action
:data-connection-id="connection.id"
:title="t('settings.mcpScopeRemoveConnection', { name: connection.name })"
:aria-label="t('settings.mcpScopeRemoveConnection', { name: connection.name })"
:disabled="disabled"
@click="updateConnections([connection.id], false, $event)"
>
<Minus />
</Button>
</div>
<div v-if="filteredUnavailableAllowedIds.length > 0" class="mt-1 border-t border-amber-500/20 pt-1">
<div class="flex items-center gap-1.5 px-2 py-1 text-[11px] font-medium text-amber-600 dark:text-amber-400">
<AlertTriangle class="h-3.5 w-3.5" />
{{ t("settings.mcpScopeUnavailableGroup", { count: filteredUnavailableAllowedIds.length }) }}
</div>
<div v-for="connectionId in filteredUnavailableAllowedIds" :key="connectionId" class="flex min-h-10 items-center gap-2 rounded px-2 py-1.5 text-amber-600 hover:bg-muted/60 dark:text-amber-400">
<TruncatedTextTooltip :text="connectionId" class="min-w-0 flex-1 font-mono text-xs" />
<Button
type="button"
size="icon-sm"
variant="ghost"
data-scope-action
:data-connection-id="connectionId"
:title="t('settings.mcpScopeRemoveUnavailableConnection', { id: connectionId })"
:aria-label="t('settings.mcpScopeRemoveUnavailableConnection', { id: connectionId })"
:disabled="disabled"
@click="updateConnections([connectionId], false, $event)"
>
<Minus />
</Button>
</div>
</div>
</div>
</section>
</div>
<p v-if="groups.unavailableAllowedIds.length > 0" class="text-xs text-amber-600 dark:text-amber-400">
{{ t("settings.mcpScopeConnectionUnavailableDescription", { count: groups.unavailableAllowedIds.length }) }}
</p>
<span class="sr-only" aria-live="polite">{{ announcement }}</span>
</div>
</template>
<style scoped>
.mcp-scope-picker {
container: mcp-scope / inline-size;
}
.mcp-scope-pane {
display: none;
}
.mcp-scope-pane--active {
display: flex;
}
.mcp-scope-connection-row {
grid-template-columns: minmax(0, 1fr) 6.75rem 1.75rem;
}
@container mcp-scope (min-width: 42rem) {
.mcp-scope-mobile-tabs {
display: none;
}
.mcp-scope-transfer {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.mcp-scope-pane {
display: flex;
}
.mcp-scope-pane + .mcp-scope-pane {
border-inline-start: 1px solid var(--border);
}
}
@container mcp-scope (max-width: 28rem) {
.mcp-scope-mode-description,
.mcp-scope-batch-label {
display: none;
}
.mcp-scope-connection-row {
grid-template-columns: minmax(0, 1fr) 5.5rem 1.75rem;
}
}
</style>
@@ -0,0 +1,89 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from "vitest";
import { dispatch, findAll, findOne, mountComponent } from "@/components/grid/__tests__/vueHostHarness";
import type { ConnectionConfig } from "@/types/database";
vi.mock("vue-i18n", () => ({
useI18n: () => ({
t: (key: string, values?: Record<string, unknown>) => `${key}${values ? ` ${JSON.stringify(values)}` : ""}`,
}),
}));
vi.mock("@lucide/vue", async () => {
const { createPassthroughStub } = await import("@/components/grid/__tests__/vueHostHarness");
const icon = createPassthroughStub("Icon", "i");
return { AlertTriangle: icon, Minus: icon, Plus: icon, Search: icon };
});
vi.mock("@/components/ui/badge", async () => ({ Badge: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("Badge", "span") }));
vi.mock("@/components/ui/button", async () => ({ Button: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("Button", "button") }));
vi.mock("@/components/ui/input", async () => ({ Input: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("Input", "input") }));
vi.mock("@/components/ui/TruncatedTextTooltip.vue", async () => ({ default: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("TruncatedTextTooltip", "span") }));
import McpConnectionScopePicker from "@/components/settings/McpConnectionScopePicker.vue";
function connection(id: string): ConnectionConfig {
return {
id,
name: `${id}-name`,
db_type: "mysql",
host: "127.0.0.1",
port: 3306,
username: "test",
password: "",
};
}
describe("McpConnectionScopePicker", () => {
it("separates allowed and available connections and emits direct moves", () => {
const update = vi.fn();
const mounted = mountComponent(McpConnectionScopePicker, {
connections: [connection("one"), connection("two")],
allowedConnectionIds: ["one"],
"onUpdate:allowedConnectionIds": update,
});
const allowedPane = findOne(mounted.root, (node) => node.props["data-scope-pane"] === "allowed");
const availablePane = findOne(mounted.root, (node) => node.props["data-scope-pane"] === "available");
expect(findAll(allowedPane, (node) => node.props["data-stub"] === "TruncatedTextTooltip").map((node) => node.props.text)).toContain("one-name");
expect(findAll(allowedPane, (node) => node.props["data-stub"] === "TruncatedTextTooltip").map((node) => node.props.text)).not.toContain("two-name");
expect(findAll(availablePane, (node) => node.props["data-stub"] === "TruncatedTextTooltip").map((node) => node.props.text)).toContain("two-name");
const add = findOne(availablePane, (node) => node.props["data-connection-id"] === "two");
dispatch(add, "click");
expect(update).toHaveBeenCalledWith(["one", "two"]);
const remove = findOne(allowedPane, (node) => node.props["data-connection-id"] === "one");
dispatch(remove, "click");
expect(update).toHaveBeenCalledWith([]);
});
it("keeps dynamic allow-all distinct from an explicit current-connection list", () => {
const update = vi.fn();
const mounted = mountComponent(McpConnectionScopePicker, {
connections: [connection("one"), connection("two")],
allowedConnectionIds: null,
"onUpdate:allowedConnectionIds": update,
});
const modeInputs = findAll(mounted.root, (node) => node.type === "input" && node.props.name === "mcp-scope-mode");
expect(modeInputs.find((input) => input.props.value === "all")?.props.checked).toBe(true);
dispatch(
findOne(mounted.root, (node) => node.type === "input" && node.props.value === "selected"),
"change",
);
expect(update).toHaveBeenCalledWith(["one", "two"]);
});
it("shows unavailable allowlist entries only in the allowed pane", () => {
const mounted = mountComponent(McpConnectionScopePicker, {
connections: [connection("one")],
allowedConnectionIds: ["one", "missing-id"],
});
const allowedPane = findOne(mounted.root, (node) => node.props["data-scope-pane"] === "allowed");
const availablePane = findOne(mounted.root, (node) => node.props["data-scope-pane"] === "available");
expect(findAll(allowedPane, (node) => node.props["data-stub"] === "TruncatedTextTooltip").map((node) => node.props.text)).toContain("missing-id");
expect(findAll(availablePane, (node) => node.props["data-stub"] === "TruncatedTextTooltip").map((node) => node.props.text)).not.toContain("missing-id");
});
});