feat(mcp): add streamable http server and fine-grained authorization

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-08-31 22:56:16 +08:00
1 parent f725c50b2c
commit f075caef21
47 files changed
+4960 -509

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,92 @@
<script setup lang="ts">
import { computed, nextTick, ref } from "vue";
import { useI18n } from "vue-i18n";
type StepId = "capabilities" | "connections" | "databases" | "overrides";
const { t } = useI18n();
const steps = computed<Array<{ id: StepId; title: string; description: string }>>(() => [
{ id: "connections", title: t("settings.mcpAuthStepConnectionsTitle"), description: t("settings.mcpAuthStepConnectionsDescription") },
{ id: "databases", title: t("settings.mcpAuthStepDatabasesTitle"), description: t("settings.mcpAuthStepDatabasesDescription") },
{ id: "overrides", title: t("settings.mcpAuthStepOverridesTitle"), description: t("settings.mcpAuthStepOverridesDescription") },
{ id: "capabilities", title: t("settings.mcpAuthStepCapabilitiesTitle"), description: t("settings.mcpAuthStepCapabilitiesDescription") },
]);
const currentStep = ref<StepId>("connections");
const currentIndex = computed(() => steps.value.findIndex((step) => step.id === currentStep.value));
function scrollableAncestor(target: EventTarget | null): HTMLElement | null {
let element = target instanceof HTMLElement ? target.parentElement : null;
while (element) {
const overflowY = window.getComputedStyle(element).overflowY;
if (overflowY === "auto" || overflowY === "scroll") return element;
element = element.parentElement;
}
return null;
}
async function selectStep(step: StepId, event?: Event) {
if (step === currentStep.value) return;
const scroller = scrollableAncestor(event?.currentTarget ?? null);
const scrollTop = scroller?.scrollTop;
currentStep.value = step;
if (!scroller || scrollTop === undefined) return;
await nextTick();
requestAnimationFrame(() => {
if (scroller.isConnected) scroller.scrollTop = scrollTop;
});
}
function previousStep(event: Event) {
const step = steps.value[currentIndex.value - 1];
if (step) void selectStep(step.id, event);
}
function nextStep(event: Event) {
const step = steps.value[currentIndex.value + 1];
if (step) void selectStep(step.id, event);
}
</script>
<template>
<section class="space-y-4 rounded-md border bg-background p-3 sm:p-4">
<header class="space-y-1">
<p class="text-sm font-semibold">{{ t("settings.mcpAuthTitle") }}</p>
<p class="text-xs text-muted-foreground">{{ t("settings.mcpAuthDescription") }}</p>
</header>
<nav class="grid gap-1 rounded-md border bg-muted/50 p-1 sm:grid-cols-4" :aria-label="t('settings.mcpAuthStepsLabel')">
<button
v-for="(step, index) in steps"
:key="step.id"
type="button"
class="group flex min-w-0 items-center gap-2 rounded px-2.5 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
:class="currentStep === step.id ? 'bg-background shadow-sm' : 'text-muted-foreground hover:bg-background/70'"
:aria-current="currentStep === step.id ? 'step' : undefined"
@click="selectStep(step.id, $event)"
>
<span
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border text-[11px] font-semibold"
:class="currentStep === step.id ? 'border-primary bg-primary text-primary-foreground' : index < currentIndex ? 'border-primary/40 bg-primary/10 text-primary' : 'bg-background text-muted-foreground'"
>{{ index + 1 }}</span
>
<span class="min-w-0">
<span class="block truncate text-xs font-medium">{{ step.title }}</span>
<span class="hidden truncate text-[10px] text-muted-foreground lg:block">{{ step.description }}</span>
</span>
</button>
</nav>
<div :class="currentStep === 'connections' ? 'block' : 'hidden'" :aria-hidden="currentStep !== 'connections'" role="region" :aria-label="t('settings.mcpAuthStepRegionLabel', { step: 1, title: t('settings.mcpAuthStepConnectionsTitle') })"><slot name="connections" /></div>
<div :class="currentStep === 'databases' ? 'block' : 'hidden'" :aria-hidden="currentStep !== 'databases'" role="region" :aria-label="t('settings.mcpAuthStepRegionLabel', { step: 2, title: t('settings.mcpAuthStepDatabasesTitle') })"><slot name="databases" /></div>
<div :class="currentStep === 'overrides' ? 'block' : 'hidden'" :aria-hidden="currentStep !== 'overrides'" role="region" :aria-label="t('settings.mcpAuthStepRegionLabel', { step: 3, title: t('settings.mcpAuthStepOverridesTitle') })"><slot name="overrides" /></div>
<div :class="currentStep === 'capabilities' ? 'block' : 'hidden'" :aria-hidden="currentStep !== 'capabilities'" role="region" :aria-label="t('settings.mcpAuthStepRegionLabel', { step: 4, title: t('settings.mcpAuthStepCapabilitiesTitle') })"><slot name="capabilities" /></div>
<footer class="flex items-center justify-between gap-3 border-t pt-3">
<button type="button" class="rounded-md border bg-background px-3 py-1.5 text-xs font-medium disabled:cursor-not-allowed disabled:opacity-50" :disabled="currentIndex === 0" @click="previousStep($event)">{{ t("settings.mcpAuthPreviousStep") }}</button>
<p class="text-center text-xs text-muted-foreground">{{ t("settings.mcpAuthStepProgress", { current: currentIndex + 1, total: steps.length }) }}</p>
<button type="button" class="rounded-md border bg-background px-3 py-1.5 text-xs font-medium disabled:cursor-not-allowed disabled:opacity-50" :disabled="currentIndex === steps.length - 1" @click="nextStep($event)">{{ t("settings.mcpAuthNextStep") }}</button>
</footer>
</section>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, ref, useId, watch } from "vue";
import { AlertTriangle, Minus, Plus, Search } from "@lucide/vue";
import { AlertTriangle, ChevronLeft, ChevronRight, Minus, Plus, Search } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -12,6 +12,8 @@ import type { ConnectionConfig } from "@/types/database";
type ScopePane = "available" | "allowed";
type ScopeMode = "all" | "selected";
const PAGE_SIZE_OPTIONS = [6, 10, 20] as const;
const props = withDefaults(
defineProps<{
connections: readonly ConnectionConfig[];
@@ -34,6 +36,9 @@ const pickerId = useId();
const rootRef = ref<HTMLElement>();
const searchQuery = ref("");
const compactPane = ref<ScopePane>("allowed");
const availablePage = ref(1);
const allowedPage = ref(1);
const connectionsPerPage = ref<number>(PAGE_SIZE_OPTIONS[0]);
const announcement = ref("");
const pendingFocus = ref<{ pane: ScopePane; index: number } | null>(null);
@@ -49,6 +54,16 @@ function connectionMatchesSearch(connection: ConnectionConfig): boolean {
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 availablePageCount = computed(() => Math.max(1, Math.ceil(filteredAvailableConnections.value.length / connectionsPerPage.value)));
const allowedPageCount = computed(() => Math.max(1, Math.ceil(filteredAllowedConnections.value.length / connectionsPerPage.value)));
const pagedAvailableConnections = computed(() => {
const start = (availablePage.value - 1) * connectionsPerPage.value;
return filteredAvailableConnections.value.slice(start, start + connectionsPerPage.value);
});
const pagedAllowedConnections = computed(() => {
const start = (allowedPage.value - 1) * connectionsPerPage.value;
return filteredAllowedConnections.value.slice(start, start + connectionsPerPage.value);
});
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);
@@ -60,6 +75,17 @@ function paneCount(visible: number, total: number): string {
return searchActive.value ? `${visible}/${total}` : String(total);
}
function setPanePage(pane: ScopePane, page: number) {
const total = pane === "available" ? availablePageCount.value : allowedPageCount.value;
const next = Math.min(Math.max(1, page), total);
if (pane === "available") availablePage.value = next;
else allowedPage.value = next;
}
function paginationLabel(page: number, pageCount: number): string {
return `${page} / ${pageCount}`;
}
function connectionAddress(connection: ConnectionConfig): string {
const address = connection.host ? `${connection.host}:${connection.port}` : "";
return [address, connection.database].filter(Boolean).join(" · ") || connection.id;
@@ -135,10 +161,23 @@ watch(
watch([policyKey, () => groups.value.allowed.length], () => {
announcement.value = t("settings.mcpScopeUpdatedAnnouncement", { selected: groups.value.allowed.length, total: props.connections.length });
});
watch(searchQuery, () => {
availablePage.value = 1;
allowedPage.value = 1;
});
watch(connectionsPerPage, () => {
availablePage.value = 1;
allowedPage.value = 1;
});
watch(availablePageCount, () => setPanePage("available", availablePage.value));
watch(allowedPageCount, () => setPanePage("allowed", allowedPage.value));
</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 ref="rootRef" tabindex="-1" class="mcp-scope-picker space-y-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">
@@ -149,7 +188,7 @@ watch([policyKey, () => groups.value.allowed.length], () => {
</div>
</div>
<div class="grid grid-cols-1 p-1 sm:grid-cols-2 gap-2.5" role="radiogroup" :aria-labelledby="`${pickerId}-mode-label`">
<div class="grid grid-cols-1 gap-1 rounded-md border bg-muted/50 p-1 sm:grid-cols-2" role="radiogroup" :aria-labelledby="`${pickerId}-mode-label`">
<Button
:disabled="disabled"
type="button"
@@ -158,8 +197,8 @@ watch([policyKey, () => groups.value.allowed.length], () => {
:aria-checked="scopeMode === 'all'"
:tabindex="scopeMode === 'all' ? 0 : -1"
variant="outline"
class="settings-choice-card h-auto justify-center border p-3"
:class="[scopeMode === 'all' ? 'dbx-choice-selected' : '', disabled ? 'cursor-not-allowed opacity-50' : '']"
class="settings-choice-card h-auto justify-center border-0 p-2.5 shadow-none"
:class="[scopeMode === 'all' ? 'dbx-choice-selected bg-background shadow-sm' : 'text-muted-foreground hover:bg-background/70', disabled ? 'cursor-not-allowed opacity-50' : '']"
@click="setScopeMode('all')"
@keydown="onScopeModeKeydown($event, 'all')"
>
@@ -176,8 +215,8 @@ watch([policyKey, () => groups.value.allowed.length], () => {
:aria-checked="scopeMode === 'selected'"
:tabindex="scopeMode === 'selected' ? 0 : -1"
variant="outline"
class="settings-choice-card h-auto justify-center border p-3"
:class="[scopeMode === 'selected' ? 'dbx-choice-selected' : '', disabled ? 'cursor-not-allowed opacity-50' : '']"
class="settings-choice-card h-auto justify-center border-0 p-2.5 shadow-none"
:class="[scopeMode === 'selected' ? 'dbx-choice-selected bg-background shadow-sm' : 'text-muted-foreground hover:bg-background/70', disabled ? 'cursor-not-allowed opacity-50' : '']"
@click="setScopeMode('selected')"
@keydown="onScopeModeKeydown($event, 'selected')"
>
@@ -188,9 +227,17 @@ watch([policyKey, () => groups.value.allowed.length], () => {
</Button>
</div>
<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 class="flex flex-wrap items-center gap-2">
<div class="relative min-w-0 flex-1">
<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>
<label class="flex h-9 items-center gap-1.5 rounded-md border bg-background px-2 text-xs text-muted-foreground">
{{ t("settings.mcpPerPage") }}
<select :value="connectionsPerPage" class="bg-transparent text-xs text-foreground outline-none" :disabled="disabled" @change="connectionsPerPage = Number(($event.target as HTMLSelectElement).value)">
<option v-for="size in PAGE_SIZE_OPTIONS" :key="size" :value="size">{{ size }}</option>
</select>
</label>
</div>
<div class="mcp-scope-mobile-tabs grid grid-cols-2 rounded-md bg-muted p-1" role="tablist" :aria-label="t('settings.mcpScopeConnection')">
@@ -243,11 +290,11 @@ watch([policyKey, () => groups.value.allowed.length], () => {
<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 class="space-y-1.5 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 v-for="connection in pagedAvailableConnections" :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" />
@@ -272,6 +319,15 @@ watch([policyKey, () => groups.value.allowed.length], () => {
<Plus />
</Button>
</div>
<div v-if="availablePageCount > 1" class="flex items-center justify-center gap-2 border-t pt-2 text-xs text-muted-foreground">
<Button type="button" size="icon-sm" variant="ghost" :disabled="availablePage === 1" :title="t('settings.mcpPreviousPage')" :aria-label="t('settings.mcpPreviousPage')" @click="setPanePage('available', availablePage - 1)">
<ChevronLeft />
</Button>
<span class="min-w-12 text-center tabular-nums">{{ paginationLabel(availablePage, availablePageCount) }}</span>
<Button type="button" size="icon-sm" variant="ghost" :disabled="availablePage === availablePageCount" :title="t('settings.mcpNextPage')" :aria-label="t('settings.mcpNextPage')" @click="setPanePage('available', availablePage + 1)">
<ChevronRight />
</Button>
</div>
</div>
</section>
@@ -295,11 +351,11 @@ watch([policyKey, () => groups.value.allowed.length], () => {
<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 class="space-y-1.5 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 v-for="connection in pagedAllowedConnections" :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" />
@@ -347,6 +403,15 @@ watch([policyKey, () => groups.value.allowed.length], () => {
</Button>
</div>
</div>
<div v-if="allowedPageCount > 1" class="flex items-center justify-center gap-2 border-t pt-2 text-xs text-muted-foreground">
<Button type="button" size="icon-sm" variant="ghost" :disabled="allowedPage === 1" :title="t('settings.mcpPreviousPage')" :aria-label="t('settings.mcpPreviousPage')" @click="setPanePage('allowed', allowedPage - 1)">
<ChevronLeft />
</Button>
<span class="min-w-12 text-center tabular-nums">{{ paginationLabel(allowedPage, allowedPageCount) }}</span>
<Button type="button" size="icon-sm" variant="ghost" :disabled="allowedPage === allowedPageCount" :title="t('settings.mcpNextPage')" :aria-label="t('settings.mcpNextPage')" @click="setPanePage('allowed', allowedPage + 1)">
<ChevronRight />
</Button>
</div>
</div>
</section>
</div>
@@ -0,0 +1,262 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { Check, ChevronLeft, ChevronRight, Loader2, Plus, RefreshCw, Search } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { listDatabases, mongoListDatabases, redisListDatabases } from "@/lib/backend/api";
import type { McpConnectionPolicy } from "@/stores/settingsStore";
import type { ConnectionConfig } from "@/types/database";
type DatabaseScope = McpConnectionPolicy["databaseScope"];
const { t } = useI18n();
const PAGE_SIZE_OPTIONS = [6, 10, 20] as const;
const props = withDefaults(
defineProps<{
connections: readonly ConnectionConfig[];
allowedConnectionIds: readonly string[] | null;
connectionPolicies: readonly McpConnectionPolicy[];
disabled?: boolean;
busy?: boolean;
}>(),
{ disabled: false, busy: false },
);
const emit = defineEmits<{
"update:connectionPolicies": [value: McpConnectionPolicy[]];
}>();
const selectedConnectionId = ref("");
const databasesByConnection = ref<Record<string, string[]>>({});
const loadingConnectionId = ref("");
const loadError = ref("");
const search = ref("");
const manualDatabase = ref("");
const pageSize = ref<number>(PAGE_SIZE_OPTIONS[0]);
const connectionPage = ref(1);
const databasePage = ref(1);
const scopedConnections = computed(() => (props.allowedConnectionIds === null ? [...props.connections] : props.connections.filter((connection) => props.allowedConnectionIds?.includes(connection.id))));
const selectedConnection = computed(() => scopedConnections.value.find((connection) => connection.id === selectedConnectionId.value));
const selectedPolicy = computed(() => props.connectionPolicies.find((policy) => policy.connectionId === selectedConnectionId.value));
const selectedScope = computed<DatabaseScope>(() => selectedPolicy.value?.databaseScope ?? "all");
const selectedDatabases = computed(() => selectedPolicy.value?.allowedDatabases ?? []);
const displayedDatabases = computed(() => {
const query = search.value.trim().toLocaleLowerCase();
return (databasesByConnection.value[selectedConnectionId.value] ?? []).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)));
const pagedScopedConnections = computed(() => {
const start = (connectionPage.value - 1) * pageSize.value;
return scopedConnections.value.slice(start, start + pageSize.value);
});
const pagedDatabases = computed(() => {
const start = (databasePage.value - 1) * pageSize.value;
return displayedDatabases.value.slice(start, start + pageSize.value);
});
watch(
scopedConnections,
(connections) => {
if (!connections.some((connection) => connection.id === selectedConnectionId.value)) {
selectedConnectionId.value = connections[0]?.id ?? "";
}
},
{ immediate: true },
);
function policyFor(connectionId: string): McpConnectionPolicy {
return (
props.connectionPolicies.find((policy) => policy.connectionId === connectionId) ?? {
connectionId,
readOnly: false,
allowDangerousSql: false,
executionModeConfigured: false,
databaseScope: "all",
allowedDatabases: [],
}
);
}
function updateSelectedPolicy(patch: Partial<McpConnectionPolicy>) {
if (props.disabled || !selectedConnectionId.value) return;
const next = { ...policyFor(selectedConnectionId.value), ...patch };
const policies = props.connectionPolicies.filter((policy) => policy.connectionId !== selectedConnectionId.value);
emit("update:connectionPolicies", [...policies, next]);
}
function setScope(scope: DatabaseScope) {
updateSelectedPolicy({ databaseScope: scope, allowedDatabases: scope === "selected" ? selectedDatabases.value : [] });
}
function setConnectionPage(page: number) {
connectionPage.value = Math.min(Math.max(1, page), connectionPageCount.value);
}
function setDatabasePage(page: number) {
databasePage.value = Math.min(Math.max(1, page), databasePageCount.value);
}
function selectConnection(connectionId: string) {
selectedConnectionId.value = connectionId;
loadError.value = "";
search.value = "";
databasePage.value = 1;
}
function toggleDatabase(database: string, checked: boolean) {
const databases = checked ? [...new Set([...selectedDatabases.value, database])] : selectedDatabases.value.filter((item) => item !== database);
updateSelectedPolicy({ databaseScope: "selected", allowedDatabases: databases });
}
function addManualDatabase() {
const database = manualDatabase.value.trim();
if (!database) return;
const loaded = databasesByConnection.value[selectedConnectionId.value] ?? [];
databasesByConnection.value = {
...databasesByConnection.value,
[selectedConnectionId.value]: [...new Set([...loaded, database])].sort((left, right) => left.localeCompare(right)),
};
toggleDatabase(database, true);
manualDatabase.value = "";
databasePage.value = 1;
}
async function loadConnectionDatabases() {
const connection = selectedConnection.value;
if (!connection || loadingConnectionId.value) return;
loadingConnectionId.value = connection.id;
loadError.value = "";
try {
const databases = connection.db_type === "mongodb" ? await mongoListDatabases(connection.id) : connection.db_type === "redis" ? (await redisListDatabases(connection.id)).map((database) => String(database.db)) : (await listDatabases(connection.id)).map((database) => database.name);
databasesByConnection.value = {
...databasesByConnection.value,
[connection.id]: [...new Set(databases.map((database) => database.trim()).filter(Boolean))].sort((left, right) => left.localeCompare(right)),
};
} catch (error: unknown) {
loadError.value = error instanceof Error ? error.message : String(error);
} finally {
loadingConnectionId.value = "";
}
}
function scopeSummary(connection: ConnectionConfig): string {
const policy = props.connectionPolicies.find((item) => item.connectionId === connection.id);
if (!policy || policy.databaseScope === "all") return t("settings.mcpDatabaseScopeSummaryAll");
if (policy.databaseScope === "none") return t("settings.mcpDatabaseScopeSummaryNone");
return t("settings.mcpDatabaseScopeSummarySelected", { count: policy.allowedDatabases.length });
}
watch(search, () => {
databasePage.value = 1;
});
watch(pageSize, () => {
connectionPage.value = 1;
databasePage.value = 1;
});
watch(connectionPageCount, () => setConnectionPage(connectionPage.value));
watch(databasePageCount, () => setDatabasePage(databasePage.value));
</script>
<template>
<section class="space-y-3">
<div>
<p class="text-sm font-medium">{{ t("settings.mcpDatabaseScopeTitle") }}</p>
<p class="text-xs text-muted-foreground">{{ t("settings.mcpDatabaseScopeDescription") }}</p>
</div>
<div v-if="!scopedConnections.length" class="rounded border border-dashed bg-background px-3 py-4 text-center text-xs text-muted-foreground">{{ t("settings.mcpDatabaseScopeEmptyHint") }}</div>
<div v-else class="grid min-h-72 overflow-hidden rounded-md border bg-background md:grid-cols-[minmax(14rem,0.42fr)_minmax(0,1fr)]">
<div class="border-b p-2 md:border-b-0 md:border-r">
<div class="flex items-center justify-between gap-2 px-2 pb-2">
<p class="text-xs font-medium text-muted-foreground">{{ t("settings.mcpDatabaseScopeAllowedConnections") }}</p>
<label class="flex h-7 items-center gap-1 rounded border bg-background px-1.5 text-[11px] text-muted-foreground">
{{ t("settings.mcpPerPage") }}
<select v-model.number="pageSize" class="bg-transparent text-[11px] text-foreground outline-none" :disabled="disabled">
<option v-for="size in PAGE_SIZE_OPTIONS" :key="size" :value="size">{{ size }}</option>
</select>
</label>
</div>
<div class="space-y-1">
<button v-for="connection in pagedScopedConnections" :key="connection.id" type="button" class="w-full rounded-md px-2 py-2 text-left transition-colors hover:bg-muted" :class="selectedConnectionId === connection.id ? 'bg-muted' : ''" @click="selectConnection(connection.id)">
<div class="flex items-center justify-between gap-2">
<span class="min-w-0 truncate text-sm font-medium">{{ connection.name }}</span>
<Badge variant="outline" class="shrink-0 text-[10px] font-normal">{{ scopeSummary(connection) }}</Badge>
</div>
<p class="mt-0.5 truncate font-mono text-[10px] text-muted-foreground">{{ connection.db_type }} · {{ connection.host || connection.database || connection.id }}</p>
</button>
</div>
<div v-if="connectionPageCount > 1" class="mt-2 flex items-center justify-center gap-2 border-t pt-2 text-xs text-muted-foreground">
<Button type="button" size="icon-sm" variant="ghost" :disabled="connectionPage === 1" :title="t('settings.mcpPreviousPage')" :aria-label="t('settings.mcpPreviousPage')" @click="setConnectionPage(connectionPage - 1)"><ChevronLeft /></Button>
<span class="min-w-12 text-center tabular-nums">{{ connectionPage }} / {{ connectionPageCount }}</span>
<Button type="button" size="icon-sm" variant="ghost" :disabled="connectionPage === connectionPageCount" :title="t('settings.mcpNextPage')" :aria-label="t('settings.mcpNextPage')" @click="setConnectionPage(connectionPage + 1)"><ChevronRight /></Button>
</div>
</div>
<div v-if="selectedConnection" class="space-y-3 p-3">
<div class="flex flex-wrap items-start justify-between gap-2">
<div>
<p class="text-sm font-medium">{{ selectedConnection.name }}</p>
<p class="text-xs text-muted-foreground">{{ t("settings.mcpDatabaseScopePermissionNote") }}</p>
</div>
<Button type="button" variant="outline" size="sm" :disabled="disabled || Boolean(loadingConnectionId)" @click="loadConnectionDatabases">
<Loader2 v-if="loadingConnectionId === selectedConnection.id" class="mr-1.5 h-3.5 w-3.5 animate-spin" />
<RefreshCw v-else class="mr-1.5 h-3.5 w-3.5" />
{{ t("settings.mcpDatabaseLoadButton") }}
</Button>
</div>
<div class="grid gap-2 sm:grid-cols-3" role="radiogroup" :aria-label="t('settings.mcpDatabaseScopeAriaLabel')">
<Button type="button" variant="outline" class="h-auto justify-start px-3 py-2 text-left" :class="selectedScope === 'all' ? 'dbx-choice-selected' : ''" :disabled="disabled || busy" @click="setScope('all')">
<span
><span class="block text-sm">{{ t("settings.mcpDatabaseScopeAllTitle") }}</span
><span class="block text-[11px] font-normal text-muted-foreground">{{ t("settings.mcpDatabaseScopeAllDescription") }}</span></span
>
</Button>
<Button type="button" variant="outline" class="h-auto justify-start px-3 py-2 text-left" :class="selectedScope === 'selected' ? 'dbx-choice-selected' : ''" :disabled="disabled || busy" @click="setScope('selected')">
<span
><span class="block text-sm">{{ t("settings.mcpDatabaseScopeSelectedTitle") }}</span
><span class="block text-[11px] font-normal text-muted-foreground">{{ t("settings.mcpDatabaseScopeSelectedDescription") }}</span></span
>
</Button>
<Button type="button" variant="outline" class="h-auto justify-start px-3 py-2 text-left" :class="selectedScope === 'none' ? 'dbx-choice-selected' : ''" :disabled="disabled || busy" @click="setScope('none')">
<span
><span class="block text-sm">{{ t("settings.mcpDatabaseScopeNoneTitle") }}</span
><span class="block text-[11px] font-normal text-muted-foreground">{{ t("settings.mcpDatabaseScopeNoneDescription") }}</span></span
>
</Button>
</div>
<template v-if="selectedScope === 'selected'">
<p class="text-xs text-muted-foreground">{{ t("settings.mcpDatabaseScopeSelectedSummary", { count: selectedDatabases.length }) }}</p>
<div class="flex gap-2">
<div class="relative min-w-0 flex-1"><Search class="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" /><Input v-model="search" class="h-8 pl-8 text-xs" :placeholder="t('settings.mcpDatabaseSearchPlaceholder')" /></div>
<Input v-model="manualDatabase" class="h-8 w-40 text-xs" :placeholder="t('settings.mcpDatabaseManualPlaceholder')" @keydown.enter.prevent="addManualDatabase" />
<Button type="button" variant="outline" size="sm" :disabled="disabled || !manualDatabase.trim()" @click="addManualDatabase"><Plus class="h-3.5 w-3.5" /></Button>
</div>
<p v-if="loadError" class="rounded border border-destructive/30 bg-destructive/5 px-2 py-1.5 text-xs text-destructive">{{ t("settings.mcpDatabaseLoadFailed", { error: loadError }) }}</p>
<div v-if="displayedDatabases.length" class="rounded border">
<label v-for="database in pagedDatabases" :key="database" class="flex cursor-pointer items-center gap-2 border-b px-3 py-2 text-xs last:border-b-0 hover:bg-muted/50">
<input type="checkbox" :checked="selectedDatabases.includes(database)" :disabled="disabled || busy" @change="toggleDatabase(database, ($event.target as HTMLInputElement).checked)" />
<span class="min-w-0 flex-1 truncate font-mono">{{ database }}</span>
<Check v-if="selectedDatabases.includes(database)" class="h-3.5 w-3.5 text-green-600" />
</label>
<div v-if="databasePageCount > 1" class="flex items-center justify-center gap-2 border-t px-3 py-2 text-xs text-muted-foreground">
<Button type="button" size="icon-sm" variant="ghost" :disabled="databasePage === 1" :title="t('settings.mcpPreviousPage')" :aria-label="t('settings.mcpPreviousPage')" @click="setDatabasePage(databasePage - 1)"><ChevronLeft /></Button>
<span class="min-w-12 text-center tabular-nums">{{ databasePage }} / {{ databasePageCount }}</span>
<Button type="button" size="icon-sm" variant="ghost" :disabled="databasePage === databasePageCount" :title="t('settings.mcpNextPage')" :aria-label="t('settings.mcpNextPage')" @click="setDatabasePage(databasePage + 1)"><ChevronRight /></Button>
</div>
</div>
<p v-else class="rounded border border-dashed px-3 py-4 text-center text-xs text-muted-foreground">{{ t("settings.mcpDatabaseEmptyHint") }}</p>
</template>
</div>
</div>
</section>
</template>