feat(mcp): add streamable http server and fine-grained authorization
This commit is contained in:
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>
|
||||
Reference in new issue
Block a user