mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
feat(mcp): add streamable http server and fine-grained authorization
This commit is contained in:
Generated
+27
@@ -2011,6 +2011,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"csv",
|
||||
"dbx-core",
|
||||
"dbx-mcp",
|
||||
"deadpool-postgres",
|
||||
"font-kit",
|
||||
"futures",
|
||||
@@ -2158,8 +2159,10 @@ name = "dbx-mcp"
|
||||
version = "0.4.76"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"dbx-core",
|
||||
"dirs",
|
||||
"log",
|
||||
"reqwest 0.12.28",
|
||||
"rmcp",
|
||||
"rustls 0.23.43",
|
||||
@@ -2170,6 +2173,8 @@ dependencies = [
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-util",
|
||||
"tower-http",
|
||||
"url",
|
||||
"uuid",
|
||||
]
|
||||
@@ -2195,6 +2200,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"dbx-core",
|
||||
"dbx-mcp",
|
||||
"futures",
|
||||
"log",
|
||||
"pbkdf2 0.12.2",
|
||||
@@ -6999,19 +7005,27 @@ checksum = "14db48ee17a9ba61810ab1a9c1beb7d06d8136ae39ac25a1137f10d357af01af"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"futures",
|
||||
"http 1.5.0",
|
||||
"http-body 1.1.0",
|
||||
"http-body-util",
|
||||
"pastey",
|
||||
"pin-project-lite",
|
||||
"rand 0.10.2",
|
||||
"rmcp-macros",
|
||||
"schemars 1.2.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sse-stream",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8206,6 +8220,19 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sse-stream"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c123f296ade4ec4b8b0f6162116e6629f5146922ca5ab40ca9d3c2e73ab4761e"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http-body 1.1.0",
|
||||
"http-body-util",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ssfmt"
|
||||
version = "0.1.2"
|
||||
|
||||
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>
|
||||
@@ -6971,6 +6971,127 @@ export default {
|
||||
mcpNpmBoundary: "DBX checks MCP status; installation, upgrades, and uninstallation run through the detected npm or pnpm runtime.",
|
||||
mcpRefresh: "Check again",
|
||||
mcpGuide: "MCP guide",
|
||||
mcpAuthTitle: "MCP authorization",
|
||||
mcpAuthDescription: "Complete the authorization in order: choose connections first, then limit databases and connection exceptions, and finally set the available tools and the global execution level.",
|
||||
mcpAuthStepsLabel: "MCP authorization steps",
|
||||
mcpAuthStepConnectionsTitle: "Connection scope",
|
||||
mcpAuthStepConnectionsDescription: "Decides which connections MCP can see",
|
||||
mcpAuthStepDatabasesTitle: "Database scope",
|
||||
mcpAuthStepDatabasesDescription: "Limit the databases each connection can access",
|
||||
mcpAuthStepOverridesTitle: "Connection exceptions",
|
||||
mcpAuthStepOverridesDescription: "Tighten execution permissions per connection",
|
||||
mcpAuthStepCapabilitiesTitle: "Capabilities and execution",
|
||||
mcpAuthStepCapabilitiesDescription: "Choose MCP tools and the global operation level",
|
||||
mcpAuthStepRegionLabel: "Step {step}: {title}",
|
||||
mcpAuthPreviousStep: "Previous",
|
||||
mcpAuthNextStep: "Next",
|
||||
mcpAuthStepProgress: "Step {current} of {total}",
|
||||
mcpPerPage: "Per page",
|
||||
mcpPreviousPage: "Previous page",
|
||||
mcpNextPage: "Next page",
|
||||
mcpDatabaseScopeTitle: "Connections and database scope",
|
||||
mcpDatabaseScopeDescription: "Select a connection exposed to MCP on the left, then limit the databases that connection can access. Database names are matched exactly.",
|
||||
mcpDatabaseScopeEmptyHint: "Allow at least one connection in 'Connection scope' first.",
|
||||
mcpDatabaseScopeAllowedConnections: "Allowed connections",
|
||||
mcpDatabaseScopeSummaryAll: "All databases",
|
||||
mcpDatabaseScopeSummaryNone: "No database access",
|
||||
mcpDatabaseScopeSummarySelected: "{count} selected",
|
||||
mcpDatabaseScopePermissionNote: "The selected scope does not change the connection's existing database permissions in DBX.",
|
||||
mcpDatabaseLoadButton: "Load from connection",
|
||||
mcpDatabaseScopeAriaLabel: "Database access scope",
|
||||
mcpDatabaseScopeAllTitle: "All databases",
|
||||
mcpDatabaseScopeAllDescription: "Keep the current access scope",
|
||||
mcpDatabaseScopeSelectedTitle: "Selected databases only",
|
||||
mcpDatabaseScopeSelectedDescription: "Expose only the checked items",
|
||||
mcpDatabaseScopeNoneTitle: "No access",
|
||||
mcpDatabaseScopeNoneDescription: "Keep the rules but deny every database",
|
||||
mcpDatabaseScopeSelectedSummary: "{count} databases allowed. Unchecked databases and cross-database requests are rejected by the MCP server.",
|
||||
mcpDatabaseSearchPlaceholder: "Filter loaded databases",
|
||||
mcpDatabaseManualPlaceholder: "Add a database name manually",
|
||||
mcpDatabaseLoadFailed: "Failed to load databases: {error}. You can add the exact database names manually.",
|
||||
mcpDatabaseEmptyHint: "Click 'Load from connection' to fetch the database list, or add allowed databases manually.",
|
||||
mcpTransportLocalStdio: "Local stdio",
|
||||
mcpTransportHttpService: "HTTP service",
|
||||
mcpHttpWebServiceTitle: "Web Streamable HTTP service",
|
||||
mcpHttpWebServiceDescription: "Web/Docker reuses the current web listening port. Enable it with deployment environment variables; the settings page never stores the token.",
|
||||
mcpHttpStatusLabelEnabled: "Enabled",
|
||||
mcpHttpStatusLabelDisabled: "Not enabled",
|
||||
mcpHttpNotConfigured: "Not configured",
|
||||
mcpHttpWebTokenSourceAndHosts: "Token source: {tokenSource}; allowed hosts: {hosts}",
|
||||
mcpHttpWebAllowedOrigins: "Allowed origins: {origins}",
|
||||
mcpHttpReloadStatus: "Reload status",
|
||||
mcpHttpServiceTitle: "Streamable HTTP service",
|
||||
mcpHttpStatusLabelRunning: "Running",
|
||||
mcpHttpStatusLabelPending: "Pending configuration",
|
||||
mcpHttpServiceDescription: "Off by default. When enabled, DBX Desktop hosts the service; the configuration above is applied to the actual listening service only after saving.",
|
||||
mcpHttpListenerTitle: "Listener configuration",
|
||||
mcpHttpListenerHintPrefix: "We recommend keeping ",
|
||||
mcpHttpListenerHintSuffix: " so only MCP clients on the same computer can connect.",
|
||||
mcpHttpHostLabel: "Listen address",
|
||||
mcpHttpPortLabel: "Port",
|
||||
mcpHttpPathLabel: "MCP path",
|
||||
mcpHttpRemoteTitle: "Remote access",
|
||||
mcpHttpRemoteHintPrefix: "Only needed when the listen address is not ",
|
||||
mcpHttpRemoteHintMiddle: " or ",
|
||||
mcpHttpRemoteHintSuffix: ". Once enabled, the Host and Origin allowlists are required.",
|
||||
mcpHttpAllowedHostsLabel: "Allowed hosts",
|
||||
mcpHttpHostsHint: "One per line, or separated with commas.",
|
||||
mcpHttpAllowedOriginsLabel: "Allowed origins",
|
||||
mcpHttpOriginsHintPrefix: "Must include the protocol, for example ",
|
||||
mcpHttpOriginsHintSuffix: ".",
|
||||
mcpHttpRemoteDisabledHint: "While remote access is off, the allowlists are not needed.",
|
||||
mcpHttpUnsavedChangesHint: "The configuration above is not saved yet; the running service will not change immediately.",
|
||||
mcpHttpSaveHint: "Saving starts or restarts the service and updates the connection details below.",
|
||||
mcpHttpReload: "Reload",
|
||||
mcpHttpSaveAndStart: "Save and start service",
|
||||
mcpHttpDisabledHint: "Enable the service to configure the listen address, remote access allowlists, and client connection details.",
|
||||
mcpHttpSaveAndStop: "Save and stop service",
|
||||
mcpHttpConnectionInfoTitle: "Active client connection details",
|
||||
mcpHttpServiceNotRunning: "The service is not running",
|
||||
mcpHttpRunningConfigBadge: "Running config",
|
||||
mcpHttpLastStatusBadge: "Last status",
|
||||
mcpHttpRunningDraftDiffersHint: "Below are the connection details of the currently running service; they may differ from the unsaved draft above.",
|
||||
mcpHttpEndpointLabel: "Service address",
|
||||
mcpHttpRotateToken: "Rotate token",
|
||||
mcpHttpRotateTokenConfirm: "After rotating the token, MCP clients using the old token are disconnected immediately. Continue?",
|
||||
mcpHttpTokenHintPrefix: "Use this value as the HTTP ",
|
||||
mcpHttpTokenHintSuffix: " credential in the client.",
|
||||
mcpHttpClientConfigTitle: "Generic HTTP client configuration",
|
||||
mcpHttpClientConfigHint: "Client field names differ; keep the URL and Authorization values.",
|
||||
mcpHttpLogsTitle: "Service diagnostic logs",
|
||||
mcpHttpValidationHostRequired: "Enter a listen address.",
|
||||
mcpHttpValidationPortRange: "The port must be an integer between 1 and 65535.",
|
||||
mcpHttpValidationPathPrefix: "The MCP path must start with /.",
|
||||
mcpHttpValidationRemoteRequired: "Enable 'Remote access' to listen on a non-loopback address.",
|
||||
mcpHttpValidationHostsRequired: "Remote access requires at least one allowed host.",
|
||||
mcpHttpValidationOriginsRequired: "Remote access requires at least one allowed origin.",
|
||||
mcpHttpErrorRemoteHostsAndOrigins: "Remote access requires both allowed hosts and allowed origins.",
|
||||
mcpHttpErrorRemoteBinding: "Remote listening requires remote access plus Host and Origin allowlists.",
|
||||
mcpHttpErrorPortInUse: "The port is already used by another application. DBX restarts its own MCP service when saving; close the other application using the port, or choose a different port.",
|
||||
mcpConnectionPolicyTitle: "Per-connection permission ceiling",
|
||||
mcpConnectionPolicyDescription: "Tighten permissions for each exposed connection; they cannot exceed the global MCP permissions.",
|
||||
mcpConnectionPolicyEmptyHint: "Allow at least one connection in step 1 before setting per-connection execution ceilings.",
|
||||
mcpConnectionPolicyInherit: "Inherit global",
|
||||
mcpConnectionPolicyReadOnly: "Read only",
|
||||
mcpConnectionPolicySafeWrite: "Allow safe writes",
|
||||
mcpConnectionPolicyHighRiskWrite: "Allow high-risk operations",
|
||||
mcpToolPermissionsTitle: "MCP tool permissions",
|
||||
mcpToolPermissionsDescription: "Expose only the MCP tools you need. Once deselected, clients are rejected by the server even if they cached the tool definitions.",
|
||||
mcpToolListConnections: "List connections",
|
||||
mcpToolListDatabases: "List databases",
|
||||
mcpToolListTables: "List tables",
|
||||
mcpToolDescribeTable: "Describe table",
|
||||
mcpToolGetSchemaContext: "Schema context",
|
||||
mcpToolExecuteQuery: "Execute SQL / Mongo commands",
|
||||
mcpToolOpenSession: "Open query session",
|
||||
mcpToolCloseSession: "Close query session",
|
||||
mcpToolExecuteRedisCommand: "Execute Redis command",
|
||||
mcpToolSendMessage: "Send message queue message",
|
||||
mcpToolAddConnection: "Add connection",
|
||||
mcpToolDuplicateConnection: "Duplicate connection",
|
||||
mcpToolRemoveConnection: "Remove connection",
|
||||
mcpToolOpenTable: "Open table in DBX",
|
||||
mcpToolExecuteAndShow: "Execute and show in DBX",
|
||||
supportInfoTitle: "Support information",
|
||||
supportInfoDescription: "Copy these environment details when filing issues or asking for help.",
|
||||
supportInfoCopy: "Copy support info",
|
||||
|
||||
@@ -6593,6 +6593,127 @@ export default withEnglishFallback({
|
||||
mcpNpmBoundary: "DBX solo comprueba y explica el estado de MCP; la instalación y actualización siguen usando npm.",
|
||||
mcpRefresh: "Comprobar de nuevo",
|
||||
mcpGuide: "Guía MCP",
|
||||
mcpAuthTitle: "Autorización de MCP",
|
||||
mcpAuthDescription: "Completa la autorización en orden: elige primero las conexiones, luego limita las bases de datos y las excepciones por conexión y, por último, define las herramientas disponibles y el nivel de ejecución global.",
|
||||
mcpAuthStepsLabel: "Pasos de autorización de MCP",
|
||||
mcpAuthStepConnectionsTitle: "Alcance de conexiones",
|
||||
mcpAuthStepConnectionsDescription: "Define qué conexiones puede ver MCP",
|
||||
mcpAuthStepDatabasesTitle: "Alcance de bases de datos",
|
||||
mcpAuthStepDatabasesDescription: "Limita las bases de datos accesibles por conexión",
|
||||
mcpAuthStepOverridesTitle: "Excepciones de conexión",
|
||||
mcpAuthStepOverridesDescription: "Restringe aún más los permisos de ejecución por conexión",
|
||||
mcpAuthStepCapabilitiesTitle: "Capacidades y ejecución",
|
||||
mcpAuthStepCapabilitiesDescription: "Elige las herramientas MCP y el nivel operativo global",
|
||||
mcpAuthStepRegionLabel: "Paso {step}: {title}",
|
||||
mcpAuthPreviousStep: "Anterior",
|
||||
mcpAuthNextStep: "Siguiente",
|
||||
mcpAuthStepProgress: "Paso {current} de {total}",
|
||||
mcpPerPage: "Por página",
|
||||
mcpPreviousPage: "Página anterior",
|
||||
mcpNextPage: "Página siguiente",
|
||||
mcpDatabaseScopeTitle: "Conexiones y alcance de bases de datos",
|
||||
mcpDatabaseScopeDescription: "Selecciona a la izquierda una conexión expuesta a MCP y limita las bases de datos a las que puede acceder. Los nombres de bases de datos se comparan de forma exacta.",
|
||||
mcpDatabaseScopeEmptyHint: "Permite primero al menos una conexión en 'Alcance de conexiones'.",
|
||||
mcpDatabaseScopeAllowedConnections: "Conexiones permitidas",
|
||||
mcpDatabaseScopeSummaryAll: "Todas las bases de datos",
|
||||
mcpDatabaseScopeSummaryNone: "Sin acceso a bases de datos",
|
||||
mcpDatabaseScopeSummarySelected: "{count} seleccionadas",
|
||||
mcpDatabaseScopePermissionNote: "El alcance seleccionado no cambia los permisos de bases de datos que la conexión ya tiene en DBX.",
|
||||
mcpDatabaseLoadButton: "Cargar desde la conexión",
|
||||
mcpDatabaseScopeAriaLabel: "Alcance de acceso a bases de datos",
|
||||
mcpDatabaseScopeAllTitle: "Todas las bases de datos",
|
||||
mcpDatabaseScopeAllDescription: "Mantiene el alcance de acceso actual",
|
||||
mcpDatabaseScopeSelectedTitle: "Solo bases de datos especificadas",
|
||||
mcpDatabaseScopeSelectedDescription: "Expone solo las opciones marcadas",
|
||||
mcpDatabaseScopeNoneTitle: "Sin acceso",
|
||||
mcpDatabaseScopeNoneDescription: "Conserva las reglas pero deniega todas las bases de datos",
|
||||
mcpDatabaseScopeSelectedSummary: "{count} bases de datos permitidas. Las bases de datos no marcadas y las solicitudes entre bases de datos son rechazadas por el servidor MCP.",
|
||||
mcpDatabaseSearchPlaceholder: "Filtrar bases de datos cargadas",
|
||||
mcpDatabaseManualPlaceholder: "Añadir nombre de base de datos manualmente",
|
||||
mcpDatabaseLoadFailed: "Error al cargar bases de datos: {error}. Puedes añadir manualmente los nombres exactos.",
|
||||
mcpDatabaseEmptyHint: "Haz clic en 'Cargar desde la conexión' para obtener la lista de bases de datos o añade manualmente las permitidas.",
|
||||
mcpTransportLocalStdio: "stdio local",
|
||||
mcpTransportHttpService: "Servicio HTTP",
|
||||
mcpHttpWebServiceTitle: "Servicio Web Streamable HTTP",
|
||||
mcpHttpWebServiceDescription: "Web/Docker reutiliza el puerto de escucha web actual. Se activa con variables de entorno de despliegue; la página de ajustes nunca guarda el Token.",
|
||||
mcpHttpStatusLabelEnabled: "Activado",
|
||||
mcpHttpStatusLabelDisabled: "Desactivado",
|
||||
mcpHttpNotConfigured: "Sin configurar",
|
||||
mcpHttpWebTokenSourceAndHosts: "Origen del Token: {tokenSource}; Hosts permitidos: {hosts}",
|
||||
mcpHttpWebAllowedOrigins: "Origins permitidos: {origins}",
|
||||
mcpHttpReloadStatus: "Recargar estado",
|
||||
mcpHttpServiceTitle: "Servicio Streamable HTTP",
|
||||
mcpHttpStatusLabelRunning: "En ejecución",
|
||||
mcpHttpStatusLabelPending: "Pendiente de aplicar",
|
||||
mcpHttpServiceDescription: "Desactivado por defecto. Al activarlo, DBX Desktop aloja el servicio; la configuración de arriba se aplica al servicio de escucha real solo después de guardar.",
|
||||
mcpHttpListenerTitle: "Configuración de escucha",
|
||||
mcpHttpListenerHintPrefix: "Se recomienda mantener ",
|
||||
mcpHttpListenerHintSuffix: " para que solo los clientes MCP del mismo equipo puedan conectarse.",
|
||||
mcpHttpHostLabel: "Dirección de escucha",
|
||||
mcpHttpPortLabel: "Puerto",
|
||||
mcpHttpPathLabel: "Ruta MCP",
|
||||
mcpHttpRemoteTitle: "Acceso remoto",
|
||||
mcpHttpRemoteHintPrefix: "Solo es necesario cuando la dirección de escucha no es ",
|
||||
mcpHttpRemoteHintMiddle: " ni ",
|
||||
mcpHttpRemoteHintSuffix: ". Al activarlo, debes rellenar las listas blancas de Host y Origin.",
|
||||
mcpHttpAllowedHostsLabel: "Hosts permitidos",
|
||||
mcpHttpHostsHint: "Uno por línea, o separados por comas.",
|
||||
mcpHttpAllowedOriginsLabel: "Origins permitidos",
|
||||
mcpHttpOriginsHintPrefix: "Debe incluir el protocolo, por ejemplo ",
|
||||
mcpHttpOriginsHintSuffix: ".",
|
||||
mcpHttpRemoteDisabledHint: "Con el acceso remoto desactivado, las listas blancas no son necesarias.",
|
||||
mcpHttpUnsavedChangesHint: "La configuración de arriba aún no se ha guardado; el servicio en ejecución no cambiará de inmediato.",
|
||||
mcpHttpSaveHint: "Al guardar se inicia o reinicia el servicio y se actualiza la información de conexión de abajo.",
|
||||
mcpHttpReload: "Recargar",
|
||||
mcpHttpSaveAndStart: "Guardar e iniciar servicio",
|
||||
mcpHttpDisabledHint: "Activa el servicio para configurar la dirección de escucha, las listas blancas de acceso remoto y la información de conexión del cliente.",
|
||||
mcpHttpSaveAndStop: "Guardar y detener servicio",
|
||||
mcpHttpConnectionInfoTitle: "Información de conexión del cliente aplicada",
|
||||
mcpHttpServiceNotRunning: "El servicio no está en ejecución",
|
||||
mcpHttpRunningConfigBadge: "Configuración en ejecución",
|
||||
mcpHttpLastStatusBadge: "Último estado",
|
||||
mcpHttpRunningDraftDiffersHint: "Abajo se muestra la información de conexión del servicio en ejecución; puede diferir del borrador sin guardar de arriba.",
|
||||
mcpHttpEndpointLabel: "Dirección del servicio",
|
||||
mcpHttpRotateToken: "Rotar Token",
|
||||
mcpHttpRotateTokenConfirm: "Al rotar el Token, los clientes MCP que usen el Token anterior se desconectan de inmediato. ¿Continuar?",
|
||||
mcpHttpTokenHintPrefix: "Usa este valor como credencial HTTP ",
|
||||
mcpHttpTokenHintSuffix: " del cliente.",
|
||||
mcpHttpClientConfigTitle: "Configuración genérica de cliente HTTP",
|
||||
mcpHttpClientConfigHint: "Los nombres de campos varían según el cliente; conserva los valores de URL y Authorization.",
|
||||
mcpHttpLogsTitle: "Registros de diagnóstico del servicio",
|
||||
mcpHttpValidationHostRequired: "Introduce una dirección de escucha.",
|
||||
mcpHttpValidationPortRange: "El puerto debe ser un entero entre 1 y 65535.",
|
||||
mcpHttpValidationPathPrefix: "La ruta MCP debe empezar por /.",
|
||||
mcpHttpValidationRemoteRequired: "Activa 'Acceso remoto' para escuchar en una dirección no local.",
|
||||
mcpHttpValidationHostsRequired: "El acceso remoto necesita al menos un Host permitido.",
|
||||
mcpHttpValidationOriginsRequired: "El acceso remoto necesita al menos un Origin permitido.",
|
||||
mcpHttpErrorRemoteHostsAndOrigins: "El acceso remoto requiere tanto Hosts permitidos como Origins permitidos.",
|
||||
mcpHttpErrorRemoteBinding: "La escucha remota requiere el acceso remoto activado y las listas blancas de Host y Origin.",
|
||||
mcpHttpErrorPortInUse: "El puerto ya está ocupado por otra aplicación. DBX reinicia su propio servicio MCP al guardar; cierra la otra aplicación que usa el puerto o elige otro puerto.",
|
||||
mcpConnectionPolicyTitle: "Límite de permisos por conexión",
|
||||
mcpConnectionPolicyDescription: "Restringe aún más los permisos de cada conexión expuesta; no pueden superar los permisos globales de MCP.",
|
||||
mcpConnectionPolicyEmptyHint: "Permite primero al menos una conexión en el paso 1 para configurar los límites de ejecución por conexión.",
|
||||
mcpConnectionPolicyInherit: "Heredar global",
|
||||
mcpConnectionPolicyReadOnly: "Solo lectura",
|
||||
mcpConnectionPolicySafeWrite: "Permitir escrituras seguras",
|
||||
mcpConnectionPolicyHighRiskWrite: "Permitir operaciones de alto riesgo",
|
||||
mcpToolPermissionsTitle: "Permisos de herramientas MCP",
|
||||
mcpToolPermissionsDescription: "Expón solo las herramientas MCP que necesitas. Al desmarcarlas, el servidor rechaza las solicitudes aunque el cliente haya cacheado las definiciones.",
|
||||
mcpToolListConnections: "Listar conexiones",
|
||||
mcpToolListDatabases: "Listar bases de datos",
|
||||
mcpToolListTables: "Listar tablas",
|
||||
mcpToolDescribeTable: "Estructura de tabla",
|
||||
mcpToolGetSchemaContext: "Contexto de Schema",
|
||||
mcpToolExecuteQuery: "Ejecutar SQL / comandos Mongo",
|
||||
mcpToolOpenSession: "Abrir sesión de consulta",
|
||||
mcpToolCloseSession: "Cerrar sesión de consulta",
|
||||
mcpToolExecuteRedisCommand: "Ejecutar comando Redis",
|
||||
mcpToolSendMessage: "Enviar mensaje de cola de mensajes",
|
||||
mcpToolAddConnection: "Añadir conexión",
|
||||
mcpToolDuplicateConnection: "Duplicar conexión",
|
||||
mcpToolRemoveConnection: "Eliminar conexión",
|
||||
mcpToolOpenTable: "Abrir tabla en DBX",
|
||||
mcpToolExecuteAndShow: "Ejecutar y mostrar en DBX",
|
||||
supportInfoTitle: "Información de soporte",
|
||||
supportInfoDescription: "Copia estos datos del entorno al crear issues o pedir ayuda.",
|
||||
supportInfoCopy: "Copiar información",
|
||||
|
||||
@@ -6593,6 +6593,127 @@ export default withEnglishFallback({
|
||||
mcpNpmBoundary: "DBX verifica e spiega solo lo stato di MCP; l'installazione e gli aggiornamenti vengono comunque eseguiti tramite npm.",
|
||||
mcpRefresh: "Verifica di nuovo",
|
||||
mcpGuide: "Guida MCP",
|
||||
mcpAuthTitle: "Autorizzazione MCP",
|
||||
mcpAuthDescription: "Completa l'autorizzazione in ordine: scegli prima le connessioni, poi limita i database e le eccezioni per connessione e infine definisci gli strumenti disponibili e il livello di esecuzione globale.",
|
||||
mcpAuthStepsLabel: "Passaggi di autorizzazione MCP",
|
||||
mcpAuthStepConnectionsTitle: "Ambito connessioni",
|
||||
mcpAuthStepConnectionsDescription: "Determina quali connessioni MCP può vedere",
|
||||
mcpAuthStepDatabasesTitle: "Ambito database",
|
||||
mcpAuthStepDatabasesDescription: "Limita i database accessibili per ogni connessione",
|
||||
mcpAuthStepOverridesTitle: "Eccezioni per connessione",
|
||||
mcpAuthStepOverridesDescription: "Restringe ulteriormente i permessi di esecuzione per connessione",
|
||||
mcpAuthStepCapabilitiesTitle: "Capacità ed esecuzione",
|
||||
mcpAuthStepCapabilitiesDescription: "Scegli gli strumenti MCP e il livello operativo globale",
|
||||
mcpAuthStepRegionLabel: "Passaggio {step}: {title}",
|
||||
mcpAuthPreviousStep: "Indietro",
|
||||
mcpAuthNextStep: "Avanti",
|
||||
mcpAuthStepProgress: "Passaggio {current} di {total}",
|
||||
mcpPerPage: "Per pagina",
|
||||
mcpPreviousPage: "Pagina precedente",
|
||||
mcpNextPage: "Pagina successiva",
|
||||
mcpDatabaseScopeTitle: "Connessioni e ambito database",
|
||||
mcpDatabaseScopeDescription: "Seleziona a sinistra una connessione esposta a MCP, poi limita i database che quella connessione può raggiungere. I nomi dei database corrispondono in modo esatto.",
|
||||
mcpDatabaseScopeEmptyHint: "Consenti prima almeno una connessione in 'Ambito connessioni'.",
|
||||
mcpDatabaseScopeAllowedConnections: "Connessioni consentite",
|
||||
mcpDatabaseScopeSummaryAll: "Tutti i database",
|
||||
mcpDatabaseScopeSummaryNone: "Nessun accesso ai database",
|
||||
mcpDatabaseScopeSummarySelected: "{count} selezionati",
|
||||
mcpDatabaseScopePermissionNote: "L'ambito selezionato non modifica le autorizzazioni database già esistenti della connessione in DBX.",
|
||||
mcpDatabaseLoadButton: "Carica dalla connessione",
|
||||
mcpDatabaseScopeAriaLabel: "Ambito di accesso ai database",
|
||||
mcpDatabaseScopeAllTitle: "Tutti i database",
|
||||
mcpDatabaseScopeAllDescription: "Mantiene l'ambito di accesso attuale",
|
||||
mcpDatabaseScopeSelectedTitle: "Solo database specificati",
|
||||
mcpDatabaseScopeSelectedDescription: "Espone solo gli elementi selezionati",
|
||||
mcpDatabaseScopeNoneTitle: "Nessun accesso",
|
||||
mcpDatabaseScopeNoneDescription: "Mantiene le regole ma rifiuta tutti i database",
|
||||
mcpDatabaseScopeSelectedSummary: "{count} database consentiti. I database non selezionati e le richieste tra database vengono rifiutati dal server MCP.",
|
||||
mcpDatabaseSearchPlaceholder: "Filtra i database caricati",
|
||||
mcpDatabaseManualPlaceholder: "Aggiungi manualmente un nome di database",
|
||||
mcpDatabaseLoadFailed: "Caricamento dei database non riuscito: {error}. Puoi aggiungere manualmente i nomi esatti.",
|
||||
mcpDatabaseEmptyHint: "Fai clic su 'Carica dalla connessione' per ottenere l'elenco dei database oppure aggiungi manualmente quelli consentiti.",
|
||||
mcpTransportLocalStdio: "stdio locale",
|
||||
mcpTransportHttpService: "Servizio HTTP",
|
||||
mcpHttpWebServiceTitle: "Servizio Web Streamable HTTP",
|
||||
mcpHttpWebServiceDescription: "Web/Docker riusa la porta di ascolto web attuale. Viene abilitato con variabili d'ambiente di distribuzione; la pagina delle impostazioni non salva mai il Token.",
|
||||
mcpHttpStatusLabelEnabled: "Attivo",
|
||||
mcpHttpStatusLabelDisabled: "Non attivo",
|
||||
mcpHttpNotConfigured: "Non configurato",
|
||||
mcpHttpWebTokenSourceAndHosts: "Origine Token: {tokenSource}; Host consentiti: {hosts}",
|
||||
mcpHttpWebAllowedOrigins: "Origin consentiti: {origins}",
|
||||
mcpHttpReloadStatus: "Ricarica stato",
|
||||
mcpHttpServiceTitle: "Servizio Streamable HTTP",
|
||||
mcpHttpStatusLabelRunning: "In esecuzione",
|
||||
mcpHttpStatusLabelPending: "In attesa di applicazione",
|
||||
mcpHttpServiceDescription: "Disattivato per impostazione predefinita. Se attivato, DBX Desktop ospita il servizio; la configurazione sopra viene applicata al servizio in ascolto solo dopo il salvataggio.",
|
||||
mcpHttpListenerTitle: "Configurazione di ascolto",
|
||||
mcpHttpListenerHintPrefix: "Si consiglia di mantenere ",
|
||||
mcpHttpListenerHintSuffix: " per consentire la connessione solo ai client MCP dello stesso computer.",
|
||||
mcpHttpHostLabel: "Indirizzo di ascolto",
|
||||
mcpHttpPortLabel: "Porta",
|
||||
mcpHttpPathLabel: "Percorso MCP",
|
||||
mcpHttpRemoteTitle: "Accesso remoto",
|
||||
mcpHttpRemoteHintPrefix: "Necessario solo quando l'indirizzo di ascolto non è ",
|
||||
mcpHttpRemoteHintMiddle: " o ",
|
||||
mcpHttpRemoteHintSuffix: ". Una volta attivato, vanno compilati gli elenchi consentiti di Host e Origin.",
|
||||
mcpHttpAllowedHostsLabel: "Host consentiti",
|
||||
mcpHttpHostsHint: "Uno per riga, separabili anche con virgole.",
|
||||
mcpHttpAllowedOriginsLabel: "Origin consentiti",
|
||||
mcpHttpOriginsHintPrefix: "Deve includere il protocollo, ad esempio ",
|
||||
mcpHttpOriginsHintSuffix: ".",
|
||||
mcpHttpRemoteDisabledHint: "Con l'accesso remoto disattivato, gli elenchi consentiti non sono necessari.",
|
||||
mcpHttpUnsavedChangesHint: "La configurazione sopra non è ancora salvata; il servizio in esecuzione non cambierà subito.",
|
||||
mcpHttpSaveHint: "Il salvataggio avvia o riavvia il servizio e aggiorna le informazioni di connessione sotto.",
|
||||
mcpHttpReload: "Ricarica",
|
||||
mcpHttpSaveAndStart: "Salva e avvia servizio",
|
||||
mcpHttpDisabledHint: "Attiva il servizio per configurare indirizzo di ascolto, elenchi di accesso remoto e informazioni di connessione del client.",
|
||||
mcpHttpSaveAndStop: "Salva e ferma servizio",
|
||||
mcpHttpConnectionInfoTitle: "Informazioni di connessione del client applicate",
|
||||
mcpHttpServiceNotRunning: "Il servizio non è in esecuzione",
|
||||
mcpHttpRunningConfigBadge: "Configurazione in esecuzione",
|
||||
mcpHttpLastStatusBadge: "Ultimo stato",
|
||||
mcpHttpRunningDraftDiffersHint: "Qui sotto ci sono le informazioni di connessione del servizio in esecuzione; possono differire dalla bozza non salvata sopra.",
|
||||
mcpHttpEndpointLabel: "Indirizzo del servizio",
|
||||
mcpHttpRotateToken: "Ruota Token",
|
||||
mcpHttpRotateTokenConfirm: "Dopo la rotazione del Token, i client MCP che usano il vecchio Token verranno disconnessi subito. Continuare?",
|
||||
mcpHttpTokenHintPrefix: "Usa questo valore come credenziale HTTP ",
|
||||
mcpHttpTokenHintSuffix: " del client.",
|
||||
mcpHttpClientConfigTitle: "Configurazione generica del client HTTP",
|
||||
mcpHttpClientConfigHint: "I nomi dei campi variano per client; conserva i valori di URL e Authorization.",
|
||||
mcpHttpLogsTitle: "Log di diagnostica del servizio",
|
||||
mcpHttpValidationHostRequired: "Inserisci un indirizzo di ascolto.",
|
||||
mcpHttpValidationPortRange: "La porta deve essere un intero tra 1 e 65535.",
|
||||
mcpHttpValidationPathPrefix: "Il percorso MCP deve iniziare con /.",
|
||||
mcpHttpValidationRemoteRequired: "Attiva 'Accesso remoto' per ascoltare su un indirizzo non locale.",
|
||||
mcpHttpValidationHostsRequired: "L'accesso remoto richiede almeno un Host consentito.",
|
||||
mcpHttpValidationOriginsRequired: "L'accesso remoto richiede almeno un Origin consentito.",
|
||||
mcpHttpErrorRemoteHostsAndOrigins: "L'accesso remoto richiede sia Host sia Origin consentiti.",
|
||||
mcpHttpErrorRemoteBinding: "L'ascolto remoto richiede l'accesso remoto attivo e gli elenchi consentiti di Host e Origin.",
|
||||
mcpHttpErrorPortInUse: "La porta è già usata da un'altra applicazione. DBX riavvia il proprio servizio MCP al salvataggio; chiudi l'altra applicazione che usa la porta oppure scegli un'altra porta.",
|
||||
mcpConnectionPolicyTitle: "Limite di permessi per connessione",
|
||||
mcpConnectionPolicyDescription: "Restringe ulteriormente i permessi di ogni connessione esposta; non possono superare i permessi MCP globali.",
|
||||
mcpConnectionPolicyEmptyHint: "Consenti prima almeno una connessione nel passaggio 1 per impostare i limiti di esecuzione per connessione.",
|
||||
mcpConnectionPolicyInherit: "Eredita globale",
|
||||
mcpConnectionPolicyReadOnly: "Sola lettura",
|
||||
mcpConnectionPolicySafeWrite: "Consenti scritture sicure",
|
||||
mcpConnectionPolicyHighRiskWrite: "Consenti operazioni ad alto rischio",
|
||||
mcpToolPermissionsTitle: "Permessi degli strumenti MCP",
|
||||
mcpToolPermissionsDescription: "Esporre solo gli strumenti MCP necessari. Dopo la deselezione, le richieste vengono rifiutate dal server anche se il client ha le definizioni in cache.",
|
||||
mcpToolListConnections: "Elenca connessioni",
|
||||
mcpToolListDatabases: "Elenca database",
|
||||
mcpToolListTables: "Elenca tabelle",
|
||||
mcpToolDescribeTable: "Struttura tabella",
|
||||
mcpToolGetSchemaContext: "Contesto Schema",
|
||||
mcpToolExecuteQuery: "Esegui SQL / comandi Mongo",
|
||||
mcpToolOpenSession: "Apri sessione di query",
|
||||
mcpToolCloseSession: "Chiudi sessione di query",
|
||||
mcpToolExecuteRedisCommand: "Esegui comando Redis",
|
||||
mcpToolSendMessage: "Invia messaggio in coda",
|
||||
mcpToolAddConnection: "Aggiungi connessione",
|
||||
mcpToolDuplicateConnection: "Duplica connessione",
|
||||
mcpToolRemoveConnection: "Rimuovi connessione",
|
||||
mcpToolOpenTable: "Apri tabella in DBX",
|
||||
mcpToolExecuteAndShow: "Esegui e mostra in DBX",
|
||||
supportInfoTitle: "Informazioni di supporto",
|
||||
supportInfoDescription: "Copia questi dettagli dell'ambiente quando apri issue o chiedi aiuto.",
|
||||
supportInfoCopy: "Copia informazioni",
|
||||
|
||||
@@ -6600,6 +6600,127 @@ export default withEnglishFallback({
|
||||
mcpNpmBoundary: "DBXはMCPステータスの確認と説明のみを行います。インストールとアップグレードはnpmを通じて実行されます。",
|
||||
mcpRefresh: "再確認",
|
||||
mcpGuide: "MCPガイド",
|
||||
mcpAuthTitle: "MCP 権限設定",
|
||||
mcpAuthDescription: "順番に設定を進めてください:まず接続を選択し、次にデータベースと接続ごとの例外を制限し、最後に利用するツールとグローバル実行レベルを確定します。",
|
||||
mcpAuthStepsLabel: "MCP 権限設定の手順",
|
||||
mcpAuthStepConnectionsTitle: "接続範囲",
|
||||
mcpAuthStepConnectionsDescription: "MCP に公開する接続を決定します",
|
||||
mcpAuthStepDatabasesTitle: "データベース範囲",
|
||||
mcpAuthStepDatabasesDescription: "接続ごとにアクセス可能なデータベースを制限します",
|
||||
mcpAuthStepOverridesTitle: "接続の例外",
|
||||
mcpAuthStepOverridesDescription: "接続ごとに実行権限をさらに制限します",
|
||||
mcpAuthStepCapabilitiesTitle: "機能と実行",
|
||||
mcpAuthStepCapabilitiesDescription: "MCP ツールとグローバル操作レベルを選択します",
|
||||
mcpAuthStepRegionLabel: "ステップ {step}:{title}",
|
||||
mcpAuthPreviousStep: "前へ",
|
||||
mcpAuthNextStep: "次へ",
|
||||
mcpAuthStepProgress: "ステップ {current} / {total}",
|
||||
mcpPerPage: "表示件数",
|
||||
mcpPreviousPage: "前のページ",
|
||||
mcpNextPage: "次のページ",
|
||||
mcpDatabaseScopeTitle: "接続とデータベース範囲",
|
||||
mcpDatabaseScopeDescription: "左側で MCP に公開する接続を選択してから、その接続がアクセスできるデータベースを制限します。データベース名は完全一致で判定されます。",
|
||||
mcpDatabaseScopeEmptyHint: "先に「接続範囲」で少なくとも 1 つの接続を許可してください。",
|
||||
mcpDatabaseScopeAllowedConnections: "許可済みの接続",
|
||||
mcpDatabaseScopeSummaryAll: "すべてのデータベース",
|
||||
mcpDatabaseScopeSummaryNone: "データベースアクセスなし",
|
||||
mcpDatabaseScopeSummarySelected: "{count} 件を選択済み",
|
||||
mcpDatabaseScopePermissionNote: "範囲を選択しても、DBX 内のこの接続の既存のデータベース権限は変更されません。",
|
||||
mcpDatabaseLoadButton: "接続から読み込む",
|
||||
mcpDatabaseScopeAriaLabel: "データベースアクセス範囲",
|
||||
mcpDatabaseScopeAllTitle: "すべてのデータベース",
|
||||
mcpDatabaseScopeAllDescription: "現在のアクセス範囲を維持",
|
||||
mcpDatabaseScopeSelectedTitle: "指定したデータベースのみ",
|
||||
mcpDatabaseScopeSelectedDescription: "チェックした項目のみ公開",
|
||||
mcpDatabaseScopeNoneTitle: "アクセス不許可",
|
||||
mcpDatabaseScopeNoneDescription: "ルールは保持しすべてのデータベースを拒否",
|
||||
mcpDatabaseScopeSelectedSummary: "{count} 個のデータベースを許可済みです。未チェックのデータベースとクロスデータベースのリクエストは MCP サーバーにより拒否されます。",
|
||||
mcpDatabaseSearchPlaceholder: "読み込み済みデータベースを絞り込み",
|
||||
mcpDatabaseManualPlaceholder: "データベース名を手動追加",
|
||||
mcpDatabaseLoadFailed: "データベースの読み込みに失敗しました:{error}。正確なデータベース名を手動で追加できます。",
|
||||
mcpDatabaseEmptyHint: "「接続から読み込む」をクリックしてデータベース一覧を取得するか、許可するデータベースを手動で追加してください。",
|
||||
mcpTransportLocalStdio: "ローカル stdio",
|
||||
mcpTransportHttpService: "HTTP サービス",
|
||||
mcpHttpWebServiceTitle: "Web Streamable HTTP サービス",
|
||||
mcpHttpWebServiceDescription: "Web/Docker は現在の Web リスニングポートを再利用します。デプロイの環境変数で有効化され、設定ページに Token は保存されません。",
|
||||
mcpHttpStatusLabelEnabled: "有効",
|
||||
mcpHttpStatusLabelDisabled: "無効",
|
||||
mcpHttpNotConfigured: "未設定",
|
||||
mcpHttpWebTokenSourceAndHosts: "Token ソース:{tokenSource};許可 Host:{hosts}",
|
||||
mcpHttpWebAllowedOrigins: "許可 Origin:{origins}",
|
||||
mcpHttpReloadStatus: "状態を再読み込み",
|
||||
mcpHttpServiceTitle: "Streamable HTTP サービス",
|
||||
mcpHttpStatusLabelRunning: "実行中",
|
||||
mcpHttpStatusLabelPending: "設定待ち",
|
||||
mcpHttpServiceDescription: "既定では無効です。有効にすると DBX Desktop がサービスをホストし、保存後には上記の設定が実際のリスニングサービスに適用されます。",
|
||||
mcpHttpListenerTitle: "リスニング設定",
|
||||
mcpHttpListenerHintPrefix: "「",
|
||||
mcpHttpListenerHintSuffix: "」を維持することをおすすめします。同じコンピューター上の MCP クライアントのみ接続できます。",
|
||||
mcpHttpHostLabel: "リスニングアドレス",
|
||||
mcpHttpPortLabel: "ポート",
|
||||
mcpHttpPathLabel: "MCP パス",
|
||||
mcpHttpRemoteTitle: "リモートアクセス",
|
||||
mcpHttpRemoteHintPrefix: "リスニングアドレスが ",
|
||||
mcpHttpRemoteHintMiddle: " または ",
|
||||
mcpHttpRemoteHintSuffix: " 以外の場合にのみ必要です。有効にする場合は Host と Origin のホワイトリストを入力してください。",
|
||||
mcpHttpAllowedHostsLabel: "許可する Host",
|
||||
mcpHttpHostsHint: "1 行に 1 件、カンマ区切りも利用できます。",
|
||||
mcpHttpAllowedOriginsLabel: "許可する Origin",
|
||||
mcpHttpOriginsHintPrefix: "プロトコルを含める必要があります(例: ",
|
||||
mcpHttpOriginsHintSuffix: ")。",
|
||||
mcpHttpRemoteDisabledHint: "リモートアクセスが無効の場合、ホワイトリストの設定は不要です。",
|
||||
mcpHttpUnsavedChangesHint: "上記の設定は未保存のため、実行中のサービスはすぐには変更されません。",
|
||||
mcpHttpSaveHint: "保存するとサービスを開始または再起動し、下記の接続情報を更新します。",
|
||||
mcpHttpReload: "再読み込み",
|
||||
mcpHttpSaveAndStart: "保存してサービスを開始",
|
||||
mcpHttpDisabledHint: "サービスを有効にすると、リスニングアドレス、リモートアクセスのホワイトリスト、クライアント接続情報を設定できます。",
|
||||
mcpHttpSaveAndStop: "保存してサービスを停止",
|
||||
mcpHttpConnectionInfoTitle: "適用済みのクライアント接続情報",
|
||||
mcpHttpServiceNotRunning: "サービスは実行されていません",
|
||||
mcpHttpRunningConfigBadge: "実行中の設定",
|
||||
mcpHttpLastStatusBadge: "最後の状態",
|
||||
mcpHttpRunningDraftDiffersHint: "下記は現在実行中のサービスの接続情報です。上記の未保存の下書きと異なる場合があります。",
|
||||
mcpHttpEndpointLabel: "サービスアドレス",
|
||||
mcpHttpRotateToken: "Token をローテーション",
|
||||
mcpHttpRotateTokenConfirm: "Token をローテーションすると、古い Token を使う MCP クライアントは直ちに切断されます。続行しますか?",
|
||||
mcpHttpTokenHintPrefix: "この値をクライアントの HTTP ",
|
||||
mcpHttpTokenHintSuffix: " 認証情報として使用してください。",
|
||||
mcpHttpClientConfigTitle: "汎用 HTTP クライアント設定",
|
||||
mcpHttpClientConfigHint: "クライアントによりフィールド名が異なります。URL と Authorization の値は保持してください。",
|
||||
mcpHttpLogsTitle: "サービス診断ログ",
|
||||
mcpHttpValidationHostRequired: "リスニングアドレスを入力してください。",
|
||||
mcpHttpValidationPortRange: "ポートは 1 ~ 65535 の整数である必要があります。",
|
||||
mcpHttpValidationPathPrefix: "MCP パスは / で始める必要があります。",
|
||||
mcpHttpValidationRemoteRequired: "ループバック以外のアドレスでリスニングする場合は「リモートアクセス」を有効にしてください。",
|
||||
mcpHttpValidationHostsRequired: "リモートアクセスには少なくとも 1 つの許可 Host が必要です。",
|
||||
mcpHttpValidationOriginsRequired: "リモートアクセスには少なくとも 1 つの許可 Origin が必要です。",
|
||||
mcpHttpErrorRemoteHostsAndOrigins: "リモートアクセスには許可 Host と許可 Origin の両方が必要です。",
|
||||
mcpHttpErrorRemoteBinding: "リモートリスニングにはリモートアクセスの有効化と Host・Origin のホワイトリストが必要です。",
|
||||
mcpHttpErrorPortInUse: "このポートは他のアプリケーションで使用されています。DBX は保存時に自身の MCP サービスを再起動します。ポートを使用している他のアプリケーションを終了するか、別のポートを使用してください。",
|
||||
mcpConnectionPolicyTitle: "接続ごとの権限上限",
|
||||
mcpConnectionPolicyDescription: "公開した各接続に対して権限をさらに制限できます。グローバルの MCP 権限を超えることはできません。",
|
||||
mcpConnectionPolicyEmptyHint: "接続ごとの実行上限を設定するには、まずステップ 1 で少なくとも 1 つの接続を許可してください。",
|
||||
mcpConnectionPolicyInherit: "グローバルを継承",
|
||||
mcpConnectionPolicyReadOnly: "読み取り専用のみ",
|
||||
mcpConnectionPolicySafeWrite: "安全な書き込みを許可",
|
||||
mcpConnectionPolicyHighRiskWrite: "高リスク操作を許可",
|
||||
mcpToolPermissionsTitle: "MCP ツール権限",
|
||||
mcpToolPermissionsDescription: "必要な MCP ツールのみ公開します。選択を解除すると、ツール定義をキャッシュしたクライアントもサーバー側で拒否されます。",
|
||||
mcpToolListConnections: "接続を一覧",
|
||||
mcpToolListDatabases: "データベースを一覧",
|
||||
mcpToolListTables: "テーブルを一覧",
|
||||
mcpToolDescribeTable: "テーブル構造",
|
||||
mcpToolGetSchemaContext: "Schema コンテキスト",
|
||||
mcpToolExecuteQuery: "SQL / Mongo コマンドを実行",
|
||||
mcpToolOpenSession: "クエリセッションを開く",
|
||||
mcpToolCloseSession: "クエリセッションを閉じる",
|
||||
mcpToolExecuteRedisCommand: "Redis コマンドを実行",
|
||||
mcpToolSendMessage: "メッセージキューのメッセージを送信",
|
||||
mcpToolAddConnection: "接続を追加",
|
||||
mcpToolDuplicateConnection: "接続を複製",
|
||||
mcpToolRemoveConnection: "接続を削除",
|
||||
mcpToolOpenTable: "DBX でテーブルを開く",
|
||||
mcpToolExecuteAndShow: "DBX で実行して表示",
|
||||
updateDownloadSource: "アップデートのダウンロード元",
|
||||
updateDownloadSourceDescription: "アプリ内アップデートのインストーラーのダウンロード元を選択します。公式ソース推奨。中国本土ではCNBの方が高速な場合があります。",
|
||||
updateDownloadSourceOfficial: "公式ソース(推奨)",
|
||||
|
||||
@@ -6340,6 +6340,127 @@ export default withEnglishFallback({
|
||||
mcpNpmBoundary: "DBX는 MCP 상태를 확인하고 설명만 합니다. 설치와 업그레이드는 여전히 npm을 통해 실행됩니다.",
|
||||
mcpRefresh: "다시 확인",
|
||||
mcpGuide: "MCP 가이드",
|
||||
mcpAuthTitle: "MCP 권한 설정",
|
||||
mcpAuthDescription: "순서대로 권한을 설정하세요: 먼저 연결을 선택하고, 데이터베이스와 연결 예외를 제한한 뒤, 마지막으로 사용할 도구와 전역 실행 수준을 확정합니다.",
|
||||
mcpAuthStepsLabel: "MCP 권한 설정 단계",
|
||||
mcpAuthStepConnectionsTitle: "연결 범위",
|
||||
mcpAuthStepConnectionsDescription: "MCP에 노출할 연결을 결정합니다",
|
||||
mcpAuthStepDatabasesTitle: "데이터베이스 범위",
|
||||
mcpAuthStepDatabasesDescription: "연결별로 접근 가능한 데이터베이스를 제한합니다",
|
||||
mcpAuthStepOverridesTitle: "연결 예외",
|
||||
mcpAuthStepOverridesDescription: "연결별로 실행 권한을 더 좁힙니다",
|
||||
mcpAuthStepCapabilitiesTitle: "기능 및 실행",
|
||||
mcpAuthStepCapabilitiesDescription: "MCP 도구와 전역 작업 수준을 선택합니다",
|
||||
mcpAuthStepRegionLabel: "단계 {step}: {title}",
|
||||
mcpAuthPreviousStep: "이전",
|
||||
mcpAuthNextStep: "다음",
|
||||
mcpAuthStepProgress: "{total}단계 중 {current}단계",
|
||||
mcpPerPage: "페이지당",
|
||||
mcpPreviousPage: "이전 페이지",
|
||||
mcpNextPage: "다음 페이지",
|
||||
mcpDatabaseScopeTitle: "연결 및 데이터베이스 범위",
|
||||
mcpDatabaseScopeDescription: "왼쪽에서 MCP에 노출된 연결을 선택한 뒤, 해당 연결이 접근할 수 있는 데이터베이스를 제한합니다. 데이터베이스 이름은 정확히 일치해야 합니다.",
|
||||
mcpDatabaseScopeEmptyHint: "먼저 '연결 범위'에서 하나 이상의 연결을 허용하세요.",
|
||||
mcpDatabaseScopeAllowedConnections: "허용된 연결",
|
||||
mcpDatabaseScopeSummaryAll: "모든 데이터베이스",
|
||||
mcpDatabaseScopeSummaryNone: "데이터베이스 접근 없음",
|
||||
mcpDatabaseScopeSummarySelected: "{count}개 선택됨",
|
||||
mcpDatabaseScopePermissionNote: "범위를 선택해도 DBX에서 이 연결의 기존 데이터베이스 권한은 변경되지 않습니다.",
|
||||
mcpDatabaseLoadButton: "연결에서 불러오기",
|
||||
mcpDatabaseScopeAriaLabel: "데이터베이스 접근 범위",
|
||||
mcpDatabaseScopeAllTitle: "모든 데이터베이스",
|
||||
mcpDatabaseScopeAllDescription: "현재 접근 범위 유지",
|
||||
mcpDatabaseScopeSelectedTitle: "지정한 데이터베이스만",
|
||||
mcpDatabaseScopeSelectedDescription: "선택한 항목만 노출",
|
||||
mcpDatabaseScopeNoneTitle: "접근 허용 안 함",
|
||||
mcpDatabaseScopeNoneDescription: "규칙은 유지하되 모든 데이터베이스 거부",
|
||||
mcpDatabaseScopeSelectedSummary: "{count}개 데이터베이스가 허용되었습니다. 선택하지 않은 데이터베이스와 크로스 데이터베이스 요청은 MCP 서버가 거부합니다.",
|
||||
mcpDatabaseSearchPlaceholder: "불러온 데이터베이스 필터",
|
||||
mcpDatabaseManualPlaceholder: "데이터베이스 이름 직접 추가",
|
||||
mcpDatabaseLoadFailed: "데이터베이스 불러오기 실패: {error}. 정확한 데이터베이스 이름을 직접 추가할 수 있습니다.",
|
||||
mcpDatabaseEmptyHint: "'연결에서 불러오기'를 클릭해 데이터베이스 목록을 가져오거나, 허용할 데이터베이스를 직접 추가하세요.",
|
||||
mcpTransportLocalStdio: "로컬 stdio",
|
||||
mcpTransportHttpService: "HTTP 서비스",
|
||||
mcpHttpWebServiceTitle: "Web Streamable HTTP 서비스",
|
||||
mcpHttpWebServiceDescription: "Web/Docker은 현재 웹 수신 포트를 재사용합니다. 배포 환경 변수로 활성화되며 설정 페이지에는 Token이 저장되지 않습니다.",
|
||||
mcpHttpStatusLabelEnabled: "활성화됨",
|
||||
mcpHttpStatusLabelDisabled: "비활성화됨",
|
||||
mcpHttpNotConfigured: "미구성",
|
||||
mcpHttpWebTokenSourceAndHosts: "Token 출처: {tokenSource}; 허용 Host: {hosts}",
|
||||
mcpHttpWebAllowedOrigins: "허용 Origin: {origins}",
|
||||
mcpHttpReloadStatus: "상태 다시 불러오기",
|
||||
mcpHttpServiceTitle: "Streamable HTTP 서비스",
|
||||
mcpHttpStatusLabelRunning: "실행 중",
|
||||
mcpHttpStatusLabelPending: "적용 대기 중",
|
||||
mcpHttpServiceDescription: "기본적으로 꺼져 있습니다. 활성화하면 DBX Desktop이 서비스를 호스팅하며, 저장 후에야 위 설정이 실제 수신 서비스에 적용됩니다.",
|
||||
mcpHttpListenerTitle: "수신 설정",
|
||||
mcpHttpListenerHintPrefix: "",
|
||||
mcpHttpListenerHintSuffix: "을(를) 유지하는 것을 권장합니다. 같은 컴퓨터의 MCP 클라이언트만 연결할 수 있습니다.",
|
||||
mcpHttpHostLabel: "수신 주소",
|
||||
mcpHttpPortLabel: "포트",
|
||||
mcpHttpPathLabel: "MCP 경로",
|
||||
mcpHttpRemoteTitle: "원격 접근",
|
||||
mcpHttpRemoteHintPrefix: "수신 주소가 ",
|
||||
mcpHttpRemoteHintMiddle: " 또는 ",
|
||||
mcpHttpRemoteHintSuffix: "이(가) 아닐 때만 필요합니다. 활성화하면 Host와 Origin 화이트리스트를 반드시 입력해야 합니다.",
|
||||
mcpHttpAllowedHostsLabel: "허용할 Host",
|
||||
mcpHttpHostsHint: "한 줄에 하나씩, 쉼표로 구분할 수도 있습니다.",
|
||||
mcpHttpAllowedOriginsLabel: "허용할 Origin",
|
||||
mcpHttpOriginsHintPrefix: "프로토콜을 포함해야 합니다(예: ",
|
||||
mcpHttpOriginsHintSuffix: ").",
|
||||
mcpHttpRemoteDisabledHint: "원격 접근이 꺼져 있으면 화이트리스트를 구성할 필요가 없습니다.",
|
||||
mcpHttpUnsavedChangesHint: "위 설정이 아직 저장되지 않아 실행 중인 서비스는 즉시 변경되지 않습니다.",
|
||||
mcpHttpSaveHint: "저장하면 서비스를 시작하거나 재시작하고 아래 접속 정보를 갱신합니다.",
|
||||
mcpHttpReload: "다시 불러오기",
|
||||
mcpHttpSaveAndStart: "저장하고 서비스 시작",
|
||||
mcpHttpDisabledHint: "서비스를 활성화하면 수신 주소, 원격 접근 화이트리스트, 클라이언트 접속 정보를 구성할 수 있습니다.",
|
||||
mcpHttpSaveAndStop: "저장하고 서비스 중지",
|
||||
mcpHttpConnectionInfoTitle: "적용된 클라이언트 접속 정보",
|
||||
mcpHttpServiceNotRunning: "서비스가 실행 중이 아닙니다",
|
||||
mcpHttpRunningConfigBadge: "실행 설정",
|
||||
mcpHttpLastStatusBadge: "최근 상태",
|
||||
mcpHttpRunningDraftDiffersHint: "아래는 현재 실행 중인 서비스의 접속 정보이며, 위의 저장되지 않은 초안과 다를 수 있습니다.",
|
||||
mcpHttpEndpointLabel: "서비스 주소",
|
||||
mcpHttpRotateToken: "Token 교체",
|
||||
mcpHttpRotateTokenConfirm: "Token을 교체하면 이전 Token을 사용하는 MCP 클라이언트가 즉시 연결 해제됩니다. 계속하시겠습니까?",
|
||||
mcpHttpTokenHintPrefix: "이 값을 클라이언트 HTTP ",
|
||||
mcpHttpTokenHintSuffix: " 자격 증명으로 사용하세요.",
|
||||
mcpHttpClientConfigTitle: "범용 HTTP 클라이언트 설정",
|
||||
mcpHttpClientConfigHint: "클라이언트마다 필드 이름이 다르므로 URL과 Authorization 값은 유지하세요.",
|
||||
mcpHttpLogsTitle: "서비스 진단 로그",
|
||||
mcpHttpValidationHostRequired: "수신 주소를 입력하세요.",
|
||||
mcpHttpValidationPortRange: "포트는 1에서 65535 사이의 정수여야 합니다.",
|
||||
mcpHttpValidationPathPrefix: "MCP 경로는 /로 시작해야 합니다.",
|
||||
mcpHttpValidationRemoteRequired: "루프백이 아닌 주소로 수신하려면 '원격 접근'을 활성화하세요.",
|
||||
mcpHttpValidationHostsRequired: "원격 접근에는 허용 Host를 하나 이상 입력해야 합니다.",
|
||||
mcpHttpValidationOriginsRequired: "원격 접근에는 허용 Origin을 하나 이상 입력해야 합니다.",
|
||||
mcpHttpErrorRemoteHostsAndOrigins: "원격 접근에는 허용 Host와 허용 Origin을 모두 입력해야 합니다.",
|
||||
mcpHttpErrorRemoteBinding: "원격 수신에는 원격 접근 활성화와 Host·Origin 화이트리스트가 필요합니다.",
|
||||
mcpHttpErrorPortInUse: "이 포트는 다른 애플리케이션이 사용 중입니다. DBX는 저장 시 자체 MCP 서비스를 재시작합니다. 해당 포트를 사용하는 다른 애플리케이션을 종료하거나 다른 포트를 사용하세요.",
|
||||
mcpConnectionPolicyTitle: "연결별 권한 상한",
|
||||
mcpConnectionPolicyDescription: "노출된 각 연결에 대해 권한을 더 좁힐 수 있습니다. 전역 MCP 권한을 초과할 수 없습니다.",
|
||||
mcpConnectionPolicyEmptyHint: "연결별 실행 상한을 설정하려면 먼저 1단계에서 하나 이상의 연결을 허용하세요.",
|
||||
mcpConnectionPolicyInherit: "전역 상속",
|
||||
mcpConnectionPolicyReadOnly: "읽기 전용만",
|
||||
mcpConnectionPolicySafeWrite: "안전한 쓰기 허용",
|
||||
mcpConnectionPolicyHighRiskWrite: "고위험 작업 허용",
|
||||
mcpToolPermissionsTitle: "MCP 도구 권한",
|
||||
mcpToolPermissionsDescription: "필요한 MCP 도구만 노출하세요. 선택 해제하면 도구 정의를 캐시한 클라이언트도 서버에서 거부됩니다.",
|
||||
mcpToolListConnections: "연결 나열",
|
||||
mcpToolListDatabases: "데이터베이스 나열",
|
||||
mcpToolListTables: "테이블 나열",
|
||||
mcpToolDescribeTable: "테이블 구조",
|
||||
mcpToolGetSchemaContext: "Schema 컨텍스트",
|
||||
mcpToolExecuteQuery: "SQL / Mongo 명령 실행",
|
||||
mcpToolOpenSession: "쿼리 세션 열기",
|
||||
mcpToolCloseSession: "쿼리 세션 닫기",
|
||||
mcpToolExecuteRedisCommand: "Redis 명령 실행",
|
||||
mcpToolSendMessage: "메시지 큐 메시지 전송",
|
||||
mcpToolAddConnection: "연결 추가",
|
||||
mcpToolDuplicateConnection: "연결 복제",
|
||||
mcpToolRemoveConnection: "연결 삭제",
|
||||
mcpToolOpenTable: "DBX에서 테이블 열기",
|
||||
mcpToolExecuteAndShow: "DBX에서 실행 및 표시",
|
||||
supportInfoTitle: "지원 정보",
|
||||
supportInfoDescription: "이슈를 등록하거나 도움을 요청할 때 이 환경 세부 정보를 복사하세요.",
|
||||
supportInfoCopy: "지원 정보 복사",
|
||||
|
||||
@@ -6595,6 +6595,127 @@ export default withEnglishFallback({
|
||||
mcpNpmBoundary: "O DBX apenas verifica e explica o status do MCP; a instalação e as atualizações ainda são feitas pelo npm.",
|
||||
mcpRefresh: "Verificar novamente",
|
||||
mcpGuide: "Guia do MCP",
|
||||
mcpAuthTitle: "Autorização do MCP",
|
||||
mcpAuthDescription: "Conclua a autorização em ordem: escolha as conexões primeiro, depois limite os bancos de dados e as exceções por conexão e, por fim, defina as ferramentas disponíveis e o nível de execução global.",
|
||||
mcpAuthStepsLabel: "Etapas de autorização do MCP",
|
||||
mcpAuthStepConnectionsTitle: "Escopo de conexões",
|
||||
mcpAuthStepConnectionsDescription: "Define quais conexões o MCP pode ver",
|
||||
mcpAuthStepDatabasesTitle: "Escopo de bancos de dados",
|
||||
mcpAuthStepDatabasesDescription: "Limita os bancos acessíveis por conexão",
|
||||
mcpAuthStepOverridesTitle: "Exceções por conexão",
|
||||
mcpAuthStepOverridesDescription: "Restringe mais ainda as permissões de execução por conexão",
|
||||
mcpAuthStepCapabilitiesTitle: "Capacidades e execução",
|
||||
mcpAuthStepCapabilitiesDescription: "Escolha as ferramentas do MCP e o nível de operação global",
|
||||
mcpAuthStepRegionLabel: "Etapa {step}: {title}",
|
||||
mcpAuthPreviousStep: "Voltar",
|
||||
mcpAuthNextStep: "Avançar",
|
||||
mcpAuthStepProgress: "Etapa {current} de {total}",
|
||||
mcpPerPage: "Por página",
|
||||
mcpPreviousPage: "Página anterior",
|
||||
mcpNextPage: "Próxima página",
|
||||
mcpDatabaseScopeTitle: "Conexões e escopo de bancos de dados",
|
||||
mcpDatabaseScopeDescription: "Selecione à esquerda uma conexão exposta ao MCP e depois limite os bancos que ela pode acessar. Os nomes dos bancos são comparados de forma exata.",
|
||||
mcpDatabaseScopeEmptyHint: "Permita primeiro pelo menos uma conexão no 'Escopo de conexões'.",
|
||||
mcpDatabaseScopeAllowedConnections: "Conexões permitidas",
|
||||
mcpDatabaseScopeSummaryAll: "Todos os bancos",
|
||||
mcpDatabaseScopeSummaryNone: "Sem acesso a bancos",
|
||||
mcpDatabaseScopeSummarySelected: "{count} selecionados",
|
||||
mcpDatabaseScopePermissionNote: "O escopo selecionado não altera as permissões de bancos que a conexão já tem no DBX.",
|
||||
mcpDatabaseLoadButton: "Carregar da conexão",
|
||||
mcpDatabaseScopeAriaLabel: "Escopo de acesso aos bancos",
|
||||
mcpDatabaseScopeAllTitle: "Todos os bancos de dados",
|
||||
mcpDatabaseScopeAllDescription: "Mantém o escopo de acesso atual",
|
||||
mcpDatabaseScopeSelectedTitle: "Somente bancos especificados",
|
||||
mcpDatabaseScopeSelectedDescription: "Expõe apenas os itens marcados",
|
||||
mcpDatabaseScopeNoneTitle: "Sem acesso",
|
||||
mcpDatabaseScopeNoneDescription: "Mantém as regras, mas recusa todos os bancos",
|
||||
mcpDatabaseScopeSelectedSummary: "{count} bancos permitidos. Bancos não marcados e solicitações entre bancos são recusados pelo servidor MCP.",
|
||||
mcpDatabaseSearchPlaceholder: "Filtrar bancos carregados",
|
||||
mcpDatabaseManualPlaceholder: "Adicionar nome do banco manualmente",
|
||||
mcpDatabaseLoadFailed: "Falha ao carregar bancos: {error}. Você pode adicionar manualmente os nomes exatos.",
|
||||
mcpDatabaseEmptyHint: "Clique em 'Carregar da conexão' para obter a lista de bancos ou adicione manualmente os permitidos.",
|
||||
mcpTransportLocalStdio: "stdio local",
|
||||
mcpTransportHttpService: "Serviço HTTP",
|
||||
mcpHttpWebServiceTitle: "Serviço Web Streamable HTTP",
|
||||
mcpHttpWebServiceDescription: "O Web/Docker reutiliza a porta de escuta web atual. É ativado por variáveis de ambiente de implantação; a página de configurações nunca salva o Token.",
|
||||
mcpHttpStatusLabelEnabled: "Ativado",
|
||||
mcpHttpStatusLabelDisabled: "Desativado",
|
||||
mcpHttpNotConfigured: "Não configurado",
|
||||
mcpHttpWebTokenSourceAndHosts: "Origem do Token: {tokenSource}; Hosts permitidos: {hosts}",
|
||||
mcpHttpWebAllowedOrigins: "Origins permitidos: {origins}",
|
||||
mcpHttpReloadStatus: "Recarregar status",
|
||||
mcpHttpServiceTitle: "Serviço Streamable HTTP",
|
||||
mcpHttpStatusLabelRunning: "Em execução",
|
||||
mcpHttpStatusLabelPending: "Pendente de aplicação",
|
||||
mcpHttpServiceDescription: "Desativado por padrão. Quando ativado, o DBX Desktop hospeda o serviço; a configuração acima só é aplicada ao serviço de escuta real após salvar.",
|
||||
mcpHttpListenerTitle: "Configuração de escuta",
|
||||
mcpHttpListenerHintPrefix: "Recomendamos manter ",
|
||||
mcpHttpListenerHintSuffix: " para permitir apenas clientes MCP do mesmo computador.",
|
||||
mcpHttpHostLabel: "Endereço de escuta",
|
||||
mcpHttpPortLabel: "Porta",
|
||||
mcpHttpPathLabel: "Caminho MCP",
|
||||
mcpHttpRemoteTitle: "Acesso remoto",
|
||||
mcpHttpRemoteHintPrefix: "Necessário apenas quando o endereço de escuta não é ",
|
||||
mcpHttpRemoteHintMiddle: " ou ",
|
||||
mcpHttpRemoteHintSuffix: ". Ao ativar, é obrigatório preencher as listas de permissão de Host e Origin.",
|
||||
mcpHttpAllowedHostsLabel: "Hosts permitidos",
|
||||
mcpHttpHostsHint: "Um por linha, ou separados por vírgulas.",
|
||||
mcpHttpAllowedOriginsLabel: "Origins permitidos",
|
||||
mcpHttpOriginsHintPrefix: "Deve incluir o protocolo, por exemplo ",
|
||||
mcpHttpOriginsHintSuffix: ".",
|
||||
mcpHttpRemoteDisabledHint: "Com o acesso remoto desativado, as listas de permissão não são necessárias.",
|
||||
mcpHttpUnsavedChangesHint: "A configuração acima ainda não foi salva; o serviço em execução não mudará imediatamente.",
|
||||
mcpHttpSaveHint: "Ao salvar, o serviço é iniciado ou reiniciado e as informações de conexão abaixo são atualizadas.",
|
||||
mcpHttpReload: "Recarregar",
|
||||
mcpHttpSaveAndStart: "Salvar e iniciar serviço",
|
||||
mcpHttpDisabledHint: "Ative o serviço para configurar endereço de escuta, listas de acesso remoto e informações de conexão do cliente.",
|
||||
mcpHttpSaveAndStop: "Salvar e parar serviço",
|
||||
mcpHttpConnectionInfoTitle: "Informações de conexão do cliente em vigor",
|
||||
mcpHttpServiceNotRunning: "O serviço não está em execução",
|
||||
mcpHttpRunningConfigBadge: "Configuração em execução",
|
||||
mcpHttpLastStatusBadge: "Último status",
|
||||
mcpHttpRunningDraftDiffersHint: "Abaixo estão as informações de conexão do serviço em execução; elas podem diferir do rascunho não salvo acima.",
|
||||
mcpHttpEndpointLabel: "Endereço do serviço",
|
||||
mcpHttpRotateToken: "Rotacionar Token",
|
||||
mcpHttpRotateTokenConfirm: "Após rotacionar o Token, clientes MCP que usam o Token antigo serão desconectados imediatamente. Continuar?",
|
||||
mcpHttpTokenHintPrefix: "Use este valor como a credencial HTTP ",
|
||||
mcpHttpTokenHintSuffix: " do cliente.",
|
||||
mcpHttpClientConfigTitle: "Configuração genérica de cliente HTTP",
|
||||
mcpHttpClientConfigHint: "Os nomes dos campos variam por cliente; mantenha os valores de URL e Authorization.",
|
||||
mcpHttpLogsTitle: "Logs de diagnóstico do serviço",
|
||||
mcpHttpValidationHostRequired: "Informe um endereço de escuta.",
|
||||
mcpHttpValidationPortRange: "A porta deve ser um inteiro entre 1 e 65535.",
|
||||
mcpHttpValidationPathPrefix: "O caminho MCP deve começar com /.",
|
||||
mcpHttpValidationRemoteRequired: "Ative o 'Acesso remoto' para escutar em um endereço não local.",
|
||||
mcpHttpValidationHostsRequired: "O acesso remoto exige pelo menos um Host permitido.",
|
||||
mcpHttpValidationOriginsRequired: "O acesso remoto exige pelo menos um Origin permitido.",
|
||||
mcpHttpErrorRemoteHostsAndOrigins: "O acesso remoto exige Hosts e Origins permitidos preenchidos.",
|
||||
mcpHttpErrorRemoteBinding: "A escuta remota exige acesso remoto ativado e listas de permissão de Host e Origin.",
|
||||
mcpHttpErrorPortInUse: "A porta já está em uso por outro aplicativo. O DBX reinicia o próprio serviço MCP ao salvar; feche o outro aplicativo que usa a porta ou escolha outra porta.",
|
||||
mcpConnectionPolicyTitle: "Limite de permissões por conexão",
|
||||
mcpConnectionPolicyDescription: "Restringe mais ainda as permissões de cada conexão exposta; elas não podem exceder as permissões globais do MCP.",
|
||||
mcpConnectionPolicyEmptyHint: "Permita primeiro pelo menos uma conexão na etapa 1 para configurar limites de execução por conexão.",
|
||||
mcpConnectionPolicyInherit: "Herdar do global",
|
||||
mcpConnectionPolicyReadOnly: "Somente leitura",
|
||||
mcpConnectionPolicySafeWrite: "Permitir escritas seguras",
|
||||
mcpConnectionPolicyHighRiskWrite: "Permitir operações de alto risco",
|
||||
mcpToolPermissionsTitle: "Permissões de ferramentas MCP",
|
||||
mcpToolPermissionsDescription: "Exponha apenas as ferramentas MCP necessárias. Ao desmarcá-las, o servidor recusa as solicitações mesmo que o cliente tenha as definições em cache.",
|
||||
mcpToolListConnections: "Listar conexões",
|
||||
mcpToolListDatabases: "Listar bancos de dados",
|
||||
mcpToolListTables: "Listar tabelas",
|
||||
mcpToolDescribeTable: "Estrutura da tabela",
|
||||
mcpToolGetSchemaContext: "Contexto do Schema",
|
||||
mcpToolExecuteQuery: "Executar SQL / comandos Mongo",
|
||||
mcpToolOpenSession: "Abrir sessão de consulta",
|
||||
mcpToolCloseSession: "Fechar sessão de consulta",
|
||||
mcpToolExecuteRedisCommand: "Executar comando Redis",
|
||||
mcpToolSendMessage: "Enviar mensagem da fila",
|
||||
mcpToolAddConnection: "Adicionar conexão",
|
||||
mcpToolDuplicateConnection: "Duplicar conexão",
|
||||
mcpToolRemoveConnection: "Remover conexão",
|
||||
mcpToolOpenTable: "Abrir tabela no DBX",
|
||||
mcpToolExecuteAndShow: "Executar e mostrar no DBX",
|
||||
supportInfoTitle: "Informações de suporte",
|
||||
supportInfoDescription: "Copie estes detalhes do ambiente ao abrir issues ou pedir ajuda.",
|
||||
supportInfoCopy: "Copiar informações",
|
||||
|
||||
@@ -6955,6 +6955,127 @@ export default withEnglishFallback({
|
||||
mcpNpmBoundary: "DBX 负责检查 MCP 状态;安装、升级和卸载由检测到的 npm 或 pnpm 运行环境执行。",
|
||||
mcpRefresh: "重新检查",
|
||||
mcpGuide: "MCP 指南",
|
||||
mcpAuthTitle: "MCP 授权设置",
|
||||
mcpAuthDescription: "按顺序完成授权:先选择连接,再限定数据库和连接例外,最后确定可用工具与全局执行级别。",
|
||||
mcpAuthStepsLabel: "MCP 授权步骤",
|
||||
mcpAuthStepConnectionsTitle: "连接范围",
|
||||
mcpAuthStepConnectionsDescription: "决定哪些连接可被 MCP 看见",
|
||||
mcpAuthStepDatabasesTitle: "数据库范围",
|
||||
mcpAuthStepDatabasesDescription: "为每个连接限定可访问的库",
|
||||
mcpAuthStepOverridesTitle: "连接例外",
|
||||
mcpAuthStepOverridesDescription: "按连接进一步收紧执行权限",
|
||||
mcpAuthStepCapabilitiesTitle: "能力与执行",
|
||||
mcpAuthStepCapabilitiesDescription: "选择 MCP 工具和全局操作级别",
|
||||
mcpAuthStepRegionLabel: "第 {step} 步:{title}",
|
||||
mcpAuthPreviousStep: "上一步",
|
||||
mcpAuthNextStep: "下一步",
|
||||
mcpAuthStepProgress: "第 {current} 步,共 {total} 步",
|
||||
mcpPerPage: "每页",
|
||||
mcpPreviousPage: "上一页",
|
||||
mcpNextPage: "下一页",
|
||||
mcpDatabaseScopeTitle: "连接与数据库范围",
|
||||
mcpDatabaseScopeDescription: "先在左侧选择已暴露给 MCP 的连接,再限定该连接可访问的数据库。数据库名称按精确名称匹配。",
|
||||
mcpDatabaseScopeEmptyHint: "请先在“连接范围”中允许至少一个连接。",
|
||||
mcpDatabaseScopeAllowedConnections: "已允许的连接",
|
||||
mcpDatabaseScopeSummaryAll: "全部库",
|
||||
mcpDatabaseScopeSummaryNone: "无库访问",
|
||||
mcpDatabaseScopeSummarySelected: "已选 {count} 个库",
|
||||
mcpDatabaseScopePermissionNote: "选择范围不会改变此连接在 DBX 中原有的数据库权限。",
|
||||
mcpDatabaseLoadButton: "从连接加载",
|
||||
mcpDatabaseScopeAriaLabel: "数据库访问范围",
|
||||
mcpDatabaseScopeAllTitle: "全部数据库",
|
||||
mcpDatabaseScopeAllDescription: "保持现有访问范围",
|
||||
mcpDatabaseScopeSelectedTitle: "仅指定数据库",
|
||||
mcpDatabaseScopeSelectedDescription: "只开放勾选项",
|
||||
mcpDatabaseScopeNoneTitle: "不允许访问",
|
||||
mcpDatabaseScopeNoneDescription: "保留规则但拒绝所有库",
|
||||
mcpDatabaseScopeSelectedSummary: "已允许 {count} 个数据库。未勾选数据库及跨库请求会被 MCP 服务端拒绝。",
|
||||
mcpDatabaseSearchPlaceholder: "筛选已加载的数据库",
|
||||
mcpDatabaseManualPlaceholder: "手工添加库名",
|
||||
mcpDatabaseLoadFailed: "加载数据库失败:{error}。可手工添加准确的数据库名称。",
|
||||
mcpDatabaseEmptyHint: "点击“从连接加载”获取数据库列表,或手工添加允许的数据库。",
|
||||
mcpTransportLocalStdio: "本地 stdio",
|
||||
mcpTransportHttpService: "HTTP 服务",
|
||||
mcpHttpWebServiceTitle: "Web Streamable HTTP 服务",
|
||||
mcpHttpWebServiceDescription: "Web/Docker 复用当前 Web 监听端口。通过部署环境变量启用,设置页不会保存 Token。",
|
||||
mcpHttpStatusLabelEnabled: "已启用",
|
||||
mcpHttpStatusLabelDisabled: "未启用",
|
||||
mcpHttpNotConfigured: "未配置",
|
||||
mcpHttpWebTokenSourceAndHosts: "Token 来源:{tokenSource};允许 Host:{hosts}",
|
||||
mcpHttpWebAllowedOrigins: "允许 Origin:{origins}",
|
||||
mcpHttpReloadStatus: "重新加载状态",
|
||||
mcpHttpServiceTitle: "Streamable HTTP 服务",
|
||||
mcpHttpStatusLabelRunning: "运行中",
|
||||
mcpHttpStatusLabelPending: "待应用配置",
|
||||
mcpHttpServiceDescription: "默认关闭。启用后由 DBX Desktop 托管服务;保存后才会将上方配置应用到实际监听服务。",
|
||||
mcpHttpListenerTitle: "监听配置",
|
||||
mcpHttpListenerHintPrefix: "推荐保留 ",
|
||||
mcpHttpListenerHintSuffix: ",仅允许同一台电脑上的 MCP 客户端连接。",
|
||||
mcpHttpHostLabel: "监听地址",
|
||||
mcpHttpPortLabel: "端口",
|
||||
mcpHttpPathLabel: "MCP 路径",
|
||||
mcpHttpRemoteTitle: "远程访问",
|
||||
mcpHttpRemoteHintPrefix: "仅当监听地址不是 ",
|
||||
mcpHttpRemoteHintMiddle: " 或 ",
|
||||
mcpHttpRemoteHintSuffix: " 时需要。开启后,必须填写 Host 和 Origin 白名单。",
|
||||
mcpHttpAllowedHostsLabel: "允许的 Host",
|
||||
mcpHttpHostsHint: "每行一个,也可用逗号分隔。",
|
||||
mcpHttpAllowedOriginsLabel: "允许的 Origin",
|
||||
mcpHttpOriginsHintPrefix: "必须包含协议,例如 ",
|
||||
mcpHttpOriginsHintSuffix: "。",
|
||||
mcpHttpRemoteDisabledHint: "未开启远程访问时,白名单无需配置。",
|
||||
mcpHttpUnsavedChangesHint: "上方配置尚未保存,当前运行服务不会立即改变。",
|
||||
mcpHttpSaveHint: "保存后会启动或重启服务,并更新下方接入信息。",
|
||||
mcpHttpReload: "重新加载",
|
||||
mcpHttpSaveAndStart: "保存并启动服务",
|
||||
mcpHttpDisabledHint: "启用服务后可配置监听地址、远程访问白名单和客户端接入信息。",
|
||||
mcpHttpSaveAndStop: "保存并停止服务",
|
||||
mcpHttpConnectionInfoTitle: "已生效的客户端接入信息",
|
||||
mcpHttpServiceNotRunning: "服务当前未运行",
|
||||
mcpHttpRunningConfigBadge: "运行配置",
|
||||
mcpHttpLastStatusBadge: "最近一次状态",
|
||||
mcpHttpRunningDraftDiffersHint: "下方是当前运行服务的接入信息;它与上方未保存的草稿可能不同。",
|
||||
mcpHttpEndpointLabel: "服务地址",
|
||||
mcpHttpRotateToken: "轮换 Token",
|
||||
mcpHttpRotateTokenConfirm: "轮换 Token 后,使用旧 Token 的 MCP 客户端会立即断开。是否继续?",
|
||||
mcpHttpTokenHintPrefix: "将此值作为客户端的 HTTP ",
|
||||
mcpHttpTokenHintSuffix: " 凭证。",
|
||||
mcpHttpClientConfigTitle: "通用 HTTP 客户端配置",
|
||||
mcpHttpClientConfigHint: "客户端字段名称不同,请保留 URL 与 Authorization 值。",
|
||||
mcpHttpLogsTitle: "服务诊断日志",
|
||||
mcpHttpValidationHostRequired: "请填写监听地址。",
|
||||
mcpHttpValidationPortRange: "端口必须是 1 到 65535 之间的整数。",
|
||||
mcpHttpValidationPathPrefix: "MCP 路径必须以 / 开头。",
|
||||
mcpHttpValidationRemoteRequired: "监听非本机地址时,请开启“允许远程访问”。",
|
||||
mcpHttpValidationHostsRequired: "远程访问需要至少填写一个允许的 Host。",
|
||||
mcpHttpValidationOriginsRequired: "远程访问需要至少填写一个允许的 Origin。",
|
||||
mcpHttpErrorRemoteHostsAndOrigins: "远程访问需要同时填写允许的 Host 和允许的 Origin。",
|
||||
mcpHttpErrorRemoteBinding: "远程监听需要开启远程访问,并设置 Host 和 Origin 白名单。",
|
||||
mcpHttpErrorPortInUse: "该端口已被其他应用占用。DBX 会在保存时自动重启自己的 MCP 服务;请关闭占用该端口的其他应用,或改用其他端口。",
|
||||
mcpConnectionPolicyTitle: "连接级权限上限",
|
||||
mcpConnectionPolicyDescription: "可针对每个已暴露连接进一步收紧权限;不能超过全局 MCP 权限。",
|
||||
mcpConnectionPolicyEmptyHint: "请先在第 1 步中允许至少一个连接,才能设置连接级执行上限。",
|
||||
mcpConnectionPolicyInherit: "继承全局",
|
||||
mcpConnectionPolicyReadOnly: "仅只读",
|
||||
mcpConnectionPolicySafeWrite: "允许安全写入",
|
||||
mcpConnectionPolicyHighRiskWrite: "允许高风险操作",
|
||||
mcpToolPermissionsTitle: "MCP 工具权限",
|
||||
mcpToolPermissionsDescription: "只暴露当前需要的 MCP 工具。取消选择后,客户端即使缓存了工具定义也会被服务端拒绝。",
|
||||
mcpToolListConnections: "列出连接",
|
||||
mcpToolListDatabases: "列出数据库",
|
||||
mcpToolListTables: "列出表",
|
||||
mcpToolDescribeTable: "表结构",
|
||||
mcpToolGetSchemaContext: "Schema 上下文",
|
||||
mcpToolExecuteQuery: "执行 SQL / Mongo 命令",
|
||||
mcpToolOpenSession: "打开查询会话",
|
||||
mcpToolCloseSession: "关闭查询会话",
|
||||
mcpToolExecuteRedisCommand: "执行 Redis 命令",
|
||||
mcpToolSendMessage: "发送消息队列消息",
|
||||
mcpToolAddConnection: "新增连接",
|
||||
mcpToolDuplicateConnection: "复制连接",
|
||||
mcpToolRemoveConnection: "删除连接",
|
||||
mcpToolOpenTable: "在 DBX 中打开表",
|
||||
mcpToolExecuteAndShow: "在 DBX 中执行并展示",
|
||||
supportInfoTitle: "支持信息",
|
||||
supportInfoDescription: "提交 issue 或寻求帮助时,可复制这些环境信息。",
|
||||
supportInfoCopy: "复制支持信息",
|
||||
|
||||
@@ -5935,6 +5935,127 @@ export default withEnglishFallback({
|
||||
mcpNpmBoundary: "DBX 只檢測和提示 MCP 狀態;安裝與升級仍由 npm 完成。",
|
||||
mcpRefresh: "重新檢查",
|
||||
mcpGuide: "MCP 指南",
|
||||
mcpAuthTitle: "MCP 授權設定",
|
||||
mcpAuthDescription: "按順序完成授權:先選擇連線,再限定資料庫和連線例外,最後確定可用工具與全域執行層級。",
|
||||
mcpAuthStepsLabel: "MCP 授權步驟",
|
||||
mcpAuthStepConnectionsTitle: "連線範圍",
|
||||
mcpAuthStepConnectionsDescription: "決定哪些連線可被 MCP 看見",
|
||||
mcpAuthStepDatabasesTitle: "資料庫範圍",
|
||||
mcpAuthStepDatabasesDescription: "為每個連線限定可存取的庫",
|
||||
mcpAuthStepOverridesTitle: "連線例外",
|
||||
mcpAuthStepOverridesDescription: "按連線進一步收緊執行權限",
|
||||
mcpAuthStepCapabilitiesTitle: "能力與執行",
|
||||
mcpAuthStepCapabilitiesDescription: "選擇 MCP 工具和全域操作層級",
|
||||
mcpAuthStepRegionLabel: "第 {step} 步:{title}",
|
||||
mcpAuthPreviousStep: "上一步",
|
||||
mcpAuthNextStep: "下一步",
|
||||
mcpAuthStepProgress: "第 {current} 步,共 {total} 步",
|
||||
mcpPerPage: "每頁",
|
||||
mcpPreviousPage: "上一頁",
|
||||
mcpNextPage: "下一頁",
|
||||
mcpDatabaseScopeTitle: "連線與資料庫範圍",
|
||||
mcpDatabaseScopeDescription: "先在左側選擇已暴露給 MCP 的連線,再限定該連線可存取的資料庫。資料庫名稱按精確名稱比對。",
|
||||
mcpDatabaseScopeEmptyHint: "請先在「連線範圍」中允許至少一個連線。",
|
||||
mcpDatabaseScopeAllowedConnections: "已允許的連線",
|
||||
mcpDatabaseScopeSummaryAll: "全部庫",
|
||||
mcpDatabaseScopeSummaryNone: "無庫存取",
|
||||
mcpDatabaseScopeSummarySelected: "已選 {count} 個庫",
|
||||
mcpDatabaseScopePermissionNote: "選擇範圍不會改變此連線在 DBX 中原有的資料庫權限。",
|
||||
mcpDatabaseLoadButton: "從連線載入",
|
||||
mcpDatabaseScopeAriaLabel: "資料庫存取範圍",
|
||||
mcpDatabaseScopeAllTitle: "全部資料庫",
|
||||
mcpDatabaseScopeAllDescription: "保持現有存取範圍",
|
||||
mcpDatabaseScopeSelectedTitle: "僅指定資料庫",
|
||||
mcpDatabaseScopeSelectedDescription: "只開放勾選項",
|
||||
mcpDatabaseScopeNoneTitle: "不允許存取",
|
||||
mcpDatabaseScopeNoneDescription: "保留規則但拒絕所有庫",
|
||||
mcpDatabaseScopeSelectedSummary: "已允許 {count} 個資料庫。未勾選資料庫及跨庫請求會被 MCP 服務端拒絕。",
|
||||
mcpDatabaseSearchPlaceholder: "篩選已載入的資料庫",
|
||||
mcpDatabaseManualPlaceholder: "手動新增庫名",
|
||||
mcpDatabaseLoadFailed: "載入資料庫失敗:{error}。可手動新增準確的資料庫名稱。",
|
||||
mcpDatabaseEmptyHint: "點擊「從連線載入」取得資料庫清單,或手動新增允許的資料庫。",
|
||||
mcpTransportLocalStdio: "本機 stdio",
|
||||
mcpTransportHttpService: "HTTP 服務",
|
||||
mcpHttpWebServiceTitle: "Web Streamable HTTP 服務",
|
||||
mcpHttpWebServiceDescription: "Web/Docker 重用目前 Web 監聽埠。透過部署環境變數啟用,設定頁不會儲存 Token。",
|
||||
mcpHttpStatusLabelEnabled: "已啟用",
|
||||
mcpHttpStatusLabelDisabled: "未啟用",
|
||||
mcpHttpNotConfigured: "未設定",
|
||||
mcpHttpWebTokenSourceAndHosts: "Token 來源:{tokenSource};允許 Host:{hosts}",
|
||||
mcpHttpWebAllowedOrigins: "允許 Origin:{origins}",
|
||||
mcpHttpReloadStatus: "重新載入狀態",
|
||||
mcpHttpServiceTitle: "Streamable HTTP 服務",
|
||||
mcpHttpStatusLabelRunning: "執行中",
|
||||
mcpHttpStatusLabelPending: "待套用設定",
|
||||
mcpHttpServiceDescription: "預設關閉。啟用後由 DBX Desktop 託管服務;儲存後才會將上方設定套用到實際監聽服務。",
|
||||
mcpHttpListenerTitle: "監聽設定",
|
||||
mcpHttpListenerHintPrefix: "建議保留 ",
|
||||
mcpHttpListenerHintSuffix: ",僅允許同一台電腦上的 MCP 用戶端連線。",
|
||||
mcpHttpHostLabel: "監聽位址",
|
||||
mcpHttpPortLabel: "埠",
|
||||
mcpHttpPathLabel: "MCP 路徑",
|
||||
mcpHttpRemoteTitle: "遠端存取",
|
||||
mcpHttpRemoteHintPrefix: "僅當監聽位址不是 ",
|
||||
mcpHttpRemoteHintMiddle: " 或 ",
|
||||
mcpHttpRemoteHintSuffix: " 時需要。開啟後,必須填寫 Host 和 Origin 白名單。",
|
||||
mcpHttpAllowedHostsLabel: "允許的 Host",
|
||||
mcpHttpHostsHint: "每行一個,也可用逗號分隔。",
|
||||
mcpHttpAllowedOriginsLabel: "允許的 Origin",
|
||||
mcpHttpOriginsHintPrefix: "必須包含協定,例如 ",
|
||||
mcpHttpOriginsHintSuffix: "。",
|
||||
mcpHttpRemoteDisabledHint: "未開啟遠端存取時,白名單無需設定。",
|
||||
mcpHttpUnsavedChangesHint: "上方設定尚未儲存,目前執行服務不會立即改變。",
|
||||
mcpHttpSaveHint: "儲存後會啟動或重新啟動服務,並更新下方接入資訊。",
|
||||
mcpHttpReload: "重新載入",
|
||||
mcpHttpSaveAndStart: "儲存並啟動服務",
|
||||
mcpHttpDisabledHint: "啟用服務後可設定監聽位址、遠端存取白名單和用戶端接入資訊。",
|
||||
mcpHttpSaveAndStop: "儲存並停止服務",
|
||||
mcpHttpConnectionInfoTitle: "已生效的用戶端接入資訊",
|
||||
mcpHttpServiceNotRunning: "服務目前未執行",
|
||||
mcpHttpRunningConfigBadge: "執行設定",
|
||||
mcpHttpLastStatusBadge: "最近一次狀態",
|
||||
mcpHttpRunningDraftDiffersHint: "下方是目前執行服務的接入資訊;它與上方未儲存的草稿可能不同。",
|
||||
mcpHttpEndpointLabel: "服務位址",
|
||||
mcpHttpRotateToken: "輪換 Token",
|
||||
mcpHttpRotateTokenConfirm: "輪換 Token 後,使用舊 Token 的 MCP 用戶端會立即斷線。是否繼續?",
|
||||
mcpHttpTokenHintPrefix: "將此值作為用戶端的 HTTP ",
|
||||
mcpHttpTokenHintSuffix: " 憑證。",
|
||||
mcpHttpClientConfigTitle: "通用 HTTP 用戶端設定",
|
||||
mcpHttpClientConfigHint: "用戶端欄位名稱不同,請保留 URL 與 Authorization 值。",
|
||||
mcpHttpLogsTitle: "服務診斷日誌",
|
||||
mcpHttpValidationHostRequired: "請填寫監聽位址。",
|
||||
mcpHttpValidationPortRange: "埠必須是 1 到 65535 之間的整數。",
|
||||
mcpHttpValidationPathPrefix: "MCP 路徑必須以 / 開頭。",
|
||||
mcpHttpValidationRemoteRequired: "監聽非本機位址時,請開啟「允許遠端存取」。",
|
||||
mcpHttpValidationHostsRequired: "遠端存取需要至少填寫一個允許的 Host。",
|
||||
mcpHttpValidationOriginsRequired: "遠端存取需要至少填寫一個允許的 Origin。",
|
||||
mcpHttpErrorRemoteHostsAndOrigins: "遠端存取需要同時填寫允許的 Host 和允許的 Origin。",
|
||||
mcpHttpErrorRemoteBinding: "遠端監聽需要開啟遠端存取,並設定 Host 和 Origin 白名單。",
|
||||
mcpHttpErrorPortInUse: "該埠已被其他應用程式佔用。DBX 會在儲存時自動重新啟動自己的 MCP 服務;請關閉佔用該埠的其他應用程式,或改用其他埠。",
|
||||
mcpConnectionPolicyTitle: "連線級權限上限",
|
||||
mcpConnectionPolicyDescription: "可針對每個已暴露連線進一步收緊權限;不能超過全域 MCP 權限。",
|
||||
mcpConnectionPolicyEmptyHint: "請先在第 1 步中允許至少一個連線,才能設定連線級執行上限。",
|
||||
mcpConnectionPolicyInherit: "繼承全域",
|
||||
mcpConnectionPolicyReadOnly: "僅唯讀",
|
||||
mcpConnectionPolicySafeWrite: "允許安全寫入",
|
||||
mcpConnectionPolicyHighRiskWrite: "允許高風險操作",
|
||||
mcpToolPermissionsTitle: "MCP 工具權限",
|
||||
mcpToolPermissionsDescription: "只暴露目前需要的 MCP 工具。取消選擇後,用戶端即使快取了工具定義也會被服務端拒絕。",
|
||||
mcpToolListConnections: "列出連線",
|
||||
mcpToolListDatabases: "列出資料庫",
|
||||
mcpToolListTables: "列出資料表",
|
||||
mcpToolDescribeTable: "資料表結構",
|
||||
mcpToolGetSchemaContext: "Schema 上下文",
|
||||
mcpToolExecuteQuery: "執行 SQL / Mongo 命令",
|
||||
mcpToolOpenSession: "開啟查詢工作階段",
|
||||
mcpToolCloseSession: "關閉查詢工作階段",
|
||||
mcpToolExecuteRedisCommand: "執行 Redis 命令",
|
||||
mcpToolSendMessage: "傳送訊息佇列訊息",
|
||||
mcpToolAddConnection: "新增連線",
|
||||
mcpToolDuplicateConnection: "複製連線",
|
||||
mcpToolRemoveConnection: "刪除連線",
|
||||
mcpToolOpenTable: "在 DBX 中開啟資料表",
|
||||
mcpToolExecuteAndShow: "在 DBX 中執行並顯示",
|
||||
updateDownloadSource: "更新下載來源",
|
||||
updateDownloadSourceDescription: "選擇應用程式內更新安裝程式的下載來源。建議使用官方來源;CNB 在中國大陸網路環境下可能更快。",
|
||||
updateDownloadSourceOfficial: "官方來源(建議)",
|
||||
|
||||
@@ -808,6 +808,11 @@ export const deleteHistoryEntry = forward("deleteHistoryEntry");
|
||||
export const checkMcpServerStatus = forward("checkMcpServerStatus");
|
||||
export const installMcpServer = forward("installMcpServer");
|
||||
export const uninstallMcpServer = forward("uninstallMcpServer");
|
||||
export const loadMcpHttpServerSettings = forward("loadMcpHttpServerSettings");
|
||||
export const saveMcpHttpServerSettings = forward("saveMcpHttpServerSettings");
|
||||
export const mcpHttpServerStatus = forward("mcpHttpServerStatus");
|
||||
export const rotateMcpHttpServerToken = forward("rotateMcpHttpServerToken");
|
||||
export const loadWebMcpHttpStatus = forward("loadWebMcpHttpStatus");
|
||||
export const checkForUpdates = forward("checkForUpdates");
|
||||
export const fetchChangelog = forward("fetchChangelog");
|
||||
export const getSystemProxyUrl = forward("getSystemProxyUrl");
|
||||
@@ -867,6 +872,9 @@ export type {
|
||||
SnippetDownloadResult,
|
||||
SnippetTokenStatus,
|
||||
McpServerStatus,
|
||||
McpHttpServerSettings,
|
||||
McpHttpServerStatus,
|
||||
WebMcpHttpStatus,
|
||||
UpdateInfo,
|
||||
RedisBlob,
|
||||
RedisCollectionPage,
|
||||
|
||||
@@ -1808,6 +1808,26 @@ export async function saveMcpGlobalPolicy(policy: Omit<McpGlobalPolicy, "configu
|
||||
if (!res.ok) throw await backendResponseError(res);
|
||||
}
|
||||
|
||||
export async function loadMcpHttpServerSettings(): Promise<import("@/lib/backend/tauri").McpHttpServerSettings> {
|
||||
return { enabled: false, host: "127.0.0.1", port: 5225, path: "/mcp", allowRemote: false, allowedHosts: [], allowedOrigins: [] };
|
||||
}
|
||||
|
||||
export async function saveMcpHttpServerSettings(_settings: import("@/lib/backend/tauri").McpHttpServerSettings): Promise<import("@/lib/backend/tauri").McpHttpServerStatus> {
|
||||
throw new Error("The MCP HTTP server is available only in DBX Desktop");
|
||||
}
|
||||
|
||||
export async function mcpHttpServerStatus(): Promise<import("@/lib/backend/tauri").McpHttpServerStatus> {
|
||||
return { enabled: false, running: false, endpoint: null, accessToken: null, lastError: null, recentLogs: [] };
|
||||
}
|
||||
|
||||
export async function rotateMcpHttpServerToken(): Promise<import("@/lib/backend/tauri").McpHttpServerStatus> {
|
||||
throw new Error("The MCP HTTP server is available only in DBX Desktop");
|
||||
}
|
||||
|
||||
export async function loadWebMcpHttpStatus(): Promise<import("@/lib/backend/tauri").WebMcpHttpStatus> {
|
||||
return get("/api/app-settings/mcp-http-status");
|
||||
}
|
||||
|
||||
export async function loadMaxAgentTurns(): Promise<number> {
|
||||
return get("/api/app-settings/max-agent-turns");
|
||||
}
|
||||
|
||||
@@ -245,10 +245,21 @@ export interface McpGlobalPolicy {
|
||||
readOnly: boolean;
|
||||
allowDangerousSql: boolean;
|
||||
allowedConnectionIds: string[] | null;
|
||||
allowedToolNames: string[] | null;
|
||||
connectionPolicies: McpConnectionPolicy[];
|
||||
configured: boolean;
|
||||
queryTimeoutSecs: number | null;
|
||||
}
|
||||
|
||||
export interface McpConnectionPolicy {
|
||||
connectionId: string;
|
||||
readOnly: boolean;
|
||||
allowDangerousSql: boolean;
|
||||
executionModeConfigured: boolean;
|
||||
databaseScope: "all" | "selected" | "none";
|
||||
allowedDatabases: string[];
|
||||
}
|
||||
|
||||
export interface SavedSqlSyncEntry {
|
||||
folderName?: string;
|
||||
fileName: string;
|
||||
@@ -634,6 +645,53 @@ export async function saveMcpGlobalPolicy(policy: Omit<McpGlobalPolicy, "configu
|
||||
return invoke("save_mcp_global_policy", { policy });
|
||||
}
|
||||
|
||||
export interface McpHttpServerSettings {
|
||||
enabled: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
path: string;
|
||||
allowRemote: boolean;
|
||||
allowedHosts: string[];
|
||||
allowedOrigins: string[];
|
||||
}
|
||||
|
||||
export interface McpHttpServerStatus {
|
||||
enabled: boolean;
|
||||
running: boolean;
|
||||
endpoint: string | null;
|
||||
accessToken: string | null;
|
||||
lastError: string | null;
|
||||
recentLogs: string[];
|
||||
}
|
||||
|
||||
export interface WebMcpHttpStatus {
|
||||
enabled: boolean;
|
||||
endpointPath: string;
|
||||
tokenSource: "environment" | "file" | null;
|
||||
allowedHosts: string[];
|
||||
allowedOrigins: string[];
|
||||
}
|
||||
|
||||
export async function loadMcpHttpServerSettings(): Promise<McpHttpServerSettings> {
|
||||
return invoke("load_mcp_http_server_settings");
|
||||
}
|
||||
|
||||
export async function saveMcpHttpServerSettings(settings: McpHttpServerSettings): Promise<McpHttpServerStatus> {
|
||||
return invoke("save_mcp_http_server_settings", { settings });
|
||||
}
|
||||
|
||||
export async function mcpHttpServerStatus(): Promise<McpHttpServerStatus> {
|
||||
return invoke("mcp_http_server_status");
|
||||
}
|
||||
|
||||
export async function rotateMcpHttpServerToken(): Promise<McpHttpServerStatus> {
|
||||
return invoke("rotate_mcp_http_server_token");
|
||||
}
|
||||
|
||||
export async function loadWebMcpHttpStatus(): Promise<WebMcpHttpStatus> {
|
||||
return { enabled: false, endpointPath: "/mcp", tokenSource: null, allowedHosts: [], allowedOrigins: [] };
|
||||
}
|
||||
|
||||
export async function loadMaxAgentTurns(): Promise<number> {
|
||||
return invoke("load_max_agent_turns");
|
||||
}
|
||||
|
||||
@@ -432,6 +432,8 @@ describe("normalizeMcpGlobalPolicy", () => {
|
||||
readOnly: false,
|
||||
allowDangerousSql: false,
|
||||
allowedConnectionIds: null,
|
||||
allowedToolNames: null,
|
||||
connectionPolicies: [],
|
||||
configured: false,
|
||||
queryTimeoutSecs: null,
|
||||
});
|
||||
@@ -449,6 +451,8 @@ describe("normalizeMcpGlobalPolicy", () => {
|
||||
readOnly: true,
|
||||
allowDangerousSql: true,
|
||||
allowedConnectionIds: ["connection-1", "connection-2"],
|
||||
allowedToolNames: null,
|
||||
connectionPolicies: [],
|
||||
configured: true,
|
||||
queryTimeoutSecs: null,
|
||||
});
|
||||
@@ -675,6 +679,8 @@ describe("settingsStore MCP policy persistence", () => {
|
||||
readOnly: true,
|
||||
allowDangerousSql: false,
|
||||
allowedConnectionIds: ["connection-1"],
|
||||
allowedToolNames: null,
|
||||
connectionPolicies: [],
|
||||
configured: true,
|
||||
queryTimeoutSecs: null,
|
||||
};
|
||||
@@ -688,6 +694,8 @@ describe("settingsStore MCP policy persistence", () => {
|
||||
readOnly: false,
|
||||
allowDangerousSql: false,
|
||||
allowedConnectionIds: [],
|
||||
allowedToolNames: null,
|
||||
connectionPolicies: [],
|
||||
configured: true,
|
||||
queryTimeoutSecs: null,
|
||||
});
|
||||
|
||||
@@ -50,11 +50,22 @@ export interface McpGlobalPolicy {
|
||||
readOnly: boolean;
|
||||
allowDangerousSql: boolean;
|
||||
allowedConnectionIds: string[] | null;
|
||||
allowedToolNames: string[] | null;
|
||||
connectionPolicies: McpConnectionPolicy[];
|
||||
configured: boolean;
|
||||
/** MCP query timeout override in seconds. null/undefined = inherit the connection; 0 = no limit. */
|
||||
queryTimeoutSecs: number | null;
|
||||
}
|
||||
|
||||
export interface McpConnectionPolicy {
|
||||
connectionId: string;
|
||||
readOnly: boolean;
|
||||
allowDangerousSql: boolean;
|
||||
executionModeConfigured: boolean;
|
||||
databaseScope: "all" | "selected" | "none";
|
||||
allowedDatabases: string[];
|
||||
}
|
||||
|
||||
export type DesktopIconTheme = "default" | "black";
|
||||
|
||||
export type InterfaceLayout = "separated" | "classic";
|
||||
@@ -91,12 +102,29 @@ export const DEFAULT_MCP_GLOBAL_POLICY: McpGlobalPolicy = {
|
||||
readOnly: false,
|
||||
allowDangerousSql: false,
|
||||
allowedConnectionIds: null,
|
||||
allowedToolNames: null,
|
||||
connectionPolicies: [],
|
||||
configured: false,
|
||||
queryTimeoutSecs: null,
|
||||
};
|
||||
|
||||
export function normalizeMcpGlobalPolicy(policy: Partial<McpGlobalPolicy> | null | undefined): McpGlobalPolicy {
|
||||
const allowedConnectionIds = policy?.allowedConnectionIds === null || policy?.allowedConnectionIds === undefined ? null : [...new Set(policy.allowedConnectionIds.filter((id): id is string => typeof id === "string" && id.trim().length > 0).map((id) => id.trim()))];
|
||||
const allowedToolNames = policy?.allowedToolNames === null || policy?.allowedToolNames === undefined ? null : [...new Set(policy.allowedToolNames.filter((name): name is string => typeof name === "string" && name.trim().length > 0).map((name) => name.trim()))];
|
||||
const connectionPolicies = Object.values(
|
||||
(policy?.connectionPolicies ?? []).reduce<Record<string, McpConnectionPolicy>>((rules, rule) => {
|
||||
if (!rule || typeof rule.connectionId !== "string" || !rule.connectionId.trim()) return rules;
|
||||
rules[rule.connectionId.trim()] = {
|
||||
connectionId: rule.connectionId.trim(),
|
||||
readOnly: rule.readOnly === true,
|
||||
allowDangerousSql: rule.readOnly !== true && rule.allowDangerousSql === true,
|
||||
executionModeConfigured: rule.executionModeConfigured !== false,
|
||||
databaseScope: rule.databaseScope === "selected" || rule.databaseScope === "none" ? rule.databaseScope : "all",
|
||||
allowedDatabases: rule.databaseScope === "selected" ? [...new Set((rule.allowedDatabases ?? []).filter((database): database is string => typeof database === "string" && database.trim().length > 0).map((database) => database.trim()))] : [],
|
||||
};
|
||||
return rules;
|
||||
}, {}),
|
||||
);
|
||||
// null / undefined / non-positive => null (inherit connection). 0 is preserved
|
||||
// as an explicit "no limit" only here; the UI maps "no limit" <=> 0 and
|
||||
// "inherit" <=> null.
|
||||
@@ -105,6 +133,8 @@ export function normalizeMcpGlobalPolicy(policy: Partial<McpGlobalPolicy> | null
|
||||
readOnly: policy?.readOnly === true,
|
||||
allowDangerousSql: policy?.allowDangerousSql === true,
|
||||
allowedConnectionIds,
|
||||
allowedToolNames,
|
||||
connectionPolicies,
|
||||
configured: policy?.configured === true,
|
||||
queryTimeoutSecs,
|
||||
};
|
||||
@@ -1530,6 +1560,8 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
readOnly: next.readOnly,
|
||||
allowDangerousSql: next.allowDangerousSql,
|
||||
allowedConnectionIds: next.allowedConnectionIds,
|
||||
allowedToolNames: next.allowedToolNames,
|
||||
connectionPolicies: next.connectionPolicies,
|
||||
queryTimeoutSecs: next.queryTimeoutSecs,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,13 +4,26 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
const IDENTIFIER_PATTERN: &str = r"[A-Za-z0-9_@$#-]*[A-Za-z_@$#][A-Za-z0-9_@$#-]*";
|
||||
const TARGET_NAME_PATTERN: &str = r"[A-Za-z0-9_@$#-]*[A-Za-z_@$#][A-Za-z0-9_@$#-]*(?:\s*\.\s*(?:\*|[A-Za-z0-9_@$#-]*[A-Za-z_@$#][A-Za-z0-9_@$#-]*)){0,2}";
|
||||
const QUALIFIED_NAME_PATTERN: &str = r"[A-Za-z0-9_@$#-]*[A-Za-z_@$#][A-Za-z0-9_@$#-]*\s*\.\s*(?:\*|[A-Za-z0-9_@$#-]*[A-Za-z_@$#][A-Za-z0-9_@$#-]*)(?:\s*\.\s*(?:\*|[A-Za-z0-9_@$#-]*[A-Za-z_@$#][A-Za-z0-9_@$#-]*))?";
|
||||
const TARGET_NAME_PATTERN: &str = r"[A-Za-z0-9_@$#-]*[A-Za-z_@$#][A-Za-z0-9_@$#-]*(?:\s*\.\s*(?:\*|[A-Za-z0-9_@$#-]*[A-Za-z_@$#][A-Za-z0-9_@$#-]*)){0,3}";
|
||||
const QUALIFIED_NAME_PATTERN: &str = r"[A-Za-z0-9_@$#-]*[A-Za-z_@$#][A-Za-z0-9_@$#-]*(?:\s*\.\s*(?:\*|[A-Za-z0-9_@$#-]*[A-Za-z_@$#][A-Za-z0-9_@$#-]*)){1,3}";
|
||||
|
||||
static DML_TARGET_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(&format!(r"(?is)\b(?:FROM|JOIN|UPDATE|INTO|REFERENCES)\s+({TARGET_NAME_PATTERN})"))
|
||||
.expect("valid DML target regex")
|
||||
});
|
||||
static APPLY_TARGET_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(&format!(r"(?is)\b(?:CROSS|OUTER)\s+APPLY\s+({TARGET_NAME_PATTERN})")).expect("valid APPLY target regex")
|
||||
});
|
||||
static APPLY_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"(?is)\b(?:CROSS|OUTER)\s+APPLY\b").expect("valid APPLY regex"));
|
||||
static FROM_CLAUSE_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?is)\bFROM\s+(.+?)(?:\b(?:WHERE|GROUP\s+BY|HAVING|ORDER\s+BY|LIMIT|OFFSET|FETCH|FOR|UNION|EXCEPT|INTERSECT|JOIN)\b|$)")
|
||||
.expect("valid FROM clause regex")
|
||||
});
|
||||
static LEADING_TARGET_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(&format!(r"(?is)^\s*({TARGET_NAME_PATTERN})(?:\s+(?:AS\s+)?{IDENTIFIER_PATTERN})?\s*$"))
|
||||
.expect("valid leading target regex")
|
||||
});
|
||||
static DDL_OBJECT_TARGET_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(&format!(
|
||||
r"(?is)\b(?:CREATE|ALTER|DROP)\s+(?:OR\s+REPLACE\s+)?(?:TABLE|VIEW|MATERIALIZED\s+VIEW|INDEX|SEQUENCE|FUNCTION|PROCEDURE|ROUTINE|TRIGGER|EVENT|TYPE|SYNONYM)\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?(?:ONLY\s+)?({TARGET_NAME_PATTERN})"
|
||||
@@ -192,15 +205,42 @@ pub fn targets_production_database(config: &ConnectionConfig, active_database: &
|
||||
return false;
|
||||
}
|
||||
|
||||
let assessment = referenced_databases(sql, &config.db_type, active_database);
|
||||
let assessment = referenced_databases(sql, &config.db_type, active_database, false);
|
||||
assessment.databases.into_iter().any(|database| marked.contains(&database)) || assessment.uncertain
|
||||
}
|
||||
|
||||
/// Returns whether a statement references a database outside an MCP database
|
||||
/// allowlist. Unlike production protection this examines read queries too:
|
||||
/// `SELECT * FROM other_db.users` must not bypass a scope that was selected via
|
||||
/// the request's `database` argument.
|
||||
///
|
||||
/// The caller is responsible for checking the active database itself. This
|
||||
/// helper only adds protection for object references qualified in SQL.
|
||||
pub fn sql_references_disallowed_database(
|
||||
sql: &str,
|
||||
db_type: &DatabaseType,
|
||||
active_database: &str,
|
||||
allowed_databases: &[String],
|
||||
) -> bool {
|
||||
let allowed = allowed_databases
|
||||
.iter()
|
||||
.map(|database| normalize_database_name(database))
|
||||
.filter(|database| !database.is_empty())
|
||||
.collect::<HashSet<_>>();
|
||||
let assessment = referenced_databases(sql, db_type, active_database, true);
|
||||
assessment.uncertain || assessment.databases.into_iter().any(|database| !allowed.contains(&database))
|
||||
}
|
||||
|
||||
fn normalize_database_name(value: &str) -> String {
|
||||
value.trim().trim_matches(|ch| matches!(ch, '`' | '"' | '[' | ']')).to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn referenced_databases(sql: &str, db_type: &DatabaseType, active_database: &str) -> ReferencedDatabaseAssessment {
|
||||
fn referenced_databases(
|
||||
sql: &str,
|
||||
db_type: &DatabaseType,
|
||||
active_database: &str,
|
||||
include_read_references: bool,
|
||||
) -> ReferencedDatabaseAssessment {
|
||||
let mut assessment = ReferencedDatabaseAssessment::default();
|
||||
let cleaned = sql_target_safety_text(sql);
|
||||
let mut use_database = String::new();
|
||||
@@ -220,7 +260,7 @@ fn referenced_databases(sql: &str, db_type: &DatabaseType, active_database: &str
|
||||
continue;
|
||||
}
|
||||
|
||||
if !statement_is_mutation {
|
||||
if !statement_is_mutation && !include_read_references {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -235,6 +275,7 @@ fn referenced_databases(sql: &str, db_type: &DatabaseType, active_database: &str
|
||||
&mut statement_databases,
|
||||
&[
|
||||
&DML_TARGET_RE,
|
||||
&APPLY_TARGET_RE,
|
||||
&DDL_OBJECT_TARGET_RE,
|
||||
&INDEX_ON_TARGET_RE,
|
||||
&TRUNCATE_TARGET_RE,
|
||||
@@ -244,6 +285,22 @@ fn referenced_databases(sql: &str, db_type: &DatabaseType, active_database: &str
|
||||
&PRIVILEGE_TARGET_RE,
|
||||
],
|
||||
);
|
||||
if include_read_references
|
||||
&& collect_comma_separated_from_databases(
|
||||
statement,
|
||||
db_type,
|
||||
&cleaned.quoted_identifiers,
|
||||
current_database,
|
||||
&mut statement_databases,
|
||||
)
|
||||
{
|
||||
assessment.uncertain = true;
|
||||
}
|
||||
if include_read_references
|
||||
&& APPLY_RE.find_iter(statement).count() != APPLY_TARGET_RE.captures_iter(statement).count()
|
||||
{
|
||||
assessment.uncertain = true;
|
||||
}
|
||||
collect_qualified_target_database_groups(
|
||||
statement,
|
||||
db_type,
|
||||
@@ -316,6 +373,57 @@ fn collect_qualified_target_databases(
|
||||
}
|
||||
}
|
||||
|
||||
/// Collects every table factor in a comma-separated FROM list. Table aliases
|
||||
/// are accepted, while derived tables and other forms this lightweight parser
|
||||
/// cannot safely resolve are reported to the caller as uncertain.
|
||||
fn collect_comma_separated_from_databases(
|
||||
statement: &str,
|
||||
db_type: &DatabaseType,
|
||||
quoted_identifiers: &HashMap<String, String>,
|
||||
current_database: &str,
|
||||
databases: &mut HashSet<String>,
|
||||
) -> bool {
|
||||
let mut uncertain = false;
|
||||
for capture in FROM_CLAUSE_RE.captures_iter(statement) {
|
||||
let Some(from_body) = capture.get(1).map(|value| value.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let factors = split_top_level_comma_separated_targets(from_body);
|
||||
if factors.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
for factor in factors {
|
||||
let Some(target) = LEADING_TARGET_RE.captures(factor).and_then(|target| target.get(1)).and_then(|target| {
|
||||
database_from_qualified_name(target.as_str(), db_type, quoted_identifiers, current_database)
|
||||
}) else {
|
||||
uncertain = true;
|
||||
continue;
|
||||
};
|
||||
databases.insert(target);
|
||||
}
|
||||
}
|
||||
uncertain
|
||||
}
|
||||
|
||||
fn split_top_level_comma_separated_targets(value: &str) -> Vec<&str> {
|
||||
let mut targets = Vec::new();
|
||||
let mut depth = 0usize;
|
||||
let mut start = 0usize;
|
||||
for (index, ch) in value.char_indices() {
|
||||
match ch {
|
||||
'(' => depth += 1,
|
||||
')' => depth = depth.saturating_sub(1),
|
||||
',' if depth == 0 => {
|
||||
targets.push(value[start..index].trim());
|
||||
start = index + ch.len_utf8();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
targets.push(value[start..].trim());
|
||||
targets
|
||||
}
|
||||
|
||||
fn collect_qualified_target_database_groups(
|
||||
statement: &str,
|
||||
db_type: &DatabaseType,
|
||||
@@ -354,6 +462,12 @@ fn database_from_qualified_name(
|
||||
if parts.len() < 2 {
|
||||
return (!current_database.is_empty()).then(|| current_database.to_string());
|
||||
}
|
||||
// SQL Server accepts four-part names as `server.database.schema.object`.
|
||||
// The database scope must use the second component in that form, otherwise
|
||||
// a linked-server name that matches an allowed database could bypass it.
|
||||
if matches!(db_type, DatabaseType::SqlServer) && parts.len() >= 4 {
|
||||
return parts.get(1).cloned();
|
||||
}
|
||||
if qualified_first_part_is_database(db_type, parts.len()) {
|
||||
return parts.first().cloned();
|
||||
}
|
||||
@@ -624,7 +738,10 @@ fn append_quoted_identifier_token(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{is_production_database, mongo_pipeline_targets_production_database, targets_production_database};
|
||||
use super::{
|
||||
is_production_database, mongo_pipeline_targets_production_database, sql_references_disallowed_database,
|
||||
targets_production_database,
|
||||
};
|
||||
use crate::models::connection::{ConnectionConfig, DatabaseType};
|
||||
use serde::Deserialize;
|
||||
|
||||
@@ -737,6 +854,76 @@ mod tests {
|
||||
assert!(targets_production_database(&config(), "staging", "USE prod_app"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_cross_database_references_for_mcp_database_scope() {
|
||||
let allowed = vec!["reporting".to_string()];
|
||||
let mysql = DatabaseType::Mysql;
|
||||
assert!(!sql_references_disallowed_database(
|
||||
"SELECT * FROM reporting.users JOIN reporting.audit_log ON 1 = 1",
|
||||
&mysql,
|
||||
"reporting",
|
||||
&allowed,
|
||||
));
|
||||
assert!(sql_references_disallowed_database("SELECT * FROM production.users", &mysql, "reporting", &allowed,));
|
||||
assert!(sql_references_disallowed_database(
|
||||
"INSERT INTO reporting.audit_log SELECT * FROM production.users",
|
||||
&mysql,
|
||||
"reporting",
|
||||
&allowed,
|
||||
));
|
||||
assert!(!sql_references_disallowed_database(
|
||||
"SELECT * FROM reporting.users AS users, reporting.audit_log AS audit_log",
|
||||
&mysql,
|
||||
"reporting",
|
||||
&allowed,
|
||||
));
|
||||
assert!(sql_references_disallowed_database(
|
||||
"SELECT * FROM reporting.users, production.secrets",
|
||||
&mysql,
|
||||
"reporting",
|
||||
&allowed,
|
||||
));
|
||||
assert!(sql_references_disallowed_database(
|
||||
"SELECT * FROM (SELECT * FROM reporting.users) AS users, reporting.audit_log AS audit_log",
|
||||
&mysql,
|
||||
"reporting",
|
||||
&allowed,
|
||||
));
|
||||
|
||||
let sqlserver = DatabaseType::SqlServer;
|
||||
assert!(sql_references_disallowed_database(
|
||||
"SELECT * FROM production.dbo.users",
|
||||
&sqlserver,
|
||||
"reporting",
|
||||
&allowed,
|
||||
));
|
||||
assert!(sql_references_disallowed_database(
|
||||
"SELECT * FROM reporting.production.dbo.users",
|
||||
&sqlserver,
|
||||
"reporting",
|
||||
&allowed,
|
||||
));
|
||||
assert!(!sql_references_disallowed_database(
|
||||
"SELECT * FROM reporting.dbo.users AS users CROSS APPLY reporting.dbo.visible_data(users.id) AS data",
|
||||
&sqlserver,
|
||||
"reporting",
|
||||
&allowed,
|
||||
));
|
||||
assert!(sql_references_disallowed_database(
|
||||
"SELECT * FROM reporting.dbo.users AS users OUTER APPLY production.dbo.sensitive_data(users.id) AS data",
|
||||
&sqlserver,
|
||||
"reporting",
|
||||
&allowed,
|
||||
));
|
||||
assert!(sql_references_disallowed_database(
|
||||
"SELECT * FROM reporting.dbo.users AS users CROSS APPLY (SELECT users.id) AS data",
|
||||
&sqlserver,
|
||||
"reporting",
|
||||
&allowed,
|
||||
));
|
||||
assert!(!sql_references_disallowed_database("SELECT * FROM dbo.users", &sqlserver, "reporting", &allowed,));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_shared_sql_target_safety_corpus() {
|
||||
let corpus: Vec<ProductionSafetyCorpusCase> =
|
||||
|
||||
@@ -36,6 +36,7 @@ const APP_STATE_OPEN_TABS_KEY: &str = "open_tabs";
|
||||
const APP_STATE_SAVED_SQL_EDITOR_POSITIONS_KEY: &str = "saved_sql_editor_positions";
|
||||
const APP_STATE_TRANSFER_TASK_LIBRARY_KEY: &str = "transfer_task_library";
|
||||
const MCP_GLOBAL_POLICY_KEY: &str = "mcp_global_policy";
|
||||
const MCP_HTTP_SERVER_SETTINGS_KEY: &str = "mcp_http_server_settings";
|
||||
const MAX_RETRIES_KEY: &str = "max_retries";
|
||||
const APP_STATE_AI_GLOBAL_INSTRUCTIONS_KEY: &str = "ai_global_custom_instructions";
|
||||
const APP_STATE_AI_CHAT_SELECTION_KEY: &str = "ai_chat_selection_v1";
|
||||
@@ -224,10 +225,110 @@ pub struct McpGlobalPolicy {
|
||||
#[serde(default)]
|
||||
pub allow_dangerous_sql: bool,
|
||||
pub allowed_connection_ids: Option<Vec<String>>,
|
||||
/// `None` exposes every built-in MCP tool. A list is an explicit
|
||||
/// allowlist and is enforced independently of connection permissions.
|
||||
#[serde(default)]
|
||||
pub allowed_tool_names: Option<Vec<String>>,
|
||||
/// Per-connection rules may only reduce the global execution ceiling.
|
||||
/// Keeping this in the global document preserves compatibility with the
|
||||
/// existing policy API while allowing each exposed connection to be safer.
|
||||
#[serde(default)]
|
||||
pub connection_policies: Vec<McpConnectionPolicy>,
|
||||
#[serde(default)]
|
||||
pub query_timeout_secs: Option<u64>,
|
||||
}
|
||||
|
||||
fn default_mcp_connection_execution_mode_configured() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct McpConnectionPolicy {
|
||||
pub connection_id: String,
|
||||
/// When true, this connection is read-only even if the MCP-wide policy
|
||||
/// permits writes.
|
||||
#[serde(default)]
|
||||
pub read_only: bool,
|
||||
/// Enables high-risk SQL only when the MCP-wide policy also enables it.
|
||||
/// The default is deliberately false, so adding a connection rule narrows
|
||||
/// a full-access global policy to safe writes unless chosen otherwise.
|
||||
#[serde(default)]
|
||||
pub allow_dangerous_sql: bool,
|
||||
/// Whether the operation ceiling is explicitly overridden for this
|
||||
/// connection. Missing on older saved policies defaults to true so their
|
||||
/// existing safe-write/read-only behavior is preserved; database-only
|
||||
/// rules leave it false and inherit the global ceiling.
|
||||
#[serde(default = "default_mcp_connection_execution_mode_configured")]
|
||||
pub execution_mode_configured: bool,
|
||||
/// Limits which databases below this connection can be reached by MCP.
|
||||
/// The default preserves existing installations: all databases remain
|
||||
/// available until a user explicitly narrows the scope.
|
||||
#[serde(default)]
|
||||
pub database_scope: McpDatabaseScope,
|
||||
/// Exact database names allowed when `database_scope` is `selected`.
|
||||
/// An empty selected list intentionally denies every database.
|
||||
#[serde(default)]
|
||||
pub allowed_databases: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum McpDatabaseScope {
|
||||
#[default]
|
||||
All,
|
||||
Selected,
|
||||
None,
|
||||
}
|
||||
|
||||
/// Configuration for the optional MCP Streamable HTTP server managed by the
|
||||
/// desktop application. Credentials deliberately do not live here: the
|
||||
/// desktop service stores its token in a private file under the DBX data dir.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct McpHttpServerSettings {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_mcp_http_host")]
|
||||
pub host: String,
|
||||
#[serde(default = "default_mcp_http_port")]
|
||||
pub port: u16,
|
||||
#[serde(default = "default_mcp_http_path")]
|
||||
pub path: String,
|
||||
#[serde(default)]
|
||||
pub allow_remote: bool,
|
||||
#[serde(default)]
|
||||
pub allowed_hosts: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub allowed_origins: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for McpHttpServerSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
host: default_mcp_http_host(),
|
||||
port: default_mcp_http_port(),
|
||||
path: default_mcp_http_path(),
|
||||
allow_remote: false,
|
||||
allowed_hosts: Vec::new(),
|
||||
allowed_origins: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_mcp_http_host() -> String {
|
||||
"127.0.0.1".to_string()
|
||||
}
|
||||
|
||||
fn default_mcp_http_port() -> u16 {
|
||||
5225
|
||||
}
|
||||
|
||||
fn default_mcp_http_path() -> String {
|
||||
"/mcp".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct McpGlobalPolicyState {
|
||||
@@ -236,6 +337,10 @@ pub struct McpGlobalPolicyState {
|
||||
pub allow_dangerous_sql: bool,
|
||||
pub allowed_connection_ids: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub allowed_tool_names: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub connection_policies: Vec<McpConnectionPolicy>,
|
||||
#[serde(default)]
|
||||
pub query_timeout_secs: Option<u64>,
|
||||
}
|
||||
|
||||
@@ -245,11 +350,135 @@ impl McpGlobalPolicyState {
|
||||
read_only: self.read_only,
|
||||
allow_dangerous_sql: self.allow_dangerous_sql,
|
||||
allowed_connection_ids: self.allowed_connection_ids.clone(),
|
||||
allowed_tool_names: self.allowed_tool_names.clone(),
|
||||
connection_policies: self.connection_policies.clone(),
|
||||
query_timeout_secs: self.query_timeout_secs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl McpGlobalPolicy {
|
||||
/// Produces the single fail-closed representation persisted by the MCP
|
||||
/// policy API. This protects the policy boundary even when a caller does
|
||||
/// not use the desktop settings form (for example, a Web API client).
|
||||
pub fn normalized(&self) -> Self {
|
||||
let allowed_connection_ids = self.allowed_connection_ids.as_ref().map(|ids| {
|
||||
let mut ids =
|
||||
ids.iter().map(|id| id.trim()).filter(|id| !id.is_empty()).map(ToOwned::to_owned).collect::<Vec<_>>();
|
||||
ids.sort();
|
||||
ids.dedup();
|
||||
ids
|
||||
});
|
||||
let allowed_tool_names = self.allowed_tool_names.as_ref().map(|tools| {
|
||||
let mut tools = tools
|
||||
.iter()
|
||||
.map(|tool| tool.trim())
|
||||
.filter(|tool| !tool.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>();
|
||||
tools.sort();
|
||||
tools.dedup();
|
||||
tools
|
||||
});
|
||||
|
||||
let mut policies = HashMap::<String, McpConnectionPolicy>::new();
|
||||
for rule in &self.connection_policies {
|
||||
let connection_id = rule.connection_id.trim();
|
||||
if connection_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
policies
|
||||
.entry(connection_id.to_string())
|
||||
.and_modify(|current| {
|
||||
// Multiple rules are treated as a conjunction: any
|
||||
// read-only rule wins and high-risk access requires every
|
||||
// duplicate rule to explicitly permit it.
|
||||
if rule.execution_mode_configured {
|
||||
if current.execution_mode_configured {
|
||||
current.read_only |= rule.read_only;
|
||||
current.allow_dangerous_sql &= rule.allow_dangerous_sql;
|
||||
} else {
|
||||
current.read_only = rule.read_only;
|
||||
current.allow_dangerous_sql = rule.allow_dangerous_sql;
|
||||
}
|
||||
current.execution_mode_configured = true;
|
||||
}
|
||||
let (scope, databases) = intersect_mcp_database_scopes(
|
||||
current.database_scope,
|
||||
¤t.allowed_databases,
|
||||
rule.database_scope,
|
||||
&rule.allowed_databases,
|
||||
);
|
||||
current.database_scope = scope;
|
||||
current.allowed_databases = databases;
|
||||
})
|
||||
.or_insert_with(|| McpConnectionPolicy {
|
||||
connection_id: connection_id.to_string(),
|
||||
read_only: rule.read_only,
|
||||
allow_dangerous_sql: rule.allow_dangerous_sql,
|
||||
execution_mode_configured: rule.execution_mode_configured,
|
||||
database_scope: rule.database_scope,
|
||||
allowed_databases: normalize_mcp_database_names(&rule.allowed_databases),
|
||||
});
|
||||
}
|
||||
let mut connection_policies = policies.into_values().collect::<Vec<_>>();
|
||||
connection_policies.sort_by(|left, right| left.connection_id.cmp(&right.connection_id));
|
||||
for rule in &mut connection_policies {
|
||||
if rule.read_only {
|
||||
rule.allow_dangerous_sql = false;
|
||||
}
|
||||
rule.allowed_databases = normalize_mcp_database_names(&rule.allowed_databases);
|
||||
if rule.database_scope != McpDatabaseScope::Selected {
|
||||
rule.allowed_databases.clear();
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
read_only: self.read_only,
|
||||
allow_dangerous_sql: !self.read_only && self.allow_dangerous_sql,
|
||||
allowed_connection_ids,
|
||||
allowed_tool_names,
|
||||
connection_policies,
|
||||
query_timeout_secs: self.query_timeout_secs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_mcp_database_names(databases: &[String]) -> Vec<String> {
|
||||
let mut databases = databases
|
||||
.iter()
|
||||
.map(|database| database.trim())
|
||||
.filter(|database| !database.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>();
|
||||
databases.sort();
|
||||
databases.dedup();
|
||||
databases
|
||||
}
|
||||
|
||||
fn intersect_mcp_database_scopes(
|
||||
left_scope: McpDatabaseScope,
|
||||
left_databases: &[String],
|
||||
right_scope: McpDatabaseScope,
|
||||
right_databases: &[String],
|
||||
) -> (McpDatabaseScope, Vec<String>) {
|
||||
use McpDatabaseScope::{All, None, Selected};
|
||||
match (left_scope, right_scope) {
|
||||
(None, _) | (_, None) => (None, Vec::new()),
|
||||
(All, All) => (All, Vec::new()),
|
||||
(All, Selected) => (Selected, normalize_mcp_database_names(right_databases)),
|
||||
(Selected, All) => (Selected, normalize_mcp_database_names(left_databases)),
|
||||
(Selected, Selected) => {
|
||||
let right = normalize_mcp_database_names(right_databases);
|
||||
let databases = normalize_mcp_database_names(left_databases)
|
||||
.into_iter()
|
||||
.filter(|database| right.binary_search(database).is_ok())
|
||||
.collect();
|
||||
(Selected, databases)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_sidebar_table_page_size() -> usize {
|
||||
1000
|
||||
}
|
||||
@@ -1644,6 +1873,8 @@ impl Storage {
|
||||
read_only: policy.read_only,
|
||||
allow_dangerous_sql: policy.allow_dangerous_sql,
|
||||
allowed_connection_ids: policy.allowed_connection_ids,
|
||||
allowed_tool_names: policy.allowed_tool_names,
|
||||
connection_policies: policy.connection_policies,
|
||||
query_timeout_secs: policy.query_timeout_secs,
|
||||
});
|
||||
};
|
||||
@@ -1656,16 +1887,21 @@ impl Storage {
|
||||
read_only: policy.read_only,
|
||||
allow_dangerous_sql: policy.allow_dangerous_sql,
|
||||
allowed_connection_ids: policy.allowed_connection_ids,
|
||||
allowed_tool_names: policy.allowed_tool_names,
|
||||
connection_policies: policy.connection_policies,
|
||||
query_timeout_secs: policy.query_timeout_secs,
|
||||
});
|
||||
};
|
||||
let policy = serde_json::from_value::<McpGlobalPolicy>(value.clone())
|
||||
.map_err(|e| format!("invalid MCP policy: {e}"))?;
|
||||
.map_err(|e| format!("invalid MCP policy: {e}"))?
|
||||
.normalized();
|
||||
Ok(McpGlobalPolicyState {
|
||||
configured: true,
|
||||
read_only: policy.read_only,
|
||||
allow_dangerous_sql: policy.allow_dangerous_sql,
|
||||
allowed_connection_ids: policy.allowed_connection_ids,
|
||||
allowed_tool_names: policy.allowed_tool_names,
|
||||
connection_policies: policy.connection_policies,
|
||||
query_timeout_secs: policy.query_timeout_secs,
|
||||
})
|
||||
})
|
||||
@@ -1674,7 +1910,7 @@ impl Storage {
|
||||
}
|
||||
|
||||
pub async fn save_mcp_global_policy(&self, policy: &McpGlobalPolicy) -> Result<(), String> {
|
||||
let policy = serde_json::to_value(policy).map_err(|e| format!("MCP_POLICY_UNAVAILABLE: {e}"))?;
|
||||
let policy = serde_json::to_value(policy.normalized()).map_err(|e| format!("MCP_POLICY_UNAVAILABLE: {e}"))?;
|
||||
self.with_conn(move |conn| {
|
||||
let current: Option<String> = conn
|
||||
.query_row("SELECT settings_json FROM app_settings WHERE id = 1", [], |row| row.get(0))
|
||||
@@ -1695,6 +1931,22 @@ impl Storage {
|
||||
.map_err(|e| format!("MCP_POLICY_UNAVAILABLE: {e}"))
|
||||
}
|
||||
|
||||
pub async fn load_mcp_http_server_settings(&self) -> Result<McpHttpServerSettings, String> {
|
||||
let settings = self.load_app_settings_json().await?;
|
||||
match settings.get(MCP_HTTP_SERVER_SETTINGS_KEY) {
|
||||
Some(value) => serde_json::from_value(value.clone())
|
||||
.map_err(|error| format!("invalid MCP HTTP server settings: {error}")),
|
||||
None => Ok(McpHttpServerSettings::default()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn save_mcp_http_server_settings(&self, settings: &McpHttpServerSettings) -> Result<(), String> {
|
||||
let mut app_settings = self.load_app_settings_json().await?;
|
||||
let value = serde_json::to_value(settings).map_err(|error| error.to_string())?;
|
||||
app_settings.insert(MCP_HTTP_SERVER_SETTINGS_KEY.to_string(), value);
|
||||
self.save_app_settings_json(&app_settings).await
|
||||
}
|
||||
|
||||
pub async fn save_desktop_settings(&self, desktop_settings: &DesktopSettings) -> Result<(), String> {
|
||||
let mut settings = self.load_app_settings_json().await?;
|
||||
settings.remove("run_in_background");
|
||||
@@ -2619,7 +2871,8 @@ fn load_mcp_global_policy_in_tx(tx: &rusqlite::Transaction<'_>) -> Result<McpGlo
|
||||
.map_err(|e| format!("MCP_POLICY_UNAVAILABLE: invalid app settings JSON: {e}"))?;
|
||||
match settings.get(MCP_GLOBAL_POLICY_KEY) {
|
||||
Some(value) => serde_json::from_value::<McpGlobalPolicy>(value.clone())
|
||||
.map_err(|e| format!("MCP_POLICY_UNAVAILABLE: invalid MCP policy: {e}"))?,
|
||||
.map_err(|e| format!("MCP_POLICY_UNAVAILABLE: invalid MCP policy: {e}"))?
|
||||
.normalized(),
|
||||
None => McpGlobalPolicy::default(),
|
||||
}
|
||||
}
|
||||
@@ -6028,6 +6281,8 @@ mod tests {
|
||||
read_only: false,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: None,
|
||||
allowed_tool_names: None,
|
||||
connection_policies: Vec::new(),
|
||||
query_timeout_secs: None,
|
||||
}
|
||||
);
|
||||
@@ -6039,6 +6294,7 @@ mod tests {
|
||||
allow_dangerous_sql: true,
|
||||
allowed_connection_ids: Some(vec!["conn-1".to_string(), "conn-2".to_string()]),
|
||||
query_timeout_secs: Some(120),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -6048,15 +6304,17 @@ mod tests {
|
||||
McpGlobalPolicyState {
|
||||
configured: true,
|
||||
read_only: true,
|
||||
allow_dangerous_sql: true,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: Some(vec!["conn-1".to_string(), "conn-2".to_string()]),
|
||||
allowed_tool_names: None,
|
||||
connection_policies: Vec::new(),
|
||||
query_timeout_secs: Some(120),
|
||||
}
|
||||
);
|
||||
assert_eq!(storage.load_password_hash().await.unwrap().as_deref(), Some("preserved"));
|
||||
let settings = storage.load_app_settings_json().await.unwrap();
|
||||
assert_eq!(settings[MCP_GLOBAL_POLICY_KEY]["readOnly"], true);
|
||||
assert_eq!(settings[MCP_GLOBAL_POLICY_KEY]["allowDangerousSql"], true);
|
||||
assert_eq!(settings[MCP_GLOBAL_POLICY_KEY]["allowDangerousSql"], false);
|
||||
assert_eq!(settings[MCP_GLOBAL_POLICY_KEY]["allowedConnectionIds"][0], "conn-1");
|
||||
assert_eq!(settings[MCP_GLOBAL_POLICY_KEY]["queryTimeoutSecs"], 120);
|
||||
assert!(settings[MCP_GLOBAL_POLICY_KEY].get("configured").is_none());
|
||||
@@ -6148,6 +6406,7 @@ mod tests {
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: Some(vec![kept.id.clone()]),
|
||||
query_timeout_secs: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -6172,6 +6431,7 @@ mod tests {
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: None,
|
||||
query_timeout_secs: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -5,9 +5,10 @@ edition = "2021"
|
||||
license = "Apache-2.0"
|
||||
|
||||
[features]
|
||||
default = ["duckdb-sidecar", "mq-admin"]
|
||||
default = ["duckdb-sidecar", "mq-admin", "sqlite-bundled"]
|
||||
duckdb-sidecar = ["dbx-core/duckdb-sidecar"]
|
||||
mq-admin = ["dbx-core/mq-admin"]
|
||||
sqlite-bundled = ["dbx-core/sqlite-bundled"]
|
||||
|
||||
[[bin]]
|
||||
name = "dbx-mcp"
|
||||
@@ -15,15 +16,19 @@ path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
async-trait = "0.1"
|
||||
dbx-core = { path = "../dbx-core", default-features = false, features = ["sqlite-bundled"] }
|
||||
dbx-core = { path = "../dbx-core", default-features = false }
|
||||
dirs = "6"
|
||||
rmcp = { version = "2.2.0", features = ["client", "transport-io"] }
|
||||
log = "0.4"
|
||||
axum = "0.8"
|
||||
rmcp = { version = "2.2.0", features = ["client", "transport-io", "transport-streamable-http-server"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "socks"] }
|
||||
rustls = { version = "0.23", features = ["aws-lc-rs"] }
|
||||
schemars = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["fs", "macros", "rt-multi-thread"] }
|
||||
tokio = { version = "1", features = ["fs", "macros", "net", "rt-multi-thread", "signal"] }
|
||||
tokio-util = "0.7"
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
url = "2"
|
||||
|
||||
|
||||
@@ -163,6 +163,13 @@ pub trait DbxBackend: Send + Sync {
|
||||
async fn load_mcp_global_policy(&self) -> Result<McpGlobalPolicy, String>;
|
||||
|
||||
async fn load_connections(&self) -> Result<Vec<ConnectionConfig>, String>;
|
||||
/// Return database names visible to the DBX connection itself. The MCP
|
||||
/// server applies its own database-scope policy before exposing these
|
||||
/// names to a client.
|
||||
async fn list_databases(&self, connection: &ConnectionConfig) -> Result<Vec<String>, String> {
|
||||
let _ = connection;
|
||||
Err("Database metadata is not supported by this backend.".to_string())
|
||||
}
|
||||
async fn load_connection_group_paths(&self) -> Result<HashMap<String, Vec<String>>, String> {
|
||||
Ok(HashMap::new())
|
||||
}
|
||||
@@ -474,6 +481,12 @@ impl WebBackend {
|
||||
}
|
||||
|
||||
impl LocalBackend {
|
||||
/// Reuse an already initialized DBX application state, for hosts that
|
||||
/// embed MCP alongside their own HTTP server.
|
||||
pub fn from_app_state(state: Arc<AppState>, data_dir: PathBuf) -> Self {
|
||||
Self { state, data_dir }
|
||||
}
|
||||
|
||||
pub async fn open(path: &Path) -> Result<Self, String> {
|
||||
let storage = Storage::open(path).await?;
|
||||
let configs = storage.load_connections().await?;
|
||||
@@ -567,6 +580,16 @@ impl DbxBackend for LocalBackend {
|
||||
Ok(configs)
|
||||
}
|
||||
|
||||
async fn list_databases(&self, connection: &ConnectionConfig) -> Result<Vec<String>, String> {
|
||||
// `list_databases_core` supports many database engines and therefore
|
||||
// produces a very large future. Boxing it here keeps the async-trait
|
||||
// implementation below Rust's type-layout recursion limit in desktop
|
||||
// builds without changing its execution behaviour.
|
||||
Box::pin(dbx_core::schema::list_databases_core(&self.state, &connection.id))
|
||||
.await
|
||||
.map(|databases| databases.into_iter().map(|database| database.name).collect())
|
||||
}
|
||||
|
||||
async fn load_connection_group_paths(&self) -> Result<HashMap<String, Vec<String>>, String> {
|
||||
Ok(self.state.storage.load_sidebar_layout().await?.map(connection_group_paths).unwrap_or_default())
|
||||
}
|
||||
@@ -763,6 +786,51 @@ impl DbxBackend for WebBackend {
|
||||
.map_err(|error| format!("Invalid connection list response: {error}"))
|
||||
}
|
||||
|
||||
async fn list_databases(&self, connection: &ConnectionConfig) -> Result<Vec<String>, String> {
|
||||
self.ensure_connected(connection).await?;
|
||||
match connection.db_type {
|
||||
DatabaseType::MongoDb => self
|
||||
.request(
|
||||
reqwest::Method::POST,
|
||||
"/api/mongo/list-databases",
|
||||
Some(json!({ "connectionId": connection.id })),
|
||||
)
|
||||
.await?
|
||||
.json::<Vec<String>>()
|
||||
.await
|
||||
.map_err(|error| format!("Invalid MongoDB database list response: {error}")),
|
||||
DatabaseType::Redis => {
|
||||
let databases = self
|
||||
.request(
|
||||
reqwest::Method::POST,
|
||||
"/api/redis/list-databases",
|
||||
Some(json!({ "connectionId": connection.id })),
|
||||
)
|
||||
.await?
|
||||
.json::<Vec<Value>>()
|
||||
.await
|
||||
.map_err(|error| format!("Invalid Redis database list response: {error}"))?;
|
||||
Ok(databases
|
||||
.into_iter()
|
||||
.filter_map(|database| {
|
||||
database.get("db").and_then(Value::as_u64).map(|database| database.to_string())
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
_ => self
|
||||
.request(
|
||||
reqwest::Method::GET,
|
||||
&format!("/api/schema/databases?connection_id={}", url_encode(&connection.id)),
|
||||
None,
|
||||
)
|
||||
.await?
|
||||
.json::<Vec<dbx_core::db::DatabaseInfo>>()
|
||||
.await
|
||||
.map(|databases| databases.into_iter().map(|database| database.name).collect())
|
||||
.map_err(|error| format!("Invalid database list response: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_connection_group_paths(&self) -> Result<HashMap<String, Vec<String>>, String> {
|
||||
let layout = self
|
||||
.request(reqwest::Method::GET, "/api/layout/sidebar", None)
|
||||
@@ -1869,6 +1937,8 @@ mod tests {
|
||||
read_only,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: None,
|
||||
allowed_tool_names: None,
|
||||
connection_policies: Vec::new(),
|
||||
query_timeout_secs: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
|
||||
/// A deliberately non-sensitive liveness response for HTTP deployment probes.
|
||||
#[derive(Serialize)]
|
||||
pub struct HealthStatus {
|
||||
pub status: &'static str,
|
||||
pub transport: &'static str,
|
||||
}
|
||||
|
||||
pub async fn health() -> Json<HealthStatus> {
|
||||
Json(HealthStatus { status: "ok", transport: "streamable-http" })
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
use std::{io, sync::Arc};
|
||||
|
||||
use axum::{http::Method, middleware, routing::get, Router};
|
||||
use rmcp::transport::{
|
||||
streamable_http_server::{session::local::LocalSessionManager, tower::StreamableHttpService},
|
||||
StreamableHttpServerConfig,
|
||||
};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tower_http::cors::{AllowOrigin, Any, CorsLayer};
|
||||
|
||||
use crate::{
|
||||
diagnostics::health,
|
||||
http_auth::{authorize_request, HttpAuth},
|
||||
runtime::HttpRuntimeConfig,
|
||||
DbxBackend, DbxMcpServer, McpScope,
|
||||
};
|
||||
|
||||
/// Builds a protected Streamable HTTP MCP router for embedding in an existing
|
||||
/// HTTP server. The embedded host remains responsible for choosing the public
|
||||
/// listener and lifecycle; this router only owns the `/mcp` protocol route.
|
||||
pub fn streamable_http_router(
|
||||
backend: Arc<dyn DbxBackend>,
|
||||
path: &str,
|
||||
auth: HttpAuth,
|
||||
allowed_hosts: Vec<String>,
|
||||
web_mode: bool,
|
||||
) -> Router {
|
||||
build_streamable_http_router(backend, path, auth, allowed_hosts, web_mode, None)
|
||||
}
|
||||
|
||||
fn build_streamable_http_router(
|
||||
backend: Arc<dyn DbxBackend>,
|
||||
path: &str,
|
||||
auth: HttpAuth,
|
||||
allowed_hosts: Vec<String>,
|
||||
web_mode: bool,
|
||||
cancellation: Option<CancellationToken>,
|
||||
) -> Router {
|
||||
let mut rmcp_config = StreamableHttpServerConfig::default().with_allowed_hosts(allowed_hosts);
|
||||
if let Some(cancellation) = cancellation {
|
||||
rmcp_config = rmcp_config.with_cancellation_token(cancellation);
|
||||
}
|
||||
let server_backend = backend.clone();
|
||||
let scope = McpScope::from_env();
|
||||
let service: StreamableHttpService<DbxMcpServer, LocalSessionManager> = StreamableHttpService::new(
|
||||
move || Ok(DbxMcpServer::with_runtime_options(server_backend.clone(), scope.clone(), web_mode)),
|
||||
Default::default(),
|
||||
rmcp_config,
|
||||
);
|
||||
|
||||
// The authentication middleware and CORS response must use the same
|
||||
// predicate. In particular, loopback desktop mode permits localhost
|
||||
// browser origins without requiring users to enumerate every development
|
||||
// port, while remote mode still requires exact configured origins.
|
||||
let cors_auth = auth.clone();
|
||||
let router =
|
||||
Router::new().nest_service(path, service).layer(middleware::from_fn_with_state(auth, authorize_request));
|
||||
router.layer(
|
||||
CorsLayer::new()
|
||||
.allow_origin(AllowOrigin::predicate(move |origin, _| {
|
||||
origin.to_str().is_ok_and(|origin| cors_auth.origin_is_allowed(origin))
|
||||
}))
|
||||
.allow_methods([Method::GET, Method::POST, Method::DELETE])
|
||||
.allow_headers(Any),
|
||||
)
|
||||
}
|
||||
|
||||
/// Serves one stateful rmcp Streamable HTTP endpoint. Every MCP protocol
|
||||
/// session receives a fresh `DbxMcpServer`, while the database backend remains
|
||||
/// shared and all authorization happens before rmcp sees a request.
|
||||
pub async fn serve_streamable_http(backend: Arc<dyn DbxBackend>, config: HttpRuntimeConfig) -> io::Result<()> {
|
||||
let cancellation = CancellationToken::new();
|
||||
let shutdown = cancellation.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
shutdown.cancel();
|
||||
});
|
||||
serve_streamable_http_with_shutdown(backend, config, cancellation).await
|
||||
}
|
||||
|
||||
/// Serves the HTTP transport until `cancellation` is cancelled. Embedding
|
||||
/// hosts use this variant so their own lifecycle controls shutdown instead of
|
||||
/// relying on a process-wide Ctrl-C handler.
|
||||
pub async fn serve_streamable_http_with_shutdown(
|
||||
backend: Arc<dyn DbxBackend>,
|
||||
config: HttpRuntimeConfig,
|
||||
cancellation: CancellationToken,
|
||||
) -> io::Result<()> {
|
||||
let listener = tokio::net::TcpListener::bind(config.bind_addr).await?;
|
||||
serve_streamable_http_on_listener(backend, config, cancellation, listener).await
|
||||
}
|
||||
|
||||
/// Variant for hosts that must bind synchronously before reporting the server
|
||||
/// as healthy (for example, DBX Desktop settings UI).
|
||||
pub async fn serve_streamable_http_on_listener(
|
||||
backend: Arc<dyn DbxBackend>,
|
||||
config: HttpRuntimeConfig,
|
||||
cancellation: CancellationToken,
|
||||
listener: tokio::net::TcpListener,
|
||||
) -> io::Result<()> {
|
||||
let mcp_router = build_streamable_http_router(
|
||||
backend,
|
||||
&config.path,
|
||||
config.auth,
|
||||
config.allowed_hosts,
|
||||
false,
|
||||
Some(cancellation.child_token()),
|
||||
);
|
||||
let router = Router::new().route("/healthz", get(health)).route("/readyz", get(health)).merge(mcp_router);
|
||||
|
||||
eprintln!("DBX MCP Streamable HTTP listening on http://{}{}", config.bind_addr, config.path);
|
||||
|
||||
axum::serve(listener, router)
|
||||
.with_graceful_shutdown(async move {
|
||||
cancellation.cancelled().await;
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
use std::{collections::HashSet, sync::Arc};
|
||||
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::{header, HeaderValue, StatusCode},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use url::Url;
|
||||
|
||||
/// Authentication and browser-origin policy for the Streamable HTTP endpoint.
|
||||
/// The token is intentionally not `Debug` and is never exposed by diagnostics.
|
||||
#[derive(Clone)]
|
||||
pub struct HttpAuth {
|
||||
token: Arc<[u8]>,
|
||||
allowed_origins: HashSet<String>,
|
||||
allow_loopback_origins: bool,
|
||||
}
|
||||
|
||||
impl HttpAuth {
|
||||
pub fn new(
|
||||
token: String,
|
||||
allowed_origins: impl IntoIterator<Item = String>,
|
||||
allow_loopback_origins: bool,
|
||||
) -> Result<Self, String> {
|
||||
if token.trim().is_empty() || token.contains(char::is_whitespace) {
|
||||
return Err("MCP HTTP bearer token must be non-empty and contain no whitespace".into());
|
||||
}
|
||||
|
||||
let mut normalized_origins = HashSet::new();
|
||||
for origin in allowed_origins {
|
||||
normalized_origins.insert(normalize_origin(&origin)?);
|
||||
}
|
||||
|
||||
Ok(Self { token: Arc::from(token.into_bytes()), allowed_origins: normalized_origins, allow_loopback_origins })
|
||||
}
|
||||
|
||||
fn token_matches(&self, candidate: &str) -> bool {
|
||||
let candidate = candidate.as_bytes();
|
||||
if candidate.len() != self.token.len() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Keep comparison work independent of the first mismatching byte.
|
||||
let difference = self
|
||||
.token
|
||||
.iter()
|
||||
.zip(candidate)
|
||||
.fold(0_u8, |difference, (expected, actual)| difference | (expected ^ actual));
|
||||
difference == 0
|
||||
}
|
||||
|
||||
pub fn origin_is_allowed(&self, origin: &str) -> bool {
|
||||
let Ok(origin) = normalize_origin(origin) else {
|
||||
return false;
|
||||
};
|
||||
if self.allowed_origins.contains(&origin) {
|
||||
return true;
|
||||
}
|
||||
self.allow_loopback_origins && origin_is_loopback(&origin)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn authorize_request(State(auth): State<HttpAuth>, request: Request, next: Next) -> Response {
|
||||
let method = request.method().clone();
|
||||
let path = request.uri().path().to_string();
|
||||
if let Some(origin) = request.headers().get(header::ORIGIN) {
|
||||
let origin = match origin.to_str() {
|
||||
Ok(origin) => origin,
|
||||
Err(_) => {
|
||||
log::warn!(target: "dbx_mcp::audit", "MCP HTTP request rejected: method={method} path={path} reason=invalid-origin");
|
||||
return forbidden();
|
||||
}
|
||||
};
|
||||
if !auth.origin_is_allowed(origin) {
|
||||
log::warn!(target: "dbx_mcp::audit", "MCP HTTP request rejected: method={method} path={path} origin={origin} reason=origin-not-allowed");
|
||||
return forbidden();
|
||||
}
|
||||
}
|
||||
|
||||
let Some(token) = bearer_token(request.headers().get(header::AUTHORIZATION)) else {
|
||||
log::warn!(target: "dbx_mcp::audit", "MCP HTTP request rejected: method={method} path={path} reason=missing-bearer-token");
|
||||
return unauthorized();
|
||||
};
|
||||
if !auth.token_matches(token) {
|
||||
log::warn!(target: "dbx_mcp::audit", "MCP HTTP request rejected: method={method} path={path} reason=invalid-bearer-token");
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
let response = next.run(request).await;
|
||||
log::info!(target: "dbx_mcp::audit", "MCP HTTP request authenticated: method={method} path={path} status={}", response.status());
|
||||
response
|
||||
}
|
||||
|
||||
fn bearer_token(value: Option<&HeaderValue>) -> Option<&str> {
|
||||
let value = value?.to_str().ok()?;
|
||||
let (scheme, token) = value.split_once(' ')?;
|
||||
(scheme.eq_ignore_ascii_case("bearer") && !token.is_empty() && !token.contains(char::is_whitespace))
|
||||
.then_some(token)
|
||||
}
|
||||
|
||||
fn normalize_origin(origin: &str) -> Result<String, String> {
|
||||
let url = Url::parse(origin).map_err(|_| format!("invalid allowed origin: {origin}"))?;
|
||||
if !matches!(url.scheme(), "http" | "https")
|
||||
|| url.host_str().is_none()
|
||||
|| url.path() != "/"
|
||||
|| url.query().is_some()
|
||||
|| url.fragment().is_some()
|
||||
{
|
||||
return Err(format!("allowed origin must be an HTTP(S) origin without a path: {origin}"));
|
||||
}
|
||||
Ok(url.origin().ascii_serialization())
|
||||
}
|
||||
|
||||
fn origin_is_loopback(origin: &str) -> bool {
|
||||
let Ok(url) = Url::parse(origin) else {
|
||||
return false;
|
||||
};
|
||||
match url.host_str() {
|
||||
Some("localhost") => true,
|
||||
Some(host) => host.parse::<std::net::IpAddr>().is_ok_and(|ip| ip.is_loopback()),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn unauthorized() -> Response {
|
||||
let mut response = (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
|
||||
response.headers_mut().insert(header::WWW_AUTHENTICATE, HeaderValue::from_static("Bearer"));
|
||||
response
|
||||
}
|
||||
|
||||
fn forbidden() -> Response {
|
||||
(StatusCode::FORBIDDEN, "Forbidden").into_response()
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
pub mod backend;
|
||||
pub mod diagnostics;
|
||||
pub mod http;
|
||||
pub mod http_auth;
|
||||
pub mod paths;
|
||||
pub mod runtime;
|
||||
pub mod server;
|
||||
pub mod session;
|
||||
pub mod transport;
|
||||
|
||||
pub use backend::{ConnectionSummary, DbxBackend, LocalBackend, WebBackend};
|
||||
pub use dbx_core::mongo_shell as mongo;
|
||||
pub use http::{serve_streamable_http_on_listener, serve_streamable_http_with_shutdown, streamable_http_router};
|
||||
pub use http_auth::HttpAuth;
|
||||
pub use runtime::{HttpRuntimeConfig, McpTransport, RuntimeConfig};
|
||||
pub use server::{DbxMcpServer, McpScope};
|
||||
pub use session::McpSessionStore;
|
||||
pub use transport::with_legacy_discovery_fallback;
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use dbx_mcp::{with_legacy_discovery_fallback, DbxBackend, DbxMcpServer, LocalBackend, WebBackend};
|
||||
use dbx_mcp::{
|
||||
http::serve_streamable_http, with_legacy_discovery_fallback, DbxBackend, DbxMcpServer, LocalBackend, McpTransport,
|
||||
RuntimeConfig, WebBackend,
|
||||
};
|
||||
use rmcp::ServiceExt;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let runtime = RuntimeConfig::from_environment_and_args()?;
|
||||
let backend: Arc<dyn DbxBackend> = if let Ok(base_url) = std::env::var("DBX_WEB_URL") {
|
||||
Arc::new(
|
||||
WebBackend::new(base_url, std::env::var("DBX_WEB_PASSWORD").unwrap_or_default())
|
||||
@@ -14,8 +18,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let db_path = dbx_mcp::paths::storage_db_path().map_err(std::io::Error::other)?;
|
||||
Arc::new(LocalBackend::open(&db_path).await.map_err(std::io::Error::other)?)
|
||||
};
|
||||
let transport = with_legacy_discovery_fallback(rmcp::transport::stdio());
|
||||
let service = DbxMcpServer::new(backend).serve(transport).await?;
|
||||
service.waiting().await?;
|
||||
Ok(())
|
||||
match runtime.transport {
|
||||
McpTransport::Stdio => {
|
||||
let transport = with_legacy_discovery_fallback(rmcp::transport::stdio());
|
||||
let service = DbxMcpServer::new(backend).serve(transport).await?;
|
||||
service.waiting().await?;
|
||||
Ok(())
|
||||
}
|
||||
McpTransport::StreamableHttp => {
|
||||
let http = runtime.http.ok_or_else(|| std::io::Error::other("missing HTTP MCP runtime configuration"))?;
|
||||
serve_streamable_http(backend, http).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
use std::{
|
||||
env, fs,
|
||||
net::{IpAddr, SocketAddr},
|
||||
};
|
||||
|
||||
use crate::http_auth::HttpAuth;
|
||||
|
||||
const DEFAULT_HTTP_HOST: &str = "127.0.0.1";
|
||||
const DEFAULT_HTTP_PORT: u16 = 5225;
|
||||
const DEFAULT_HTTP_PATH: &str = "/mcp";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum McpTransport {
|
||||
Stdio,
|
||||
StreamableHttp,
|
||||
}
|
||||
|
||||
pub struct RuntimeConfig {
|
||||
pub transport: McpTransport,
|
||||
pub http: Option<HttpRuntimeConfig>,
|
||||
}
|
||||
|
||||
pub struct HttpRuntimeConfig {
|
||||
pub(crate) bind_addr: SocketAddr,
|
||||
pub(crate) path: String,
|
||||
pub(crate) auth: HttpAuth,
|
||||
pub(crate) allowed_hosts: Vec<String>,
|
||||
}
|
||||
|
||||
impl HttpRuntimeConfig {
|
||||
pub fn new(bind_addr: SocketAddr, path: String, auth: HttpAuth, allowed_hosts: Vec<String>) -> Self {
|
||||
Self { bind_addr, path, auth, allowed_hosts }
|
||||
}
|
||||
|
||||
pub fn bind_addr(&self) -> SocketAddr {
|
||||
self.bind_addr
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeConfig {
|
||||
/// Parses DBX MCP's small runtime surface without adding a command-line dependency.
|
||||
/// `stdio` remains the default, preserving all existing npm and native launchers.
|
||||
pub fn from_environment_and_args() -> Result<Self, String> {
|
||||
let mut transport = env::var("DBX_MCP_TRANSPORT").ok();
|
||||
let mut host = env::var("DBX_MCP_HTTP_HOST").ok();
|
||||
let mut port = env::var("DBX_MCP_HTTP_PORT").ok();
|
||||
let mut path = env::var("DBX_MCP_HTTP_PATH").ok();
|
||||
let mut allow_remote = env_flag("DBX_MCP_HTTP_ALLOW_REMOTE")?;
|
||||
let mut explicit_allow_remote = false;
|
||||
|
||||
let mut arguments = env::args().skip(1);
|
||||
while let Some(argument) = arguments.next() {
|
||||
match argument.as_str() {
|
||||
"--stdio" => transport = Some("stdio".into()),
|
||||
"--http" => transport = Some("streamable-http".into()),
|
||||
"--http-allow-remote" => {
|
||||
allow_remote = true;
|
||||
explicit_allow_remote = true;
|
||||
}
|
||||
"--transport" => transport = Some(required_argument(&mut arguments, "--transport")?),
|
||||
"--http-host" => host = Some(required_argument(&mut arguments, "--http-host")?),
|
||||
"--http-port" => port = Some(required_argument(&mut arguments, "--http-port")?),
|
||||
"--http-path" => path = Some(required_argument(&mut arguments, "--http-path")?),
|
||||
_ => {
|
||||
if let Some(value) = argument.strip_prefix("--transport=") {
|
||||
transport = Some(value.into());
|
||||
} else if let Some(value) = argument.strip_prefix("--http-host=") {
|
||||
host = Some(value.into());
|
||||
} else if let Some(value) = argument.strip_prefix("--http-port=") {
|
||||
port = Some(value.into());
|
||||
} else if let Some(value) = argument.strip_prefix("--http-path=") {
|
||||
path = Some(value.into());
|
||||
} else {
|
||||
return Err(format!("unknown DBX MCP argument: {argument}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let transport = parse_transport(transport.as_deref().unwrap_or("stdio"))?;
|
||||
if transport == McpTransport::Stdio {
|
||||
return Ok(Self { transport, http: None });
|
||||
}
|
||||
|
||||
let host = host.unwrap_or_else(|| DEFAULT_HTTP_HOST.into());
|
||||
let ip = host.parse::<IpAddr>().map_err(|_| "DBX_MCP_HTTP_HOST must be an IP address".to_string())?;
|
||||
let port = port
|
||||
.unwrap_or_else(|| DEFAULT_HTTP_PORT.to_string())
|
||||
.parse::<u16>()
|
||||
.map_err(|_| "DBX_MCP_HTTP_PORT must be a valid TCP port".to_string())?;
|
||||
let bind_addr = SocketAddr::new(ip, port);
|
||||
let path = path.unwrap_or_else(|| DEFAULT_HTTP_PATH.into());
|
||||
validate_path(&path)?;
|
||||
|
||||
let is_loopback = ip.is_loopback();
|
||||
if !is_loopback && !(allow_remote && explicit_allow_remote) {
|
||||
return Err(
|
||||
"non-loopback HTTP binding requires both DBX_MCP_HTTP_ALLOW_REMOTE=1 and --http-allow-remote".into()
|
||||
);
|
||||
}
|
||||
|
||||
let allowed_hosts = comma_separated_env("DBX_MCP_HTTP_ALLOWED_HOSTS");
|
||||
let allowed_origins = comma_separated_env("DBX_MCP_HTTP_ALLOWED_ORIGINS");
|
||||
if !is_loopback && (allowed_hosts.is_empty() || allowed_origins.is_empty()) {
|
||||
return Err(
|
||||
"non-loopback HTTP binding requires DBX_MCP_HTTP_ALLOWED_HOSTS and DBX_MCP_HTTP_ALLOWED_ORIGINS".into(),
|
||||
);
|
||||
}
|
||||
|
||||
let token = http_token_from_environment()?;
|
||||
let auth = HttpAuth::new(token, allowed_origins.clone(), is_loopback)?;
|
||||
let allowed_hosts =
|
||||
if is_loopback { vec!["localhost".into(), "127.0.0.1".into(), "::1".into()] } else { allowed_hosts };
|
||||
|
||||
Ok(Self { transport, http: Some(HttpRuntimeConfig { bind_addr, path, auth, allowed_hosts }) })
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_transport(value: &str) -> Result<McpTransport, String> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"stdio" => Ok(McpTransport::Stdio),
|
||||
"http" | "streamable-http" | "streamable_http" => Ok(McpTransport::StreamableHttp),
|
||||
_ => Err("DBX_MCP_TRANSPORT must be stdio or streamable-http".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn required_argument(arguments: &mut impl Iterator<Item = String>, name: &str) -> Result<String, String> {
|
||||
arguments.next().filter(|value| !value.starts_with("--")).ok_or_else(|| format!("{name} requires a value"))
|
||||
}
|
||||
|
||||
fn env_flag(name: &str) -> Result<bool, String> {
|
||||
match env::var(name) {
|
||||
Ok(value) => match value.trim().to_ascii_lowercase().as_str() {
|
||||
"1" | "true" | "yes" => Ok(true),
|
||||
"0" | "false" | "no" | "" => Ok(false),
|
||||
_ => Err(format!("{name} must be true or false")),
|
||||
},
|
||||
Err(env::VarError::NotPresent) => Ok(false),
|
||||
Err(env::VarError::NotUnicode(_)) => Err(format!("{name} must be valid UTF-8")),
|
||||
}
|
||||
}
|
||||
|
||||
fn comma_separated_env(name: &str) -> Vec<String> {
|
||||
env::var(name)
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flat_map(|value| {
|
||||
value.split(',').map(str::trim).filter(|value| !value.is_empty()).map(ToOwned::to_owned).collect::<Vec<_>>()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn http_token_from_environment() -> Result<String, String> {
|
||||
let inline_token = env::var("DBX_MCP_HTTP_TOKEN").ok();
|
||||
let token_file = env::var("DBX_MCP_HTTP_TOKEN_FILE").ok();
|
||||
match (inline_token, token_file) {
|
||||
(Some(_), Some(_)) => Err("set only one of DBX_MCP_HTTP_TOKEN or DBX_MCP_HTTP_TOKEN_FILE".into()),
|
||||
(Some(token), None) if !token.trim().is_empty() => Ok(token),
|
||||
(None, Some(path)) => fs::read_to_string(&path)
|
||||
.map_err(|error| format!("failed to read DBX_MCP_HTTP_TOKEN_FILE: {error}"))
|
||||
.map(|token| token.trim_end_matches(['\r', '\n']).to_owned())
|
||||
.and_then(|token| {
|
||||
(!token.is_empty()).then_some(token).ok_or_else(|| "DBX_MCP_HTTP_TOKEN_FILE is empty".into())
|
||||
}),
|
||||
_ => Err("Streamable HTTP requires DBX_MCP_HTTP_TOKEN or DBX_MCP_HTTP_TOKEN_FILE".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_path(path: &str) -> Result<(), String> {
|
||||
if path == "/" || !path.starts_with('/') || path.ends_with('/') || path.contains('?') || path.contains('#') {
|
||||
return Err("DBX_MCP_HTTP_PATH must be an absolute path such as /mcp".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
+481
-54
@@ -18,18 +18,25 @@ use dbx_core::{
|
||||
db::redis_driver::{classify_command, parse_command_argv, RedisCommandResult, RedisCommandSafety},
|
||||
models::connection::DatabaseType,
|
||||
production_safety::{
|
||||
is_production_database, mongo_pipeline_targets_production_database, targets_production_database,
|
||||
is_production_database, mongo_pipeline_targets_production_database, sql_references_disallowed_database,
|
||||
targets_production_database,
|
||||
},
|
||||
query_execution_sql::is_write_sql_for_database,
|
||||
sql_risk::{
|
||||
classify_sql_risk_for_database, is_dangerous_sql_for_database, mcp_sql_has_forbidden_database_switch, SqlRisk,
|
||||
},
|
||||
storage::McpGlobalPolicy,
|
||||
storage::{McpDatabaseScope, McpGlobalPolicy},
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub struct ListConnectionsRequest {}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub struct ListDatabasesRequest {
|
||||
#[serde(flatten)]
|
||||
pub selector: ConnectionSelector,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub struct ConnectionSelector {
|
||||
#[schemars(description = "Unique ID of the DBX connection")]
|
||||
@@ -136,7 +143,9 @@ pub struct DuplicateConnectionRequest {
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub struct RemoveConnectionRequest {
|
||||
pub connection_name: String,
|
||||
#[schemars(description = "Name of the DBX connection when connection_id is not provided")]
|
||||
#[schemars(extend("type" = "string"))]
|
||||
pub connection_name: Option<String>,
|
||||
#[schemars(extend("type" = "string"))]
|
||||
pub connection_id: Option<String>,
|
||||
}
|
||||
@@ -244,6 +253,14 @@ pub struct McpScope {
|
||||
struct ResolvedConnection {
|
||||
connection: dbx_core::models::connection::ConnectionConfig,
|
||||
policy: McpGlobalPolicy,
|
||||
database_scope: DatabaseScope,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum DatabaseScope {
|
||||
All,
|
||||
Selected(Vec<String>),
|
||||
None,
|
||||
}
|
||||
|
||||
impl McpScope {
|
||||
@@ -324,6 +341,9 @@ impl DbxMcpServer {
|
||||
&self,
|
||||
Parameters(ListConnectionsRequest {}): Parameters<ListConnectionsRequest>,
|
||||
) -> CallToolResult {
|
||||
if let Err(error) = self.ensure_tool_allowed("dbx_list_connections").await {
|
||||
return error;
|
||||
}
|
||||
match self.load_scoped_connections().await {
|
||||
Ok(connections) if connections.is_empty() => text("No connections configured in DBX."),
|
||||
Ok(connections) => {
|
||||
@@ -342,13 +362,31 @@ impl DbxMcpServer {
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(name = "dbx_list_tables", description = "List tables and views for a database connection")]
|
||||
async fn list_tables(&self, Parameters(request): Parameters<ListTablesRequest>) -> CallToolResult {
|
||||
#[tool(
|
||||
name = "dbx_list_databases",
|
||||
description = "List database names available through a DBX connection. When the connection has a database allowlist, returns only the databases allowed by DBX MCP settings; use this before listing tables if the connection has no default database."
|
||||
)]
|
||||
async fn list_databases(&self, Parameters(request): Parameters<ListDatabasesRequest>) -> CallToolResult {
|
||||
if let Err(error) = self.ensure_tool_allowed("dbx_list_databases").await {
|
||||
return error;
|
||||
}
|
||||
let resolved = match self.resolve_connection(&request.selector).await {
|
||||
Ok(resolved) => resolved,
|
||||
Err(error) => return error,
|
||||
};
|
||||
let database = match self.resolve_database(request.database, &resolved.connection) {
|
||||
self.list_databases_for_resolved(&resolved).await
|
||||
}
|
||||
|
||||
#[tool(name = "dbx_list_tables", description = "List tables and views for a database connection")]
|
||||
async fn list_tables(&self, Parameters(request): Parameters<ListTablesRequest>) -> CallToolResult {
|
||||
if let Err(error) = self.ensure_tool_allowed("dbx_list_tables").await {
|
||||
return error;
|
||||
}
|
||||
let resolved = match self.resolve_connection(&request.selector).await {
|
||||
Ok(resolved) => resolved,
|
||||
Err(error) => return error,
|
||||
};
|
||||
let database = match self.resolve_database(request.database, &resolved) {
|
||||
Ok(database) => database,
|
||||
Err(error) => return error,
|
||||
};
|
||||
@@ -378,11 +416,14 @@ impl DbxMcpServer {
|
||||
|
||||
#[tool(name = "dbx_describe_table", description = "Get column definitions for a table")]
|
||||
async fn describe_table(&self, Parameters(request): Parameters<DescribeTableRequest>) -> CallToolResult {
|
||||
if let Err(error) = self.ensure_tool_allowed("dbx_describe_table").await {
|
||||
return error;
|
||||
}
|
||||
let resolved = match self.resolve_connection(&request.selector).await {
|
||||
Ok(resolved) => resolved,
|
||||
Err(error) => return error,
|
||||
};
|
||||
let database = match self.resolve_database(request.database, &resolved.connection) {
|
||||
let database = match self.resolve_database(request.database, &resolved) {
|
||||
Ok(database) => database,
|
||||
Err(error) => return error,
|
||||
};
|
||||
@@ -402,6 +443,9 @@ impl DbxMcpServer {
|
||||
description = "Execute a SQL query on a database connection (max 100 rows returned)"
|
||||
)]
|
||||
async fn execute_query(&self, Parameters(request): Parameters<ExecuteQueryRequest>) -> CallToolResult {
|
||||
if let Err(error) = self.ensure_tool_allowed("dbx_execute_query").await {
|
||||
return error;
|
||||
}
|
||||
let explicit_cell_window = (request.cell_char_offset.is_some() || request.cell_char_limit.is_some())
|
||||
.then(|| QueryCellWindow::from_options(request.cell_char_offset, request.cell_char_limit));
|
||||
let resolved = match self.resolve_connection(&request.selector).await {
|
||||
@@ -415,6 +459,13 @@ impl DbxMcpServer {
|
||||
"Redis connections do not accept SQL through dbx_execute_query. Use dbx_execute_redis_command.",
|
||||
);
|
||||
}
|
||||
// Database discovery does not require a default database. In a
|
||||
// narrowed MCP scope it must never reveal names outside the allowlist,
|
||||
// so serve it directly from the scoped discovery path instead of
|
||||
// passing SHOW DATABASES to the database driver.
|
||||
if is_database_discovery_sql(&request.sql) {
|
||||
return self.list_databases_for_resolved(&resolved).await;
|
||||
}
|
||||
// Resolve the session before the database so its connection/database
|
||||
// binding is enforced on every stateful query.
|
||||
let session = match request.session_id.as_deref().map(str::trim).filter(|id| !id.is_empty()) {
|
||||
@@ -441,7 +492,7 @@ impl DbxMcpServer {
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let database = match self.resolve_database(request.database, connection) {
|
||||
let database = match self.resolve_database(request.database, &resolved) {
|
||||
Ok(database) => database,
|
||||
Err(error) => return error,
|
||||
};
|
||||
@@ -457,7 +508,13 @@ impl DbxMcpServer {
|
||||
}
|
||||
}
|
||||
if connection.db_type == DatabaseType::MongoDb {
|
||||
let command = match validate_mongo_command(connection, &resolved.policy, &database, &request.sql) {
|
||||
let command = match validate_mongo_command(
|
||||
connection,
|
||||
&resolved.policy,
|
||||
&resolved.database_scope,
|
||||
&database,
|
||||
&request.sql,
|
||||
) {
|
||||
Ok(command) => command,
|
||||
Err(error) => return error,
|
||||
};
|
||||
@@ -476,6 +533,12 @@ impl DbxMcpServer {
|
||||
// switching is allowed — unless a hard database scope is configured,
|
||||
// which a USE statement could otherwise escape.
|
||||
let allow_database_switch = session.is_some() && self.scope.database.is_none();
|
||||
if connection.db_type != DatabaseType::MongoDb {
|
||||
if let Err(error) = ensure_sql_database_scope(&resolved.database_scope, connection, &database, &request.sql)
|
||||
{
|
||||
return error;
|
||||
}
|
||||
}
|
||||
let permissions =
|
||||
match validate_sql_policy(connection, &resolved.policy, &database, &request.sql, allow_database_switch) {
|
||||
Ok(permissions) => permissions,
|
||||
@@ -512,6 +575,9 @@ impl DbxMcpServer {
|
||||
description = "Open a stateful query session pinned to a single backend connection. Returns a session ID for dbx_execute_query: USE, SET CATALOG, session variables and temporary tables persist across calls within the session. Close with dbx_close_session when done; idle sessions expire after 30 minutes."
|
||||
)]
|
||||
async fn open_session(&self, Parameters(request): Parameters<OpenSessionRequest>) -> CallToolResult {
|
||||
if let Err(error) = self.ensure_tool_allowed("dbx_open_session").await {
|
||||
return error;
|
||||
}
|
||||
let resolved = match self.resolve_connection(&request.selector).await {
|
||||
Ok(resolved) => resolved,
|
||||
Err(error) => return error,
|
||||
@@ -523,7 +589,7 @@ impl DbxMcpServer {
|
||||
format!("Sessions are only supported for SQL connections; \"{}\" is not one.", connection.name),
|
||||
);
|
||||
}
|
||||
let database = match self.resolve_database(request.database, connection) {
|
||||
let database = match self.resolve_database(request.database, &resolved) {
|
||||
Ok(database) => database,
|
||||
Err(error) => return error,
|
||||
};
|
||||
@@ -543,6 +609,9 @@ impl DbxMcpServer {
|
||||
description = "Close a stateful query session and release its pinned backend connection"
|
||||
)]
|
||||
async fn close_session(&self, Parameters(request): Parameters<CloseSessionRequest>) -> CallToolResult {
|
||||
if let Err(error) = self.ensure_tool_allowed("dbx_close_session").await {
|
||||
return error;
|
||||
}
|
||||
let (session, expired) = self.sessions.begin_close(&request.session_id).await.into_parts();
|
||||
self.close_backend_sessions_best_effort(expired).await;
|
||||
let Some(session) = session else {
|
||||
@@ -572,6 +641,9 @@ impl DbxMcpServer {
|
||||
&self,
|
||||
Parameters(request): Parameters<ExecuteRedisCommandRequest>,
|
||||
) -> CallToolResult {
|
||||
if let Err(error) = self.ensure_tool_allowed("dbx_execute_redis_command").await {
|
||||
return error;
|
||||
}
|
||||
let resolved = match self.resolve_connection(&request.selector).await {
|
||||
Ok(resolved) => resolved,
|
||||
Err(error) => return error,
|
||||
@@ -610,7 +682,7 @@ impl DbxMcpServer {
|
||||
"MCP Redis command execution is read-only in DBX MCP settings.",
|
||||
);
|
||||
}
|
||||
let database = match self.resolve_redis_database(request.db, connection) {
|
||||
let database = match self.resolve_redis_database(request.db, &resolved) {
|
||||
Ok(database) => database,
|
||||
Err(error) => return error,
|
||||
};
|
||||
@@ -641,6 +713,9 @@ impl DbxMcpServer {
|
||||
description = "Send a base64-encoded message to a Kafka, RocketMQ, or RabbitMQ topic/queue"
|
||||
)]
|
||||
async fn send_message(&self, Parameters(request): Parameters<SendMessageRequest>) -> CallToolResult {
|
||||
if let Err(error) = self.ensure_tool_allowed("dbx_send_message").await {
|
||||
return error;
|
||||
}
|
||||
#[cfg(not(feature = "mq-admin"))]
|
||||
{
|
||||
let _ = request;
|
||||
@@ -702,12 +777,15 @@ impl DbxMcpServer {
|
||||
|
||||
#[tool(name = "dbx_get_schema_context", description = "Get compact table and column context for writing SQL")]
|
||||
async fn get_schema_context(&self, Parameters(request): Parameters<SchemaContextRequest>) -> CallToolResult {
|
||||
if let Err(error) = self.ensure_tool_allowed("dbx_get_schema_context").await {
|
||||
return error;
|
||||
}
|
||||
let resolved = match self.resolve_connection(&request.selector).await {
|
||||
Ok(resolved) => resolved,
|
||||
Err(error) => return error,
|
||||
};
|
||||
let connection = &resolved.connection;
|
||||
let database = match self.resolve_database(request.database, connection) {
|
||||
let database = match self.resolve_database(request.database, &resolved) {
|
||||
Ok(database) => database,
|
||||
Err(error) => return error,
|
||||
};
|
||||
@@ -750,6 +828,9 @@ impl DbxMcpServer {
|
||||
|
||||
#[tool(name = "dbx_add_connection", description = "Add a new database connection to DBX")]
|
||||
async fn add_connection(&self, Parameters(request): Parameters<AddConnectionRequest>) -> CallToolResult {
|
||||
if let Err(error) = self.ensure_tool_allowed("dbx_add_connection").await {
|
||||
return error;
|
||||
}
|
||||
let policy = match self.load_policy().await {
|
||||
Ok(policy) => policy,
|
||||
Err(error) => return error,
|
||||
@@ -804,6 +885,9 @@ impl DbxMcpServer {
|
||||
&self,
|
||||
Parameters(request): Parameters<DuplicateConnectionRequest>,
|
||||
) -> CallToolResult {
|
||||
if let Err(error) = self.ensure_tool_allowed("dbx_duplicate_connection").await {
|
||||
return error;
|
||||
}
|
||||
let policy = match self.load_policy().await {
|
||||
Ok(policy) => policy,
|
||||
Err(error) => return error,
|
||||
@@ -859,6 +943,9 @@ impl DbxMcpServer {
|
||||
|
||||
#[tool(name = "dbx_remove_connection", description = "Remove a database connection from DBX")]
|
||||
async fn remove_connection(&self, Parameters(request): Parameters<RemoveConnectionRequest>) -> CallToolResult {
|
||||
if let Err(error) = self.ensure_tool_allowed("dbx_remove_connection").await {
|
||||
return error;
|
||||
}
|
||||
let policy = match self.load_policy().await {
|
||||
Ok(policy) => policy,
|
||||
Err(error) => return error,
|
||||
@@ -876,22 +963,38 @@ impl DbxMcpServer {
|
||||
let target = if let Some(id) = request.connection_id.as_deref().map(str::trim).filter(|id| !id.is_empty()) {
|
||||
connections.iter().find(|connection| connection.id == id).cloned()
|
||||
} else {
|
||||
let Some(name) = request.connection_name.as_deref().map(str::trim).filter(|name| !name.is_empty()) else {
|
||||
return tool_error("CONNECTION_NOT_FOUND", "Either connection_id or connection_name is required.");
|
||||
};
|
||||
let matching = connections
|
||||
.iter()
|
||||
.filter(|connection| connection.name.eq_ignore_ascii_case(&request.connection_name))
|
||||
.filter(|connection| connection.name.eq_ignore_ascii_case(name))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if matching.len() > 1 {
|
||||
return tool_error("AMBIGUOUS_CONNECTION", ambiguous_connections(&request.connection_name, &matching));
|
||||
return tool_error("AMBIGUOUS_CONNECTION", ambiguous_connections(name, &matching));
|
||||
}
|
||||
matching.into_iter().next()
|
||||
};
|
||||
let Some(target) = target else {
|
||||
return tool_error(
|
||||
"CONNECTION_NOT_FOUND",
|
||||
format!("Connection \"{}\" not found.", request.connection_name),
|
||||
request
|
||||
.connection_id
|
||||
.as_deref()
|
||||
.filter(|id| !id.trim().is_empty())
|
||||
.map(|id| format!("Connection with id \"{id}\" not found."))
|
||||
.unwrap_or_else(|| {
|
||||
format!("Connection \"{}\" not found.", request.connection_name.as_deref().unwrap_or_default())
|
||||
}),
|
||||
);
|
||||
};
|
||||
if !policy_allows_connection(&policy, &target) {
|
||||
return tool_error(
|
||||
"CONNECTION_OUT_OF_SCOPE",
|
||||
format!("Connection \"{}\" is not allowed by DBX MCP settings.", target.id),
|
||||
);
|
||||
}
|
||||
match self.backend.remove_connection_for_mcp(&target.id).await {
|
||||
Ok(true) => text(format!("Connection \"{}\" (id: {}) removed.", target.name, target.id)),
|
||||
Ok(false) => tool_error("CONNECTION_NOT_FOUND", format!("Connection \"{}\" not found.", target.name)),
|
||||
@@ -901,12 +1004,15 @@ impl DbxMcpServer {
|
||||
|
||||
#[tool(name = "dbx_open_table", description = "Open a table in DBX desktop app. Requires DBX to be running.")]
|
||||
async fn open_table(&self, Parameters(request): Parameters<OpenTableRequest>) -> CallToolResult {
|
||||
if let Err(error) = self.ensure_tool_allowed("dbx_open_table").await {
|
||||
return error;
|
||||
}
|
||||
let resolved = match self.resolve_connection(&request.selector).await {
|
||||
Ok(resolved) => resolved,
|
||||
Err(error) => return error,
|
||||
};
|
||||
let connection = &resolved.connection;
|
||||
let database = match self.resolve_database(request.database, connection) {
|
||||
let database = match self.resolve_database(request.database, &resolved) {
|
||||
Ok(database) => database,
|
||||
Err(error) => return error,
|
||||
};
|
||||
@@ -938,6 +1044,9 @@ impl DbxMcpServer {
|
||||
description = "Execute a SQL query in DBX desktop app UI and show results there. Requires DBX to be running."
|
||||
)]
|
||||
async fn execute_and_show(&self, Parameters(request): Parameters<ExecuteAndShowRequest>) -> CallToolResult {
|
||||
if let Err(error) = self.ensure_tool_allowed("dbx_execute_and_show").await {
|
||||
return error;
|
||||
}
|
||||
let resolved = match self.resolve_connection(&request.selector).await {
|
||||
Ok(resolved) => resolved,
|
||||
Err(error) => return error,
|
||||
@@ -946,10 +1055,16 @@ impl DbxMcpServer {
|
||||
if connection.db_type == DatabaseType::Redis {
|
||||
return tool_error("REDIS_COMMAND_REQUIRED", "Use dbx_execute_redis_command for Redis connections.");
|
||||
}
|
||||
let database = match self.resolve_database(request.database, connection) {
|
||||
let database = match self.resolve_database(request.database, &resolved) {
|
||||
Ok(database) => database,
|
||||
Err(error) => return error,
|
||||
};
|
||||
if connection.db_type != DatabaseType::MongoDb {
|
||||
if let Err(error) = ensure_sql_database_scope(&resolved.database_scope, connection, &database, &request.sql)
|
||||
{
|
||||
return error;
|
||||
}
|
||||
}
|
||||
let permissions = if connection.db_type == DatabaseType::MongoDb {
|
||||
mcp_permissions(connection, &resolved.policy)
|
||||
} else {
|
||||
@@ -959,7 +1074,9 @@ impl DbxMcpServer {
|
||||
}
|
||||
};
|
||||
if connection.db_type == DatabaseType::MongoDb {
|
||||
if let Err(error) = validate_mongo_command(connection, &resolved.policy, &database, &request.sql) {
|
||||
if let Err(error) =
|
||||
validate_mongo_command(connection, &resolved.policy, &resolved.database_scope, &database, &request.sql)
|
||||
{
|
||||
return error;
|
||||
}
|
||||
}
|
||||
@@ -985,6 +1102,20 @@ impl DbxMcpServer {
|
||||
}
|
||||
|
||||
impl DbxMcpServer {
|
||||
async fn list_databases_for_resolved(&self, resolved: &ResolvedConnection) -> CallToolResult {
|
||||
match &resolved.database_scope {
|
||||
DatabaseScope::None => tool_error(
|
||||
"DATABASE_OUT_OF_SCOPE",
|
||||
"This connection is configured with no database access in DBX MCP settings.",
|
||||
),
|
||||
DatabaseScope::Selected(databases) => text(format_database_names(databases)),
|
||||
DatabaseScope::All => match self.backend.list_databases(&resolved.connection).await {
|
||||
Ok(databases) => text(format_database_names(&databases)),
|
||||
Err(error) => tool_error("DATABASE_LIST_ERROR", error),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_scoped_connections(&self) -> Result<Vec<dbx_core::models::connection::ConnectionConfig>, String> {
|
||||
let policy = self.backend.load_mcp_global_policy().await?;
|
||||
let connections = self.backend.load_connections().await?;
|
||||
@@ -999,15 +1130,40 @@ impl DbxMcpServer {
|
||||
self.backend.load_mcp_global_policy().await.map_err(|error| backend_tool_error("MCP_POLICY_UNAVAILABLE", error))
|
||||
}
|
||||
|
||||
async fn ensure_tool_allowed(&self, tool_name: &str) -> Result<(), CallToolResult> {
|
||||
let policy = self.load_policy().await?;
|
||||
if policy_allows_tool(&policy, tool_name) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(tool_error("TOOL_OUT_OF_SCOPE", format!("Tool \"{tool_name}\" is not allowed by DBX MCP settings.")))
|
||||
}
|
||||
}
|
||||
|
||||
/// The advertised `tools/list` view: router-enabled tools narrowed to the
|
||||
/// global policy's tool allowlist. When the policy cannot be loaded every
|
||||
/// tool call already fails with `MCP_POLICY_UNAVAILABLE`, so fall back to
|
||||
/// the plain router view rather than adding a new failure mode here.
|
||||
async fn policy_filtered_tools(&self) -> Vec<rmcp::model::Tool> {
|
||||
match self.load_policy().await {
|
||||
Ok(policy) => self
|
||||
.tool_router
|
||||
.list_all()
|
||||
.into_iter()
|
||||
.filter(|tool| policy_allows_tool(&policy, tool.name.as_ref()))
|
||||
.collect(),
|
||||
Err(_) => self.tool_router.list_all(),
|
||||
}
|
||||
}
|
||||
|
||||
// CallToolResult is the rmcp wire response type; keeping it unboxed avoids conversions at every tool boundary.
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn resolve_database(
|
||||
&self,
|
||||
requested: Option<String>,
|
||||
connection: &dbx_core::models::connection::ConnectionConfig,
|
||||
resolved: &ResolvedConnection,
|
||||
) -> Result<String, CallToolResult> {
|
||||
let requested = requested.map(|database| database.trim().to_string()).filter(|database| !database.is_empty());
|
||||
if let Some(scoped) = self.scope.database.as_deref() {
|
||||
let database = if let Some(scoped) = self.scope.database.as_deref() {
|
||||
if let Some(requested) = requested.as_deref() {
|
||||
if requested != scoped {
|
||||
return Err(tool_error(
|
||||
@@ -1016,9 +1172,12 @@ impl DbxMcpServer {
|
||||
));
|
||||
}
|
||||
}
|
||||
return Ok(scoped.to_string());
|
||||
}
|
||||
Ok(requested.or_else(|| connection.database.clone()).unwrap_or_default())
|
||||
scoped.to_string()
|
||||
} else {
|
||||
requested.or_else(|| resolved.connection.database.clone()).unwrap_or_default()
|
||||
};
|
||||
ensure_database_in_scope(&resolved.database_scope, &database)?;
|
||||
Ok(database)
|
||||
}
|
||||
|
||||
/// Resolve the schema for scoped CLI agents. A selected schema is a hard
|
||||
@@ -1045,7 +1204,7 @@ impl DbxMcpServer {
|
||||
fn resolve_redis_database(
|
||||
&self,
|
||||
requested: Option<u32>,
|
||||
connection: &dbx_core::models::connection::ConnectionConfig,
|
||||
resolved: &ResolvedConnection,
|
||||
) -> Result<u32, CallToolResult> {
|
||||
if let Some(scoped) = self.scope.database.as_deref() {
|
||||
let scoped_database = parse_redis_database(scoped).ok_or_else(|| {
|
||||
@@ -1062,9 +1221,12 @@ impl DbxMcpServer {
|
||||
));
|
||||
}
|
||||
}
|
||||
ensure_database_in_scope(&resolved.database_scope, &scoped_database.to_string())?;
|
||||
return Ok(scoped_database);
|
||||
}
|
||||
Ok(requested.or_else(|| redis_database(connection)).unwrap_or(0))
|
||||
let database = requested.or_else(|| redis_database(&resolved.connection)).unwrap_or(0);
|
||||
ensure_database_in_scope(&resolved.database_scope, &database.to_string())?;
|
||||
Ok(database)
|
||||
}
|
||||
|
||||
async fn resolve_connection(&self, selector: &ConnectionSelector) -> Result<ResolvedConnection, CallToolResult> {
|
||||
@@ -1088,7 +1250,7 @@ impl DbxMcpServer {
|
||||
format!("Connection \"{id}\" is not allowed by DBX MCP settings."),
|
||||
));
|
||||
}
|
||||
return Ok(ResolvedConnection { connection, policy });
|
||||
return Ok(resolved_connection(policy, connection));
|
||||
}
|
||||
if self.scope.connection_scope_enabled() {
|
||||
let connection = connections
|
||||
@@ -1109,7 +1271,7 @@ impl DbxMcpServer {
|
||||
"The DBX AI session scope is outside the global MCP connection allowlist.",
|
||||
));
|
||||
}
|
||||
return Ok(ResolvedConnection { connection, policy });
|
||||
return Ok(resolved_connection(policy, connection));
|
||||
}
|
||||
let Some(name) = selector.connection_name.as_deref().map(str::trim).filter(|name| !name.is_empty()) else {
|
||||
return Err(tool_error("CONNECTION_NOT_FOUND", "Either connection_id or connection_name is required."));
|
||||
@@ -1129,7 +1291,7 @@ impl DbxMcpServer {
|
||||
"CONNECTION_OUT_OF_SCOPE",
|
||||
format!("Connection \"{name}\" is not allowed by DBX MCP settings."),
|
||||
)),
|
||||
[connection] => Ok(ResolvedConnection { connection: connection.clone(), policy }),
|
||||
[connection] => Ok(resolved_connection(policy, connection.clone())),
|
||||
_ => Err(tool_error("AMBIGUOUS_CONNECTION", ambiguous_connections(name, &allowed))),
|
||||
}
|
||||
}
|
||||
@@ -1142,6 +1304,21 @@ impl ServerHandler for DbxMcpServer {
|
||||
.with_server_info(Implementation::new("dbx", env!("CARGO_PKG_VERSION")))
|
||||
.with_instructions("Use DBX connections to inspect schemas and query databases safely.")
|
||||
}
|
||||
|
||||
/// Hide tools the global policy disallows from the advertised list, the
|
||||
/// same way scope-based route disabling does. Per-call enforcement stays
|
||||
/// in `ensure_tool_allowed`; this only stops clients (especially LLMs)
|
||||
/// from seeing — and repeatedly retrying — capabilities that would be
|
||||
/// rejected anyway. When the policy cannot be loaded, every call already
|
||||
/// fails with `MCP_POLICY_UNAVAILABLE`, so the listing falls back to the
|
||||
/// router view instead of adding a new failure mode here.
|
||||
async fn list_tools(
|
||||
&self,
|
||||
_request: Option<rmcp::model::PaginatedRequestParams>,
|
||||
_context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
|
||||
) -> Result<rmcp::model::ListToolsResult, rmcp::ErrorData> {
|
||||
Ok(rmcp::model::ListToolsResult { tools: self.policy_filtered_tools().await, meta: None, next_cursor: None })
|
||||
}
|
||||
}
|
||||
|
||||
fn text(value: impl Into<String>) -> CallToolResult {
|
||||
@@ -1188,6 +1365,101 @@ fn policy_allows_connection(
|
||||
policy.allowed_connection_ids.as_ref().is_none_or(|allowed| allowed.iter().any(|id| id == &connection.id))
|
||||
}
|
||||
|
||||
fn policy_allows_tool(policy: &McpGlobalPolicy, tool_name: &str) -> bool {
|
||||
policy.allowed_tool_names.as_ref().is_none_or(|allowed| allowed.iter().any(|name| name == tool_name))
|
||||
}
|
||||
|
||||
fn database_scope_for_connection(
|
||||
policy: &McpGlobalPolicy,
|
||||
connection: &dbx_core::models::connection::ConnectionConfig,
|
||||
) -> DatabaseScope {
|
||||
let Some(rule) = policy.connection_policies.iter().find(|rule| rule.connection_id == connection.id) else {
|
||||
return DatabaseScope::All;
|
||||
};
|
||||
match rule.database_scope {
|
||||
McpDatabaseScope::All => DatabaseScope::All,
|
||||
McpDatabaseScope::Selected => DatabaseScope::Selected(rule.allowed_databases.clone()),
|
||||
McpDatabaseScope::None => DatabaseScope::None,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolved_connection(
|
||||
policy: McpGlobalPolicy,
|
||||
connection: dbx_core::models::connection::ConnectionConfig,
|
||||
) -> ResolvedConnection {
|
||||
let database_scope = database_scope_for_connection(&policy, &connection);
|
||||
let policy = effective_policy_for_connection(policy, &connection);
|
||||
ResolvedConnection { connection, policy, database_scope }
|
||||
}
|
||||
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn ensure_database_in_scope(scope: &DatabaseScope, database: &str) -> Result<(), CallToolResult> {
|
||||
match scope {
|
||||
DatabaseScope::All => Ok(()),
|
||||
DatabaseScope::Selected(allowed) if allowed.iter().any(|allowed| allowed == database) => Ok(()),
|
||||
DatabaseScope::Selected(_) | DatabaseScope::None => Err(tool_error(
|
||||
"DATABASE_OUT_OF_SCOPE",
|
||||
format!("Database \"{database}\" is not allowed by DBX MCP settings for this connection."),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A selected database scope applies to SQL object references as well as the
|
||||
/// request-level `database` field. Without this guard an MCP client could pick
|
||||
/// an allowed default and access another MySQL/SQL Server database through a
|
||||
/// qualified name such as `other_db.users`.
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn ensure_sql_database_scope(
|
||||
scope: &DatabaseScope,
|
||||
connection: &dbx_core::models::connection::ConnectionConfig,
|
||||
active_database: &str,
|
||||
sql: &str,
|
||||
) -> Result<(), CallToolResult> {
|
||||
let DatabaseScope::Selected(allowed_databases) = scope else {
|
||||
return Ok(());
|
||||
};
|
||||
if sql_references_disallowed_database(sql, &connection.db_type, active_database, allowed_databases) {
|
||||
return Err(tool_error(
|
||||
"DATABASE_OUT_OF_SCOPE",
|
||||
"SQL references a database that is not allowed by DBX MCP settings for this connection.",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_database_discovery_sql(sql: &str) -> bool {
|
||||
let normalized = sql.trim().trim_end_matches(';').trim().to_ascii_lowercase();
|
||||
matches!(normalized.as_str(), "show databases" | "show schemas")
|
||||
}
|
||||
|
||||
fn format_database_names(databases: &[String]) -> String {
|
||||
if databases.is_empty() {
|
||||
"No databases are available through this MCP connection.".to_string()
|
||||
} else {
|
||||
databases.iter().map(|database| format!("- {database}")).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
/// A connection-level rule is a restrictive overlay, never an escalation. It
|
||||
/// therefore composes safely with the global policy, the connection's own
|
||||
/// read-only flag, and production-database protections checked elsewhere.
|
||||
fn effective_policy_for_connection(
|
||||
mut policy: McpGlobalPolicy,
|
||||
connection: &dbx_core::models::connection::ConnectionConfig,
|
||||
) -> McpGlobalPolicy {
|
||||
if let Some(rule) = policy.connection_policies.iter().find(|rule| rule.connection_id == connection.id) {
|
||||
if rule.execution_mode_configured {
|
||||
policy.read_only |= rule.read_only;
|
||||
policy.allow_dangerous_sql &= !rule.read_only && rule.allow_dangerous_sql;
|
||||
}
|
||||
}
|
||||
// This returned value is carried through the request only; retaining a
|
||||
// complete policy document here could accidentally be reused for another
|
||||
// connection by a future caller.
|
||||
policy.connection_policies.clear();
|
||||
policy
|
||||
}
|
||||
|
||||
fn mcp_permissions(
|
||||
connection: &dbx_core::models::connection::ConnectionConfig,
|
||||
policy: &McpGlobalPolicy,
|
||||
@@ -1261,6 +1533,7 @@ fn validate_sql_policy(
|
||||
fn validate_mongo_command(
|
||||
connection: &dbx_core::models::connection::ConnectionConfig,
|
||||
policy: &McpGlobalPolicy,
|
||||
database_scope: &DatabaseScope,
|
||||
database: &str,
|
||||
source: &str,
|
||||
) -> Result<MongoCommand, CallToolResult> {
|
||||
@@ -1278,6 +1551,11 @@ fn validate_mongo_command(
|
||||
"MongoDB runCommand is not available through MCP; review and execute it manually in DBX.",
|
||||
));
|
||||
}
|
||||
if let MongoCommand::Aggregate { pipeline, .. } = &command {
|
||||
for target_database in mongo_aggregate_target_databases(pipeline, database)? {
|
||||
ensure_database_in_scope(database_scope, &target_database)?;
|
||||
}
|
||||
}
|
||||
let permissions = mcp_permissions(connection, policy);
|
||||
let production_database = match &command {
|
||||
MongoCommand::Aggregate { pipeline, .. } => {
|
||||
@@ -1309,6 +1587,40 @@ fn validate_mongo_command(
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn mongo_aggregate_target_databases(pipeline: &str, active_database: &str) -> Result<Vec<String>, CallToolResult> {
|
||||
let stages = serde_json::from_str::<serde_json::Value>(pipeline)
|
||||
.ok()
|
||||
.and_then(|value| value.as_array().cloned())
|
||||
.ok_or_else(|| tool_error("QUERY_ERROR", "MongoDB aggregate pipeline must be a JSON array."))?;
|
||||
let mut databases = Vec::new();
|
||||
for stage in stages {
|
||||
let Some(stage) = stage.as_object() else { continue };
|
||||
for key in ["$out", "$merge"] {
|
||||
let Some(target) = stage.get(key) else { continue };
|
||||
let target_database = match target {
|
||||
serde_json::Value::String(_) => active_database.to_string(),
|
||||
serde_json::Value::Object(target) => target
|
||||
.get("db")
|
||||
.or_else(|| {
|
||||
target.get("into").and_then(serde_json::Value::as_object).and_then(|into| into.get("db"))
|
||||
})
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or(active_database)
|
||||
.to_string(),
|
||||
_ => {
|
||||
return Err(tool_error(
|
||||
"QUERY_ERROR",
|
||||
"MongoDB aggregate output target must be a string or object.",
|
||||
))
|
||||
}
|
||||
};
|
||||
databases.push(target_database);
|
||||
}
|
||||
}
|
||||
Ok(databases)
|
||||
}
|
||||
|
||||
fn non_empty_env(name: &str) -> Option<String> {
|
||||
std::env::var(name).ok().map(|value| value.trim().to_string()).filter(|value| !value.is_empty())
|
||||
}
|
||||
@@ -1462,6 +1774,7 @@ mod tests {
|
||||
|
||||
struct FakeBackend {
|
||||
connections: Vec<ConnectionConfig>,
|
||||
policy: McpGlobalPolicy,
|
||||
recorded_arguments: std::sync::Mutex<Vec<(String, serde_json::Value)>>,
|
||||
closed_sessions: std::sync::Mutex<Vec<String>>,
|
||||
pinned_sessions: std::sync::Mutex<HashSet<String>>,
|
||||
@@ -1472,6 +1785,7 @@ mod tests {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
connections: Vec::new(),
|
||||
policy: McpGlobalPolicy::default(),
|
||||
recorded_arguments: std::sync::Mutex::new(Vec::new()),
|
||||
closed_sessions: std::sync::Mutex::new(Vec::new()),
|
||||
pinned_sessions: std::sync::Mutex::new(HashSet::new()),
|
||||
@@ -1495,6 +1809,10 @@ mod tests {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn resolved_connection_for_test(connection: ConnectionConfig) -> ResolvedConnection {
|
||||
ResolvedConnection { connection, policy: McpGlobalPolicy::default(), database_scope: DatabaseScope::All }
|
||||
}
|
||||
|
||||
fn result_text(result: &CallToolResult) -> &str {
|
||||
result.content[0].as_text().expect("text tool result").text.as_str()
|
||||
}
|
||||
@@ -1510,7 +1828,7 @@ mod tests {
|
||||
#[async_trait]
|
||||
impl DbxBackend for FakeBackend {
|
||||
async fn load_mcp_global_policy(&self) -> Result<McpGlobalPolicy, String> {
|
||||
Ok(McpGlobalPolicy::default())
|
||||
Ok(self.policy.clone())
|
||||
}
|
||||
|
||||
async fn load_connections(&self) -> Result<Vec<ConnectionConfig>, String> {
|
||||
@@ -1605,10 +1923,11 @@ mod tests {
|
||||
let tools = server.tool_router.list_all();
|
||||
let names = tools.iter().map(|tool| tool.name.as_ref()).collect::<Vec<_>>();
|
||||
#[cfg(feature = "mq-admin")]
|
||||
assert_eq!(tools.len(), 14);
|
||||
assert_eq!(tools.len(), 15);
|
||||
#[cfg(not(feature = "mq-admin"))]
|
||||
assert_eq!(tools.len(), 13);
|
||||
assert_eq!(tools.len(), 14);
|
||||
assert!(names.contains(&"dbx_list_connections"));
|
||||
assert!(names.contains(&"dbx_list_databases"));
|
||||
assert!(names.contains(&"dbx_list_tables"));
|
||||
assert!(names.contains(&"dbx_describe_table"));
|
||||
assert!(names.contains(&"dbx_execute_query"));
|
||||
@@ -1625,6 +1944,33 @@ mod tests {
|
||||
assert!(names.contains(&"dbx_send_message"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tools_list_hides_tools_the_policy_disallows() {
|
||||
let policy = McpGlobalPolicy {
|
||||
allowed_tool_names: Some(vec!["dbx_list_connections".to_string(), "dbx_execute_query".to_string()]),
|
||||
..Default::default()
|
||||
};
|
||||
let server = DbxMcpServer::with_runtime_options(
|
||||
Arc::new(FakeBackend { policy, ..Default::default() }),
|
||||
McpScope::default(),
|
||||
false,
|
||||
);
|
||||
|
||||
let tools = server.policy_filtered_tools().await;
|
||||
let names = tools.iter().map(|tool| tool.name.as_ref()).collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(names, vec!["dbx_execute_query", "dbx_list_connections"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tools_list_keeps_the_full_view_without_an_allowlist() {
|
||||
let server = DbxMcpServer::with_runtime_options(Arc::new(FakeBackend::default()), McpScope::default(), false);
|
||||
|
||||
let tools = server.policy_filtered_tools().await;
|
||||
|
||||
assert_eq!(tools.len(), server.tool_router.list_all().len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_context_tables_schema_is_gemini_compatible() {
|
||||
let server = DbxMcpServer::with_runtime_options(Arc::new(FakeBackend::default()), McpScope::default(), false);
|
||||
@@ -1699,7 +2045,7 @@ mod tests {
|
||||
("dbx_open_table", &["database", "schema"]),
|
||||
("dbx_execute_and_show", &["database"]),
|
||||
("dbx_add_connection", &["port", "database", "driver_profile"]),
|
||||
("dbx_remove_connection", &["connection_id"]),
|
||||
("dbx_remove_connection", &["connection_id", "connection_name"]),
|
||||
("dbx_execute_redis_command", &["db"]),
|
||||
("dbx_get_schema_context", &["database", "schema", "max_tables"]),
|
||||
];
|
||||
@@ -1817,9 +2163,9 @@ mod tests {
|
||||
);
|
||||
let names = server.tool_router.list_all().into_iter().map(|tool| tool.name).collect::<Vec<_>>();
|
||||
#[cfg(feature = "mq-admin")]
|
||||
assert_eq!(names.len(), 9);
|
||||
assert_eq!(names.len(), 10);
|
||||
#[cfg(not(feature = "mq-admin"))]
|
||||
assert_eq!(names.len(), 8);
|
||||
assert_eq!(names.len(), 9);
|
||||
assert!(!names.iter().any(|name| name == "dbx_add_connection"));
|
||||
assert!(!names.iter().any(|name| name == "dbx_duplicate_connection"));
|
||||
assert!(!names.iter().any(|name| name == "dbx_remove_connection"));
|
||||
@@ -1858,9 +2204,10 @@ mod tests {
|
||||
);
|
||||
|
||||
assert_eq!(server.load_scoped_connections().await.unwrap().len(), 1);
|
||||
assert_eq!(server.resolve_database(None, &scoped).unwrap(), "analytics");
|
||||
assert_eq!(server.resolve_database(Some("analytics".to_string()), &scoped).unwrap(), "analytics");
|
||||
let error = server.resolve_database(Some("production".to_string()), &scoped).unwrap_err();
|
||||
let resolved = resolved_connection_for_test(scoped.clone());
|
||||
assert_eq!(server.resolve_database(None, &resolved).unwrap(), "analytics");
|
||||
assert_eq!(server.resolve_database(Some("analytics".to_string()), &resolved).unwrap(), "analytics");
|
||||
let error = server.resolve_database(Some("production".to_string()), &resolved).unwrap_err();
|
||||
assert!(result_text(&error).contains("DATABASE_OUT_OF_SCOPE"));
|
||||
|
||||
let names = server.tool_router.list_all().into_iter().map(|tool| tool.name).collect::<Vec<_>>();
|
||||
@@ -1868,6 +2215,78 @@ mod tests {
|
||||
assert!(!names.iter().any(|name| name == "dbx_execute_and_show"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_database_allowlist_is_enforced_for_sql_and_redis_targets() {
|
||||
let sql = connection("sql", "sql", "mysql", "reporting");
|
||||
let selected = ResolvedConnection {
|
||||
connection: sql,
|
||||
policy: McpGlobalPolicy::default(),
|
||||
database_scope: DatabaseScope::Selected(vec!["reporting".to_string()]),
|
||||
};
|
||||
let server = DbxMcpServer::with_runtime_options(Arc::new(FakeBackend::default()), McpScope::default(), false);
|
||||
assert_eq!(server.resolve_database(Some("reporting".to_string()), &selected).unwrap(), "reporting");
|
||||
let error = server.resolve_database(Some("production".to_string()), &selected).unwrap_err();
|
||||
assert!(result_text(&error).contains("DATABASE_OUT_OF_SCOPE"));
|
||||
|
||||
let redis = ResolvedConnection {
|
||||
connection: connection("redis", "redis", "redis", "0"),
|
||||
policy: McpGlobalPolicy::default(),
|
||||
database_scope: DatabaseScope::Selected(vec!["2".to_string()]),
|
||||
};
|
||||
assert_eq!(server.resolve_redis_database(Some(2), &redis).unwrap(), 2);
|
||||
let error = server.resolve_redis_database(Some(3), &redis).unwrap_err();
|
||||
assert!(result_text(&error).contains("DATABASE_OUT_OF_SCOPE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_database_allowlist_blocks_qualified_sql_references() {
|
||||
let connection = connection("sql", "sql", "mysql", "reporting");
|
||||
let scope = DatabaseScope::Selected(vec!["reporting".to_string()]);
|
||||
|
||||
assert!(ensure_sql_database_scope(&scope, &connection, "reporting", "SELECT * FROM reporting.users").is_ok());
|
||||
let error =
|
||||
ensure_sql_database_scope(&scope, &connection, "reporting", "SELECT * FROM production.users").unwrap_err();
|
||||
assert!(result_text(&error).contains("DATABASE_OUT_OF_SCOPE"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn selected_database_scope_can_be_discovered_without_a_connection_default_database() {
|
||||
let connection = connection("sql", "sql", "mysql", "");
|
||||
let backend = Arc::new(FakeBackend {
|
||||
connections: vec![connection],
|
||||
policy: McpGlobalPolicy {
|
||||
allowed_connection_ids: Some(vec!["sql".to_string()]),
|
||||
connection_policies: vec![dbx_core::storage::McpConnectionPolicy {
|
||||
connection_id: "sql".to_string(),
|
||||
read_only: false,
|
||||
allow_dangerous_sql: false,
|
||||
database_scope: McpDatabaseScope::Selected,
|
||||
allowed_databases: vec!["aa".to_string(), "aaa".to_string(), "abc".to_string()],
|
||||
execution_mode_configured: false,
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
let server = DbxMcpServer::with_runtime_options(backend, McpScope::default(), false);
|
||||
let result = server.list_databases(Parameters(ListDatabasesRequest { selector: selector("sql") })).await;
|
||||
assert_eq!(result_text(&result), "- aa\n- aaa\n- abc");
|
||||
let show_databases = server
|
||||
.execute_query(Parameters(ExecuteQueryRequest {
|
||||
selector: selector("sql"),
|
||||
database: None,
|
||||
sql: "SHOW DATABASES".to_string(),
|
||||
session_id: None,
|
||||
cell_char_offset: None,
|
||||
cell_char_limit: None,
|
||||
}))
|
||||
.await;
|
||||
assert_eq!(result_text(&show_databases), "- aa\n- aaa\n- abc");
|
||||
assert!(is_database_discovery_sql(" SHOW DATABASES; "));
|
||||
assert!(is_database_discovery_sql("show schemas"));
|
||||
assert!(!is_database_discovery_sql("show tables"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_scope_is_a_hard_bound() {
|
||||
let dameng = connection("dameng-1", "Dameng", "dameng", "APPDB");
|
||||
@@ -1881,7 +2300,8 @@ mod tests {
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(server.resolve_database(None, &dameng).unwrap(), "APPDB");
|
||||
let resolved = resolved_connection_for_test(dameng.clone());
|
||||
assert_eq!(server.resolve_database(None, &resolved).unwrap(), "APPDB");
|
||||
assert_eq!(server.resolve_schema(None).unwrap(), "REPORTING");
|
||||
assert_eq!(server.resolve_schema(Some("REPORTING".to_string())).unwrap(), "REPORTING");
|
||||
let error = server.resolve_schema(Some("APP_USER".to_string())).unwrap_err();
|
||||
@@ -1896,8 +2316,9 @@ mod tests {
|
||||
McpScope { database: Some("2".to_string()), ..Default::default() },
|
||||
false,
|
||||
);
|
||||
assert_eq!(scoped.resolve_redis_database(None, &redis).unwrap(), 2);
|
||||
let error = scoped.resolve_redis_database(Some(3), &redis).unwrap_err();
|
||||
let resolved = resolved_connection_for_test(redis.clone());
|
||||
assert_eq!(scoped.resolve_redis_database(None, &resolved).unwrap(), 2);
|
||||
let error = scoped.resolve_redis_database(Some(3), &resolved).unwrap_err();
|
||||
assert!(result_text(&error).contains("DATABASE_OUT_OF_SCOPE"));
|
||||
|
||||
let invalid = DbxMcpServer::with_runtime_options(
|
||||
@@ -1905,7 +2326,7 @@ mod tests {
|
||||
McpScope { database: Some("analytics".to_string()), ..Default::default() },
|
||||
false,
|
||||
);
|
||||
let error = invalid.resolve_redis_database(None, &redis).unwrap_err();
|
||||
let error = invalid.resolve_redis_database(None, &resolved).unwrap_err();
|
||||
assert!(result_text(&error).contains("INVALID_DATABASE_SCOPE"));
|
||||
}
|
||||
|
||||
@@ -1917,21 +2338,27 @@ mod tests {
|
||||
read_only: false,
|
||||
allow_dangerous_sql: true,
|
||||
allowed_connection_ids: None,
|
||||
query_timeout_secs: None,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let error = validate_mongo_command(
|
||||
&mongo,
|
||||
&policy,
|
||||
&DatabaseScope::All,
|
||||
"staging",
|
||||
r#"db.items.aggregate([{"$out":{"db":"production","coll":"archive"}}])"#,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(result_text(&error).contains("PRODUCTION_WRITE_BLOCKED"));
|
||||
|
||||
assert!(
|
||||
validate_mongo_command(&mongo, &policy, "staging", r#"db.items.aggregate([{"$out":"archive"}])"#,).is_ok()
|
||||
);
|
||||
assert!(validate_mongo_command(
|
||||
&mongo,
|
||||
&policy,
|
||||
&DatabaseScope::All,
|
||||
"staging",
|
||||
r#"db.items.aggregate([{"$out":"archive"}])"#,
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1944,22 +2371,22 @@ mod tests {
|
||||
read_only: true,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: None,
|
||||
query_timeout_secs: None,
|
||||
..Default::default()
|
||||
},
|
||||
McpGlobalPolicy {
|
||||
read_only: false,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: None,
|
||||
query_timeout_secs: None,
|
||||
..Default::default()
|
||||
},
|
||||
McpGlobalPolicy {
|
||||
read_only: false,
|
||||
allow_dangerous_sql: true,
|
||||
allowed_connection_ids: None,
|
||||
query_timeout_secs: None,
|
||||
..Default::default()
|
||||
},
|
||||
] {
|
||||
let error = validate_mongo_command(&mongo, &policy, "staging", source).unwrap_err();
|
||||
let error = validate_mongo_command(&mongo, &policy, &DatabaseScope::All, "staging", source).unwrap_err();
|
||||
assert!(result_text(&error).contains("SQL_BLOCKED"));
|
||||
assert!(result_text(&error).contains("runCommand"));
|
||||
}
|
||||
@@ -1990,7 +2417,7 @@ mod tests {
|
||||
read_only: false,
|
||||
allow_dangerous_sql: true,
|
||||
allowed_connection_ids: None,
|
||||
query_timeout_secs: None,
|
||||
..Default::default()
|
||||
};
|
||||
let read_only_error =
|
||||
validate_sql_policy(&read_only, &writable_policy, "app", "DELETE FROM sessions", false).unwrap_err();
|
||||
@@ -2045,7 +2472,7 @@ mod tests {
|
||||
read_only: false,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: None,
|
||||
query_timeout_secs: None,
|
||||
..Default::default()
|
||||
};
|
||||
let permissions = mcp_permissions(&connection, &safe_write);
|
||||
assert!(!permissions.allow_dangerous, "confirmed SQL must NOT elevate allow_dangerous for Redis/Mongo paths");
|
||||
@@ -2072,7 +2499,7 @@ mod tests {
|
||||
read_only: false,
|
||||
allow_dangerous_sql: true,
|
||||
allowed_connection_ids: None,
|
||||
query_timeout_secs: None,
|
||||
..Default::default()
|
||||
};
|
||||
let permissions =
|
||||
validate_sql_policy(&connection, &full_access, "app", "CREATE TABLE metrics (id INT)", false).unwrap();
|
||||
@@ -2086,7 +2513,7 @@ mod tests {
|
||||
read_only: true,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: None,
|
||||
query_timeout_secs: None,
|
||||
..Default::default()
|
||||
};
|
||||
let error = validate_sql_policy(&connection, &read_only_policy, "app", "CREATE TABLE metrics (id INT)", false)
|
||||
.unwrap_err();
|
||||
@@ -2111,7 +2538,7 @@ mod tests {
|
||||
read_only: false,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: None,
|
||||
query_timeout_secs: None,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let blocked = validate_sql_policy(&starrocks, &policy, "default_catalog", "USE analytics", false).unwrap_err();
|
||||
|
||||
@@ -99,7 +99,7 @@ async fn duplicate_connection_preserves_secrets_ssh_and_sidebar_group() {
|
||||
read_only: false,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: None,
|
||||
query_timeout_secs: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.expect("enable MCP connection management");
|
||||
@@ -234,7 +234,7 @@ async fn duplicate_connection_preserves_secrets_ssh_and_sidebar_group() {
|
||||
read_only: true,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: None,
|
||||
query_timeout_secs: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.expect("enable MCP read-only policy");
|
||||
|
||||
@@ -199,6 +199,7 @@ async fn execute_query_injects_and_omits_timeout_secs_from_policy() {
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: None,
|
||||
query_timeout_secs: Some(300),
|
||||
..Default::default()
|
||||
},
|
||||
connections: vec![test_connection("scoped", "shared-db")],
|
||||
calls: Mutex::new(Vec::new()),
|
||||
@@ -215,6 +216,7 @@ async fn execute_query_injects_and_omits_timeout_secs_from_policy() {
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: None,
|
||||
query_timeout_secs: None,
|
||||
..Default::default()
|
||||
},
|
||||
connections: vec![test_connection("scoped", "shared-db")],
|
||||
calls: Mutex::new(Vec::new()),
|
||||
@@ -283,10 +285,11 @@ async fn initializes_lists_tools_and_calls_a_tool() {
|
||||
let tools = client.peer().list_tools(None).await.expect("list tools");
|
||||
let names = tools.tools.iter().map(|tool| tool.name.as_ref()).collect::<Vec<_>>();
|
||||
#[cfg(feature = "mq-admin")]
|
||||
assert_eq!(names.len(), 14);
|
||||
assert_eq!(names.len(), 15);
|
||||
#[cfg(not(feature = "mq-admin"))]
|
||||
assert_eq!(names.len(), 13);
|
||||
assert_eq!(names.len(), 14);
|
||||
assert!(names.contains(&"dbx_list_connections"));
|
||||
assert!(names.contains(&"dbx_list_databases"));
|
||||
assert!(names.contains(&"dbx_duplicate_connection"));
|
||||
assert!(names.contains(&"dbx_execute_redis_command"));
|
||||
assert!(names.contains(&"dbx_execute_and_show"));
|
||||
@@ -303,6 +306,38 @@ async fn initializes_lists_tools_and_calls_a_tool() {
|
||||
server_task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_connection_respects_global_connection_scope() {
|
||||
let backend = PolicyBackend {
|
||||
policy: McpGlobalPolicy {
|
||||
read_only: false,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: Some(vec!["allowed".to_string()]),
|
||||
..Default::default()
|
||||
},
|
||||
connections: vec![test_connection("allowed", "allowed-db"), test_connection("blocked", "blocked-db")],
|
||||
group_paths: Ok(HashMap::new()),
|
||||
};
|
||||
let (server_transport, client_transport) = tokio::io::duplex(16 * 1024);
|
||||
let server = DbxMcpServer::with_runtime_options(Arc::new(backend), McpScope::default(), false);
|
||||
let server_task = tokio::spawn(async move { server.serve(server_transport).await });
|
||||
let client = ().serve(client_transport).await.expect("initialize MCP client");
|
||||
|
||||
let result = client
|
||||
.peer()
|
||||
.call_tool(
|
||||
CallToolRequestParams::new("dbx_remove_connection")
|
||||
.with_arguments(json!({ "connection_id": "blocked" }).as_object().cloned().unwrap_or_else(Map::new)),
|
||||
)
|
||||
.await
|
||||
.expect("call blocked connection removal");
|
||||
assert_eq!(result.is_error, Some(true));
|
||||
assert!(result.content[0].as_text().expect("blocked removal result").text.contains("CONNECTION_OUT_OF_SCOPE"));
|
||||
|
||||
client.cancel().await.expect("close MCP client");
|
||||
server_task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enforces_global_connection_scope_and_read_only_policy() {
|
||||
let backend = PolicyBackend {
|
||||
@@ -310,7 +345,7 @@ async fn enforces_global_connection_scope_and_read_only_policy() {
|
||||
read_only: true,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: Some(vec!["allowed".to_string(), "allowed-staging".to_string()]),
|
||||
query_timeout_secs: None,
|
||||
..Default::default()
|
||||
},
|
||||
connections: vec![
|
||||
test_connection("allowed", "shared-db"),
|
||||
@@ -383,7 +418,7 @@ async fn read_only_policy_blocks_write_capable_sql_the_keyword_scan_misses() {
|
||||
read_only: true,
|
||||
allow_dangerous_sql,
|
||||
allowed_connection_ids: None,
|
||||
query_timeout_secs: None,
|
||||
..Default::default()
|
||||
},
|
||||
connections: vec![mysql_connection("mysql", "reporting")],
|
||||
group_paths: Ok(HashMap::new()),
|
||||
@@ -428,7 +463,7 @@ async fn read_only_policy_allows_read_only_show_statements() {
|
||||
read_only: true,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: None,
|
||||
query_timeout_secs: None,
|
||||
..Default::default()
|
||||
},
|
||||
connections: vec![connection],
|
||||
group_paths: Ok(HashMap::new()),
|
||||
|
||||
@@ -10,14 +10,15 @@ path = "src/main.rs"
|
||||
|
||||
[features]
|
||||
default = ["duckdb-sidecar", "dynamodb", "mq-admin", "sqlite-sqlcipher", "system-fonts"]
|
||||
duckdb-sidecar = ["dbx-core/duckdb-sidecar"]
|
||||
duckdb-sidecar = ["dbx-core/duckdb-sidecar", "dbx-mcp/duckdb-sidecar"]
|
||||
dynamodb = ["dbx-core/dynamodb"]
|
||||
mq-admin = ["dbx-core/mq-admin"]
|
||||
mq-admin = ["dbx-core/mq-admin", "dbx-mcp/mq-admin"]
|
||||
sqlite-sqlcipher = ["dbx-core/sqlite-sqlcipher"]
|
||||
system-fonts = ["dbx-core/system-fonts"]
|
||||
|
||||
[dependencies]
|
||||
dbx-core = { path = "../dbx-core", default-features = false, features = ["openapi"] }
|
||||
dbx-mcp = { path = "../dbx-mcp", default-features = false }
|
||||
redis = { version = "0.32", features = ["tokio-comp"] }
|
||||
axum = { version = "0.8", features = ["multipart", "ws"] }
|
||||
tower-http = { version = "0.6", features = ["cors", "fs", "compression-gzip", "trace"] }
|
||||
|
||||
@@ -22,6 +22,7 @@ use dbx_core::connection::AppState;
|
||||
use dbx_core::sql_dialect::dialect_loader::{register_core_dialects, DialectPluginLoader, DialectRegistry};
|
||||
use dbx_core::sql_dialect::hot_reload::DialectHotReload;
|
||||
use dbx_core::storage::Storage;
|
||||
use dbx_mcp::{streamable_http_router, DbxBackend, HttpAuth, LocalBackend};
|
||||
use state::WebState;
|
||||
use tokio::sync::RwLock;
|
||||
use tower_http::compression::predicate::{DefaultPredicate, NotForContentType, Predicate};
|
||||
@@ -147,6 +148,56 @@ fn mount_public_base_path(mut app: Router, public_base_path: &str, static_dir: O
|
||||
app
|
||||
}
|
||||
|
||||
/// Builds the native Web MCP endpoint. It is intentionally opt-in: exposing a
|
||||
/// token-bearing MCP server on a Web listener must never happen merely because
|
||||
/// DBX Web itself was started.
|
||||
fn web_mcp_router(web_state: &Arc<WebState>) -> Result<Option<Router>, String> {
|
||||
let token = web_mcp_token()?;
|
||||
let Some(token) = token else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let allowed_hosts = comma_separated_env("DBX_WEB_MCP_ALLOWED_HOSTS");
|
||||
if allowed_hosts.is_empty() {
|
||||
return Err("DBX_WEB_MCP_ALLOWED_HOSTS is required when DBX Web MCP is enabled".into());
|
||||
}
|
||||
|
||||
let allowed_origins = comma_separated_env("DBX_WEB_MCP_ALLOWED_ORIGINS");
|
||||
let auth = HttpAuth::new(token, allowed_origins, false)?;
|
||||
let backend: Arc<dyn DbxBackend> =
|
||||
Arc::new(LocalBackend::from_app_state(web_state.app.clone(), web_state.data_dir.clone()));
|
||||
|
||||
Ok(Some(streamable_http_router(backend, "/mcp", auth, allowed_hosts, true)))
|
||||
}
|
||||
|
||||
fn web_mcp_token() -> Result<Option<String>, String> {
|
||||
let inline_token = std::env::var("DBX_WEB_MCP_TOKEN").ok();
|
||||
let token_file = std::env::var("DBX_WEB_MCP_TOKEN_FILE").ok();
|
||||
match (inline_token, token_file) {
|
||||
(Some(_), Some(_)) => Err("set only one of DBX_WEB_MCP_TOKEN or DBX_WEB_MCP_TOKEN_FILE".into()),
|
||||
(Some(token), None) if !token.trim().is_empty() => Ok(Some(token)),
|
||||
(Some(_), None) => Err("DBX_WEB_MCP_TOKEN must not be empty".into()),
|
||||
(None, Some(path)) => std::fs::read_to_string(&path)
|
||||
.map_err(|error| format!("failed to read DBX_WEB_MCP_TOKEN_FILE: {error}"))
|
||||
.map(|token| token.trim_end_matches(['\r', '\n']).to_owned())
|
||||
.and_then(|token| {
|
||||
(!token.is_empty()).then_some(token).ok_or_else(|| "DBX_WEB_MCP_TOKEN_FILE is empty".into())
|
||||
})
|
||||
.map(Some),
|
||||
(None, None) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn comma_separated_env(name: &str) -> Vec<String> {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flat_map(|value| {
|
||||
value.split(',').map(str::trim).filter(|value| !value.is_empty()).map(ToOwned::to_owned).collect::<Vec<_>>()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(feature = "mq-admin")]
|
||||
fn add_mq_routes(router: Router<Arc<WebState>>) -> Router<Arc<WebState>> {
|
||||
router
|
||||
@@ -994,6 +1045,7 @@ async fn main() {
|
||||
"/app-settings/mcp-policy",
|
||||
get(routes::app_settings::load_mcp_global_policy).put(routes::app_settings::save_mcp_global_policy),
|
||||
)
|
||||
.route("/app-settings/mcp-http-status", get(routes::app_settings::load_web_mcp_http_status))
|
||||
.route(
|
||||
"/app-settings/max-agent-turns",
|
||||
get(routes::app_settings::load_max_agent_turns).put(routes::app_settings::save_max_agent_turns),
|
||||
@@ -1045,6 +1097,11 @@ async fn main() {
|
||||
.layer(CompressionLayer::new().compress_when(web_compression_predicate()))
|
||||
.layer(tower_http::trace::TraceLayer::new_for_http());
|
||||
|
||||
if let Some(mcp_router) = web_mcp_router(&web_state).expect("Invalid DBX Web MCP configuration") {
|
||||
app = app.merge(mcp_router);
|
||||
tracing::info!("DBX Web MCP is enabled at /mcp");
|
||||
}
|
||||
|
||||
let static_dir = std::env::var_os("DBX_STATIC_DIR").map(std::path::PathBuf::from);
|
||||
app = mount_public_base_path(app, &public_base_path, static_dir.as_deref());
|
||||
|
||||
|
||||
@@ -65,6 +65,43 @@ pub async fn save_mcp_global_policy(
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WebMcpHttpStatus {
|
||||
pub enabled: bool,
|
||||
pub endpoint_path: String,
|
||||
pub token_source: Option<&'static str>,
|
||||
pub allowed_hosts: Vec<String>,
|
||||
pub allowed_origins: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn load_web_mcp_http_status(State(state): State<Arc<WebState>>) -> Json<WebMcpHttpStatus> {
|
||||
let token_source = match (std::env::var_os("DBX_WEB_MCP_TOKEN"), std::env::var_os("DBX_WEB_MCP_TOKEN_FILE")) {
|
||||
(Some(_), None) => Some("environment"),
|
||||
(None, Some(_)) => Some("file"),
|
||||
_ => None,
|
||||
};
|
||||
let endpoint_path =
|
||||
if state.public_base_path == "/" { "/mcp".to_string() } else { format!("{}/mcp", state.public_base_path) };
|
||||
Json(WebMcpHttpStatus {
|
||||
enabled: token_source.is_some(),
|
||||
endpoint_path,
|
||||
token_source,
|
||||
allowed_hosts: comma_separated_env("DBX_WEB_MCP_ALLOWED_HOSTS"),
|
||||
allowed_origins: comma_separated_env("DBX_WEB_MCP_ALLOWED_ORIGINS"),
|
||||
})
|
||||
}
|
||||
|
||||
fn comma_separated_env(name: &str) -> Vec<String> {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flat_map(|value| {
|
||||
value.split(',').map(str::trim).filter(|value| !value.is_empty()).map(ToOwned::to_owned).collect::<Vec<_>>()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SaveMaxAgentTurnsRequest {
|
||||
|
||||
@@ -1227,7 +1227,7 @@ mod tests {
|
||||
read_only: false,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: Some(vec![existing.id.clone()]),
|
||||
query_timeout_secs: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1300,7 +1300,7 @@ mod tests {
|
||||
read_only: false,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: Some(vec![removed.id.clone()]),
|
||||
query_timeout_secs: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1331,7 +1331,7 @@ mod tests {
|
||||
read_only: true,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: None,
|
||||
query_timeout_secs: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -986,7 +986,7 @@ mod tests {
|
||||
read_only: false,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: Some(vec![connection.id.clone()]),
|
||||
query_timeout_secs: None,
|
||||
..Default::default()
|
||||
};
|
||||
state.app.storage.save_mcp_global_policy(&writable_policy).await.unwrap();
|
||||
|
||||
@@ -1033,7 +1033,7 @@ mod tests {
|
||||
read_only: false,
|
||||
allow_dangerous_sql: true,
|
||||
allowed_connection_ids: Some(vec!["different-connection".to_string()]),
|
||||
query_timeout_secs: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1058,7 +1058,7 @@ mod tests {
|
||||
read_only: false,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: Some(vec![connection.id.clone()]),
|
||||
query_timeout_secs: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1088,7 +1088,7 @@ mod tests {
|
||||
read_only: false,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: Some(vec![connection.id.clone()]),
|
||||
query_timeout_secs: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -1586,7 +1586,7 @@ mod tests {
|
||||
read_only: false,
|
||||
allow_dangerous_sql: true,
|
||||
allowed_connection_ids: Some(vec![connection_id.to_string()]),
|
||||
query_timeout_secs: None,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,12 @@ services:
|
||||
- "4224:4224"
|
||||
environment:
|
||||
- DBX_PASSWORD=changeme
|
||||
# Native Streamable HTTP MCP is served by this same Web listener at /mcp.
|
||||
# Set the host authority clients use (including its externally mapped port).
|
||||
# - DBX_WEB_MCP_TOKEN=replace-with-a-long-random-secret
|
||||
# - DBX_WEB_MCP_ALLOWED_HOSTS=localhost:4224
|
||||
# Browser-based MCP clients additionally need their exact Origin, for example:
|
||||
# - DBX_WEB_MCP_ALLOWED_ORIGINS=https://mcp-client.example.com
|
||||
# Uncomment when publishing DBX under a reverse-proxy subpath, e.g. https://example.com/dbx/
|
||||
# - DBX_PUBLIC_BASE_PATH=/dbx
|
||||
volumes:
|
||||
|
||||
@@ -60,9 +60,29 @@ docker compose up -d --pull always
|
||||
| `DBX_DATA_DIR` | `/app/data` | Directory containing the DBX database, plugins, drivers, and other persistent data. |
|
||||
| `DBX_PORT` | `4224` | HTTP port inside the container. |
|
||||
| `DBX_PUBLIC_BASE_PATH` | `/` | URL prefix for reverse-proxy deployments, for example `/dbx`. |
|
||||
| `DBX_WEB_MCP_TOKEN` | Not set | Enables native Streamable HTTP MCP with this bearer token. Keep it secret. |
|
||||
| `DBX_WEB_MCP_TOKEN_FILE` | Not set | Read the native MCP bearer token from a file, for example a mounted Docker secret. Cannot be combined with `DBX_WEB_MCP_TOKEN`. |
|
||||
| `DBX_WEB_MCP_ALLOWED_HOSTS` | Not set | Required when native MCP is enabled. Comma-separated public Host authorities, including ports when present. |
|
||||
| `DBX_WEB_MCP_ALLOWED_ORIGINS` | Not set | Comma-separated browser Origins allowed to call native MCP. Optional for non-browser MCP clients. |
|
||||
|
||||
Persist `/app/data` with a named volume or bind mount. Removing this data removes saved connections and other DBX application data.
|
||||
|
||||
## Native HTTP MCP
|
||||
|
||||
Native MCP is disabled by default. When enabled, it is served by the existing DBX Web listener at `/mcp`; no second container port is required. With a host mapping of `4225:4224`, configure the public authority clients use:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
DBX_WEB_MCP_TOKEN: replace-with-a-long-random-secret
|
||||
DBX_WEB_MCP_ALLOWED_HOSTS: localhost:4225
|
||||
ports:
|
||||
- "4225:4224"
|
||||
```
|
||||
|
||||
The MCP endpoint is `http://localhost:4225/mcp` and requires `Authorization: Bearer <DBX_WEB_MCP_TOKEN>`. For a reverse proxy, set `DBX_WEB_MCP_ALLOWED_HOSTS` to the public hostname (and port if non-default); with `DBX_PUBLIC_BASE_PATH: /dbx`, the endpoint becomes `/dbx/mcp`. Browser-based clients must also set `DBX_WEB_MCP_ALLOWED_ORIGINS` to their exact `https://host[:port]` origin.
|
||||
|
||||
The token is a deployment credential: rotate it through your secret manager and restart the container. DBX Desktop offers a local **Rotate Token** action for its separately managed loopback HTTP MCP service.
|
||||
|
||||
DuckDB is delivered as a standalone native driver instead of being embedded in `dbx-web`. Install the DuckDB driver from Driver Manager after the first launch. It is stored under `/app/data/agents` and remains available across container upgrades when `/app/data` is persisted.
|
||||
|
||||
## Reverse Proxy
|
||||
|
||||
@@ -87,12 +87,14 @@ DeepSeek Harness 通过 Cordis 插件条目加载 MCP Server,不读取 `mcpSer
|
||||
|
||||
## 工具列表
|
||||
|
||||
DBX MCP 当前提供 12 个工具:
|
||||
DBX MCP 当前提供 15 个工具:
|
||||
|
||||
| 工具 | 说明 |
|
||||
| --- | --- |
|
||||
| `dbx_list_connections` | 列出当前 MCP 会话可见的连接 |
|
||||
| `dbx_list_databases` | 列出连接中可通过 MCP 访问的数据库,并遵守该连接的数据库范围 |
|
||||
| `dbx_add_connection` | 添加连接到 DBX 存储 |
|
||||
| `dbx_duplicate_connection` | 复制一个连接及其完整配置 |
|
||||
| `dbx_remove_connection` | 从 DBX 存储删除连接 |
|
||||
| `dbx_list_tables` | 列出表、视图或集合 |
|
||||
| `dbx_describe_table` | 返回列定义和表元数据 |
|
||||
@@ -101,6 +103,7 @@ DBX MCP 当前提供 12 个工具:
|
||||
| `dbx_open_session` | 为 SQL 连接打开固定后端连接的有状态查询会话 |
|
||||
| `dbx_close_session` | 关闭会话并释放固定连接资源 |
|
||||
| `dbx_execute_redis_command` | 执行 Redis 命令 |
|
||||
| `dbx_send_message` | 向支持的消息队列 Topic 或队列发送消息 |
|
||||
| `dbx_open_table` | 在运行中的 DBX 桌面端打开表 |
|
||||
| `dbx_execute_and_show` | 执行查询并在 DBX 中展示结果 |
|
||||
|
||||
@@ -121,6 +124,30 @@ DBX MCP 当前提供 12 个工具:
|
||||
|
||||
## 数据库访问
|
||||
|
||||
### DBX Desktop 原生 Streamable HTTP
|
||||
|
||||
Desktop 可以选择直接托管 Streamable HTTP MCP 服务。该服务**默认关闭**;在 **设置 → MCP → HTTP 服务**启用后,默认仅监听 `127.0.0.1:5225/mcp`。监听服务由 Desktop 进程管理,会生成本地 Bearer Token,页面提供可复制的客户端配置;服务异常退出时会由 Desktop 自动重新拉起。
|
||||
|
||||
本机 MCP 客户端的接入步骤:
|
||||
|
||||
1. 打开 **设置 → MCP → HTTP 服务**,开启 **Streamable HTTP 服务**。除非确实需要让其他设备访问,否则保持默认本机回环地址。
|
||||
2. 点击 **保存并启动服务**。修改地址、端口或路径后保存,会通过重启服务来应用新配置。
|
||||
3. 将页面展示的服务地址和 Bearer Token 复制到 MCP 客户端。
|
||||
|
||||
不同客户端的字段名称可能不同,但核心配置如下:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "http",
|
||||
"url": "http://127.0.0.1:5225/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer <DBX Desktop 页面展示的 Token>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
本机客户端建议保留默认回环监听。绑定到局域网地址时,必须开启 **允许远程访问**,并填写精确的允许 `Host`;浏览器客户端还必须填写精确的 `Origin`。客户端通过非默认端口访问时,允许的 Host 应写成 `host:port`。轮换 Token 会立即使旧凭证失效。
|
||||
|
||||
### 本地连接
|
||||
|
||||
这是默认模式。MCP 使用 DBX 中保存的连接配置。原生可执行的连接不要求 DBX 桌面端保持运行;需要 Desktop bridge 或已安装 Agent/驱动的连接仍依赖对应运行时。
|
||||
@@ -151,6 +178,22 @@ DBX Web 请求遵循标准系统代理环境变量:https 地址读取 `HTTPS_P
|
||||
|
||||
DBX Web 和 Docker 同样需要独立 DuckDB 驱动。首次启动后可通过 Driver Manager 安装;Docker 会把驱动保存到 `/app/data/agents`,挂载 `/app/data` 数据卷后升级容器不会丢失。
|
||||
|
||||
### DBX Web 原生 Streamable HTTP
|
||||
|
||||
DBX Web 也可以直接托管 MCP。只有配置 `DBX_WEB_MCP_TOKEN` 或 `DBX_WEB_MCP_TOKEN_FILE` 后才会启用。它复用现有 Web 监听器和 `/mcp` 路径,因此 Docker 和反向代理部署不需要额外暴露第二个端口。将 `DBX_WEB_MCP_ALLOWED_HOSTS` 配置为客户端在 `Host` 请求头中发送的公网访问地址;如有端口映射,必须包含映射后的端口。生产环境请使用 `DBX_WEB_MCP_TOKEN_FILE` 或部署平台的 Secret 管理能力,不要把真实 Token 提交到 Compose 文件中。
|
||||
|
||||
例如容器映射为 `4225:4224` 时,MCP 地址为 `http://localhost:4225/mcp`:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
DBX_WEB_MCP_TOKEN: replace-with-a-long-random-secret
|
||||
DBX_WEB_MCP_ALLOWED_HOSTS: localhost:4225
|
||||
ports:
|
||||
- "4225:4224"
|
||||
```
|
||||
|
||||
客户端必须携带 `Authorization: Bearer <DBX_WEB_MCP_TOKEN>`。浏览器客户端还需要在 `DBX_WEB_MCP_ALLOWED_ORIGINS` 中填写精确 Origin,例如 `https://mcp.example.com`;原生客户端通常不会发送 `Origin` 请求头。若反向代理添加了公网路径前缀,请设置 `DBX_PUBLIC_BASE_PATH=/dbx`,此时原生端点为 `/dbx/mcp`。原生 HTTP 与现有的 `DBX_WEB_URL` stdio 适配方式可以同时使用:两者共享 DBX 连接和 MCP 策略,后者仍适合只支持 stdio 的客户端。
|
||||
|
||||
### 桌面 UI 工具
|
||||
|
||||
`dbx_open_table` 和 `dbx_execute_and_show` 要求 DBX 桌面端正在运行,并且在 Web 模式或连接作用域隐藏 UI 工具时不可用。其他工具是否需要 Desktop 取决于该连接是原生执行还是 bridge/Agent 路径。
|
||||
@@ -167,6 +210,21 @@ DBX 在 **设置 → MCP** 中保存一份权威策略,并在每次请求时
|
||||
|
||||
`WHERE TRUE`、`WHERE 1 = 1`、`_id: {$exists: true}` 或不透明 MongoDB 过滤器仍按高风险处理。连接自身只读、生产库保护、数据库账号权限和 MCP 连接 allowlist 在任何模式下都是权限上限。
|
||||
|
||||
对于已暴露给 MCP 的连接,**设置 → MCP** 还可以设置额外的连接级执行上限:继承全局、只读、安全写入或高风险写入。连接规则只能收窄权限。例如全局策略为高风险访问时,连接规则设为只读仍不能写入;任何连接规则也不能绕过 DBX 的生产库保护或连接自身保护。
|
||||
|
||||
同一页面还可只暴露选定的 MCP 工具。这项限制在服务端的每一次调用中都会强制执行,不只是客户端的展示偏好;即使客户端缓存了旧工具列表,也会在服务端 allowlist 更新后被拒绝。
|
||||
|
||||
### 按顺序配置授权范围
|
||||
|
||||
在 **设置 → MCP** 中建议按以下顺序定义最终权限范围:
|
||||
|
||||
1. 选择 MCP 可见的是**所有连接**(包括以后新增的连接),还是仅**指定连接**。
|
||||
2. 为每个已暴露连接选择**全部数据库**、**仅指定数据库**或**不允许访问**。指定数据库名按精确名称匹配;可先调用 `dbx_list_databases` 获取当前允许使用的名称。
|
||||
3. 对敏感连接按需设置连接级执行上限,使其低于全局权限模式。
|
||||
4. 仅选择客户端实际需要的 MCP 工具,再设置全局执行权限模式。
|
||||
|
||||
每一层都是权限上限:已允许的工具也不能绕过隐藏连接、数据库范围、连接只读、生产库保护或数据库账号本身的权限。请求未被选中数据库时,服务会返回 `DATABASE_OUT_OF_SCOPE`。
|
||||
|
||||
新版 Server 不允许 `DBX_MCP_ALLOW_WRITES` 或 `DBX_MCP_ALLOW_DANGEROUS_SQL` 放宽 DBX 中央策略。为兼容升级,在中央策略首次保存前,旧配置中的 `DBX_MCP_ALLOW_WRITES=0`(或 `false`)仍会保持 MCP 只读;策略保存后仅以中央策略为准,并忽略旧权限变量。旧连接 scope 变量只能进一步收窄 DBX allowlist。
|
||||
|
||||
| 变量 | 用途 |
|
||||
@@ -179,6 +237,10 @@ DBX 在 **设置 → MCP** 中保存一份权威策略,并在每次请求时
|
||||
| `DBX_WEB_HEADERS` | DBX Web 请求附加的请求头 JSON 对象,例如 `{"Authorization":"Bearer token"}` |
|
||||
| `DBX_WEB_INSECURE_SKIP_VERIFY` | `1`/`true` 时跳过 TLS 证书验证(用于自签名 DBX Web 后端;默认验证) |
|
||||
| `DBX_WEB_CA_CERT` | DBX Web TLS 验证时信任的 PEM/DER CA 文件 |
|
||||
| `DBX_WEB_MCP_TOKEN` | 使用该 Bearer Token 启用原生 Web Streamable HTTP MCP |
|
||||
| `DBX_WEB_MCP_TOKEN_FILE` | 从文件读取原生 Web MCP Token;不能与 `DBX_WEB_MCP_TOKEN` 同时设置 |
|
||||
| `DBX_WEB_MCP_ALLOWED_HOSTS` | 原生 Web MCP 必填:允许的公网 Host authority,逗号分隔 |
|
||||
| `DBX_WEB_MCP_ALLOWED_ORIGINS` | 原生 Web MCP 的浏览器 Origin allowlist,逗号分隔 |
|
||||
| `DBX_MCP_ALLOW_WRITES` | 仅用于升级兼容:`0`/`false` 使尚未配置的策略保持只读 |
|
||||
| `DBX_MCP_SCOPE_CONNECTION_ID` | 兼容旧配置:限制为一个连接 ID |
|
||||
| `DBX_MCP_SCOPE_CONNECTION_IDS` | 兼容旧配置:限制为多个连接 ID |
|
||||
@@ -192,6 +254,11 @@ DBX 在 **设置 → MCP** 中保存一份权威策略,并在每次请求时
|
||||
<Accordion title="npm 没有安装当前平台包">不要使用 `--no-optional`,重新安装 `@dbx-app/mcp-server`,并用 `node -p 'process.platform + "-" + process.arch'` 检查平台。Alpine Linux 默认使用 musl,目前不在 Linux 发布包支持范围内。</Accordion>
|
||||
<Accordion title="找不到 dbx.db">将 `DBX_DATA_DIR` 设置为包含 `dbx.db` 的目录。Windows 便携版通常是 `DBX.exe` 旁边的 `data` 目录。</Accordion>
|
||||
<Accordion title="提示 DBX 未运行">只有 `dbx_open_table` 和 `dbx_execute_and_show` 需要桌面端运行;本地查询工具可以在 DBX 关闭时执行。</Accordion>
|
||||
<Accordion title="Desktop HTTP 客户端提示 ERR_CONNECTION_REFUSED">进入 **设置 → MCP → HTTP 服务**,确认服务已开启并点击 **保存并启动服务**。客户端应使用页面展示的地址、路径和当前 Bearer Token。默认的 `127.0.0.1` 只接受同一台电脑上的连接。</Accordion>
|
||||
<Accordion title="Desktop 提示 HTTP 地址已被占用">已有其他进程监听了所选地址和端口。请在 **设置 → MCP → HTTP 服务** 中改用未被占用的端口,再保存配置以重启服务。不要让两个 DBX Desktop 实例使用相同 HTTP 端点。</Accordion>
|
||||
<Accordion title="请求返回 DATABASE_OUT_OF_SCOPE">该连接虽然已暴露给 MCP,但请求的数据库不在它的 MCP 数据库范围内。请在 **设置 → MCP** 中添加精确的数据库名称,或在合适时将该连接设为全部数据库。指定范围时,应先调用 `dbx_list_databases`,再使用返回的名称。</Accordion>
|
||||
<Accordion title="之前可见的工具现在被拒绝">该工具已不在 **设置 → MCP** 的选中范围内,或当前权限策略不允许它。修改工具 allowlist 后请刷新 MCP 客户端的工具列表;服务端同样会拒绝缓存的旧工具调用。</Accordion>
|
||||
<Accordion title="Docker 或反向代理下 MCP 请求失败">请使用公网访问地址,包括映射后的端口和 `DBX_PUBLIC_BASE_PATH` 前缀。`DBX_WEB_MCP_ALLOWED_HOSTS` 必须与实际 `Host` authority 精确一致;浏览器客户端还必须在 `DBX_WEB_MCP_ALLOWED_ORIGINS` 中配置精确 Origin。</Accordion>
|
||||
<Accordion title="Agent/JDBC 数据库无法启动">在 DBX Driver Manager 中安装或更新匹配的 Agent、JDBC 驱动和 JRE。厂商专有驱动不会随 MCP 原生包发布。</Accordion>
|
||||
<Accordion title="DuckDB 连接无法启动">在 DBX Driver Manager 中安装或更新 DuckDB 驱动。本地 MCP、DBX Web 和 Docker 都通过独立驱动运行 DuckDB,不会内置 DuckDB 引擎。</Accordion>
|
||||
<Accordion title="出现 better-sqlite3 或 Node ABI 错误">MCP 不需要 `better-sqlite3`。请先升级 `@dbx-app/mcp-server`;如果错误来自 `@dbx-app/cli`,请按照 CLI 的安装要求处理,因为它们是两个独立的包。</Accordion>
|
||||
|
||||
@@ -87,12 +87,14 @@ Run `dsh web --dump-config` to verify the composed configuration, then restart D
|
||||
|
||||
## Tools
|
||||
|
||||
DBX MCP currently provides 12 tools:
|
||||
DBX MCP currently provides 15 tools:
|
||||
|
||||
| Tool | Description |
|
||||
| --- | --- |
|
||||
| `dbx_list_connections` | List connections visible to the MCP session |
|
||||
| `dbx_list_databases` | List databases available through a connection, respecting its MCP database scope |
|
||||
| `dbx_add_connection` | Add a connection to DBX storage |
|
||||
| `dbx_duplicate_connection` | Duplicate a DBX connection with its complete settings |
|
||||
| `dbx_remove_connection` | Remove a connection from DBX storage |
|
||||
| `dbx_list_tables` | List tables, views, or collections |
|
||||
| `dbx_describe_table` | Return columns and table metadata |
|
||||
@@ -101,6 +103,7 @@ DBX MCP currently provides 12 tools:
|
||||
| `dbx_open_session` | Open a stateful SQL query session pinned to one backend connection |
|
||||
| `dbx_close_session` | Close a session and release its pinned connection resources |
|
||||
| `dbx_execute_redis_command` | Execute a Redis command |
|
||||
| `dbx_send_message` | Send a message to a supported message queue topic or queue |
|
||||
| `dbx_open_table` | Open a table in the running DBX desktop app |
|
||||
| `dbx_execute_and_show` | Execute a query and display the result in DBX |
|
||||
|
||||
@@ -121,6 +124,30 @@ Sessions support SQL connections only and are bound to one connection and databa
|
||||
|
||||
## Database Access
|
||||
|
||||
### Native Streamable HTTP for DBX Desktop
|
||||
|
||||
Desktop can optionally host its own Streamable HTTP MCP endpoint. It is **off by default** and, when enabled in **Settings → MCP → HTTP Service**, listens only on `127.0.0.1:5225/mcp` by default. The Desktop process manages the listener, creates a local bearer token, shows a copyable client configuration, and restarts the managed HTTP service if it exits unexpectedly.
|
||||
|
||||
To connect a local MCP client:
|
||||
|
||||
1. Open **Settings → MCP → HTTP Service**, turn on **Streamable HTTP Service**, and keep the default loopback address unless another device genuinely needs access.
|
||||
2. Click **Save and start service**. Saving a changed address, port, or path applies the configuration by restarting the service.
|
||||
3. Copy the displayed service address and bearer token into the MCP client.
|
||||
|
||||
Use the generated values in an HTTP-capable MCP client. The transport-specific field names differ between clients, but the essential configuration is:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "http",
|
||||
"url": "http://127.0.0.1:5225/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer <token shown by DBX Desktop>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Keep the default loopback binding for local clients. Binding to a LAN address requires turning on **Allow remote access** and setting the exact allowed `Host` authorities; browser clients also need their exact `Origin` values. Use `host:port` in an allowed Host when the client sends a non-default port. Rotating the token immediately invalidates the previous credential.
|
||||
|
||||
### Local connections
|
||||
|
||||
MCP uses the connections saved in DBX. Connections that execute natively do not require the Desktop app to stay open; bridge-backed or installed Agent/driver paths still depend on their runtime.
|
||||
@@ -151,6 +178,22 @@ DBX Web requests honor the standard system proxy environment variables: `HTTPS_P
|
||||
|
||||
DBX Web and Docker require the same standalone DuckDB driver. Install it from Driver Manager after the first launch. Docker stores installed drivers under `/app/data/agents`, so they persist when `/app/data` is mounted as a volume.
|
||||
|
||||
### Native Streamable HTTP for DBX Web
|
||||
|
||||
DBX Web can also host MCP itself. This mode is disabled unless `DBX_WEB_MCP_TOKEN` (or `DBX_WEB_MCP_TOKEN_FILE`) is configured. It uses the existing Web listener and the `/mcp` path, so Docker and reverse-proxy deployments do not need a second exposed port. Configure `DBX_WEB_MCP_ALLOWED_HOSTS` with the public authorities that clients send in the `Host` header, including a mapped port when applicable. Use `DBX_WEB_MCP_TOKEN_FILE` or your deployment secret manager instead of committing a real token to a Compose file.
|
||||
|
||||
For example, a container published as `4225:4224` uses `http://localhost:4225/mcp`:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
DBX_WEB_MCP_TOKEN: replace-with-a-long-random-secret
|
||||
DBX_WEB_MCP_ALLOWED_HOSTS: localhost:4225
|
||||
ports:
|
||||
- "4225:4224"
|
||||
```
|
||||
|
||||
Clients must supply `Authorization: Bearer <DBX_WEB_MCP_TOKEN>`. Browser-based clients additionally require `DBX_WEB_MCP_ALLOWED_ORIGINS` with their exact origin, such as `https://mcp.example.com`; native clients normally do not send an `Origin` header. When a reverse proxy adds a public path prefix, set `DBX_PUBLIC_BASE_PATH=/dbx`; the native endpoint then becomes `/dbx/mcp`. Native HTTP and the existing `DBX_WEB_URL` stdio adapter can coexist: they share DBX connections and MCP policy, while the adapter remains useful for clients that support only stdio.
|
||||
|
||||
### Desktop UI tools
|
||||
|
||||
`dbx_open_table` and `dbx_execute_and_show` require the DBX desktop app and are unavailable in Web mode or when scoped policy hides UI tools. Whether other tools need Desktop depends on whether the connection uses native execution or a bridge/Agent path.
|
||||
@@ -167,6 +210,21 @@ DBX stores one authoritative MCP policy under **Settings → MCP** and reloads i
|
||||
|
||||
An ineffective condition such as `WHERE TRUE`, `WHERE 1 = 1`, `_id: {$exists: true}`, or an opaque MongoDB filter remains high risk. Connection-level read-only protection, production protection, database credentials, and the MCP connection allowlist remain upper bounds in every mode.
|
||||
|
||||
For connections that are exposed to MCP, **Settings → MCP** can apply an additional per-connection ceiling: inherit the global mode, read only, safe write, or high-risk write. A connection rule only narrows access. For example, a global high-risk policy cannot make a connection writable when its connection rule is read only, and no connection rule can override DBX production or connection-level protection.
|
||||
|
||||
The same page can also expose only selected MCP tools. This is enforced at every call, rather than being only a client-side display preference; a client that retained an old tool list is denied once the server-side allowlist changes.
|
||||
|
||||
### Configure access in order
|
||||
|
||||
Use **Settings → MCP** to define the effective scope in this order:
|
||||
|
||||
1. Choose whether MCP can see **all connections** (including future connections) or only **specified connections**.
|
||||
2. For every exposed connection, choose **all databases**, **specified databases**, or **no access**. Specified database names are exact matches; use `dbx_list_databases` to discover the names that are currently allowed.
|
||||
3. Optionally set a per-connection execution ceiling to narrow the global permission mode for a sensitive connection.
|
||||
4. Select only the MCP tools that the client needs, then choose the global permission mode.
|
||||
|
||||
Every layer is an upper bound: an allowed tool still cannot bypass a hidden connection, a database scope, connection read-only status, production protection, or the database account's own privileges. Requests for a database outside the selected scope are rejected with `DATABASE_OUT_OF_SCOPE`.
|
||||
|
||||
Updated servers do not let `DBX_MCP_ALLOW_WRITES` or `DBX_MCP_ALLOW_DANGEROUS_SQL` widen the DBX policy. For upgrade compatibility, `DBX_MCP_ALLOW_WRITES=0` (or `false`) still keeps MCP read-only until a central policy is saved for the first time. After that, the central policy is authoritative and the legacy permission variables are ignored. Legacy connection-scope variables can only narrow the DBX allowlist.
|
||||
|
||||
| Variable | Purpose |
|
||||
@@ -179,6 +237,10 @@ Updated servers do not let `DBX_MCP_ALLOW_WRITES` or `DBX_MCP_ALLOW_DANGEROUS_SQ
|
||||
| `DBX_WEB_HEADERS` | JSON object of extra HTTP headers for DBX Web requests, e.g. `{"Authorization":"Bearer token"}` |
|
||||
| `DBX_WEB_INSECURE_SKIP_VERIFY` | `1`/`true` disables TLS certificate verification for self-signed DBX Web backends (default: verify) |
|
||||
| `DBX_WEB_CA_CERT` | PEM/DER CA file to trust for DBX Web TLS verification |
|
||||
| `DBX_WEB_MCP_TOKEN` | Enable native Web Streamable HTTP MCP with this bearer token |
|
||||
| `DBX_WEB_MCP_TOKEN_FILE` | Read the native Web MCP token from a file; cannot be combined with `DBX_WEB_MCP_TOKEN` |
|
||||
| `DBX_WEB_MCP_ALLOWED_HOSTS` | Required comma-separated public Host authorities for native Web MCP |
|
||||
| `DBX_WEB_MCP_ALLOWED_ORIGINS` | Comma-separated browser Origins allowed for native Web MCP |
|
||||
| `DBX_MCP_ALLOW_WRITES` | Upgrade compatibility only: `0`/`false` keeps an unconfigured policy read-only |
|
||||
| `DBX_MCP_SCOPE_CONNECTION_ID` | Compatibility scope for one connection ID |
|
||||
| `DBX_MCP_SCOPE_CONNECTION_IDS` | Compatibility scope for multiple connection IDs |
|
||||
@@ -192,6 +254,11 @@ Updated servers do not let `DBX_MCP_ALLOW_WRITES` or `DBX_MCP_ALLOW_DANGEROUS_SQ
|
||||
<Accordion title="The optional platform package was not installed">Reinstall without `--no-optional`, then verify the platform with `node -p 'process.platform + "-" + process.arch'`. The current platform must appear in the supported platform table.</Accordion>
|
||||
<Accordion title="dbx.db cannot be found">Set `DBX_DATA_DIR` to the folder containing `dbx.db`. For Windows portable builds, this is normally the `data` folder beside `DBX.exe`.</Accordion>
|
||||
<Accordion title="DBX is not running">Only `dbx_open_table` and `dbx_execute_and_show` require the desktop app. Local query tools can run without DBX open.</Accordion>
|
||||
<Accordion title="A Desktop HTTP client gets ERR_CONNECTION_REFUSED">In **Settings → MCP → HTTP Service**, turn on the service and click **Save and start service**. Confirm that the client uses the displayed address, path, and current bearer token. The default `127.0.0.1` address accepts connections only from the same computer.</Accordion>
|
||||
<Accordion title="Desktop says the HTTP address is already in use">Another process is listening on the selected address and port. Choose an unused port in **Settings → MCP → HTTP Service**, then save the configuration to restart the service. Do not run two DBX Desktop instances with the same HTTP endpoint.</Accordion>
|
||||
<Accordion title="A request returns DATABASE_OUT_OF_SCOPE">The connection is exposed, but the requested database is not in its MCP database scope. Add the exact database name in **Settings → MCP**, or change that connection's scope to all databases if appropriate. For a specified scope, call `dbx_list_databases` first and then use one of the returned names.</Accordion>
|
||||
<Accordion title="A previously visible tool is rejected">The tool is no longer selected in **Settings → MCP** or the current policy does not permit it. Refresh the MCP client's tool list after changing the allowlist; the server rejects stale tool calls as well.</Accordion>
|
||||
<Accordion title="A Docker or reverse-proxy MCP request fails">Use the public endpoint, including the mapped port and any `DBX_PUBLIC_BASE_PATH` prefix. Set `DBX_WEB_MCP_ALLOWED_HOSTS` to the exact public `Host` authority. Browser clients also require an exact `DBX_WEB_MCP_ALLOWED_ORIGINS` entry.</Accordion>
|
||||
<Accordion title="An Agent/JDBC database cannot start">Install or update the matching DBX agent, JDBC driver, and JRE through DBX Driver Manager. Proprietary drivers are not included in the native MCP package.</Accordion>
|
||||
<Accordion title="A DuckDB connection cannot start">Install or update the DuckDB driver through DBX Driver Manager. Local MCP, DBX Web, and Docker use the standalone driver rather than embedding the DuckDB engine.</Accordion>
|
||||
<Accordion title="I see a better-sqlite3 or Node ABI error">MCP does not require `better-sqlite3`. Upgrade `@dbx-app/mcp-server`; if the error comes from `@dbx-app/cli`, follow the CLI installation requirements because it is a separate package.</Accordion>
|
||||
|
||||
@@ -17,10 +17,11 @@ The MCP protocol, connection loading, SQL safety, schema access, Redis support,
|
||||
|
||||
## Features
|
||||
|
||||
- **10 MCP tools** for connections, schemas, SQL, Redis, and DBX UI integration
|
||||
- **15 MCP tools** for connection and database discovery, schemas, SQL, Redis, sessions, messages, and DBX UI integration
|
||||
- **Precompiled native binaries** with no local Rust, Cargo, Python, or C/C++ build requirement
|
||||
- **No `better-sqlite3` runtime dependency** and no Node native-addon ABI coupling
|
||||
- **Local, Web, and Docker modes** using the same tool interface
|
||||
- **Optional Streamable HTTP transport** protected by a bearer token, while stdio remains the default
|
||||
- **Direct native execution** for supported SQL, Redis, and MongoDB connections
|
||||
- **Agent/JDBC database support** through DBX agent infrastructure when the required agent and JRE are installed
|
||||
- **DBX-managed access policy** with a connection allowlist and three execution modes
|
||||
@@ -142,18 +143,25 @@ Ask the MCP client to:
|
||||
| Tool | Description |
|
||||
| --- | --- |
|
||||
| `dbx_list_connections` | List connections visible to the MCP session |
|
||||
| `dbx_list_databases` | List databases available through a connection, respecting its MCP database scope |
|
||||
| `dbx_add_connection` | Add a connection to DBX storage |
|
||||
| `dbx_duplicate_connection` | Duplicate a DBX connection with its complete settings |
|
||||
| `dbx_remove_connection` | Remove a connection from DBX storage |
|
||||
| `dbx_list_tables` | List tables, views, collections, or message queue topics |
|
||||
| `dbx_describe_table` | Return columns and table metadata |
|
||||
| `dbx_get_schema_context` | Return compact schema context suitable for an AI model |
|
||||
| `dbx_execute_query` | Execute SQL or a supported MongoDB shell command, returning at most 100 rows |
|
||||
| `dbx_open_session` | Open a stateful SQL query session pinned to one backend connection |
|
||||
| `dbx_close_session` | Close a session and release its pinned connection resources |
|
||||
| `dbx_execute_redis_command` | Execute a Redis command |
|
||||
| `dbx_send_message` | Send a message to a supported message queue topic |
|
||||
| `dbx_open_table` | Open a table in the running DBX desktop application |
|
||||
| `dbx_execute_and_show` | Execute a query and display the result in the DBX desktop application |
|
||||
|
||||
When connection scoping is enabled, mutating connection tools and desktop UI tools are hidden.
|
||||
|
||||
`dbx_list_databases` returns only database names allowed by the selected connection's MCP database scope. `dbx_send_message` is available when message-queue support is included in the server build.
|
||||
|
||||
## Execution Modes
|
||||
|
||||
### Local native mode
|
||||
@@ -202,6 +210,40 @@ DBX Web requests honor the standard system proxy environment variables (`HTTP_PR
|
||||
|
||||
Docker and DBX Web also require the standalone DuckDB driver. Install it from Driver Manager after the first launch; when `/app/data` is persisted, the driver is stored under `/app/data/agents` and survives container upgrades.
|
||||
|
||||
### Native Streamable HTTP
|
||||
|
||||
The native server uses stdio by default. To host a local Streamable HTTP endpoint instead, start it with a bearer token:
|
||||
|
||||
```bash
|
||||
DBX_MCP_HTTP_TOKEN=replace-with-a-long-random-secret dbx-mcp-server --http
|
||||
```
|
||||
|
||||
It listens on `http://127.0.0.1:5225/mcp` by default. Configure an HTTP-capable MCP client with that URL and `Authorization: Bearer <token>`:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "http",
|
||||
"url": "http://127.0.0.1:5225/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer replace-with-a-long-random-secret"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The default loopback address accepts only clients on the same computer. Binding to a non-loopback address requires all of the following: `DBX_MCP_HTTP_ALLOW_REMOTE=1`, the `--http-allow-remote` flag, and non-empty `DBX_MCP_HTTP_ALLOWED_HOSTS` plus `DBX_MCP_HTTP_ALLOWED_ORIGINS` allowlists. Use exact public Host authorities and browser Origins.
|
||||
|
||||
DBX Web can host native Streamable HTTP on its existing listener, rather than opening a second port. Enable it with `DBX_WEB_MCP_TOKEN` (or `DBX_WEB_MCP_TOKEN_FILE`) and configure the public Host allowlist. For a container published as `4225:4224`, the endpoint is `http://localhost:4225/mcp`:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
DBX_WEB_MCP_TOKEN: replace-with-a-long-random-secret
|
||||
DBX_WEB_MCP_ALLOWED_HOSTS: localhost:4225
|
||||
ports:
|
||||
- "4225:4224"
|
||||
```
|
||||
|
||||
Clients send `Authorization: Bearer <DBX_WEB_MCP_TOKEN>`. Browser clients also require an exact `DBX_WEB_MCP_ALLOWED_ORIGINS` entry. When a reverse proxy adds a path prefix, set `DBX_PUBLIC_BASE_PATH`; the endpoint becomes `<base-path>/mcp`. Native HTTP and the `DBX_WEB_URL` stdio adapter can coexist and share the same DBX policy.
|
||||
|
||||
### Windows portable DBX
|
||||
|
||||
Point `DBX_DATA_DIR` at the portable `data` directory containing `dbx.db`:
|
||||
@@ -271,6 +313,19 @@ SQL text is not included in normal MCP errors or logged by default. Enable tempo
|
||||
| `DBX_WEB_HEADERS` | JSON object of extra HTTP headers for DBX Web requests, e.g. `{"Authorization":"Bearer token"}` |
|
||||
| `DBX_WEB_INSECURE_SKIP_VERIFY` | `1`/`true` disables TLS certificate verification for self-signed backends |
|
||||
| `DBX_WEB_CA_CERT` | PEM/DER CA file to trust for DBX Web TLS verification |
|
||||
| `DBX_WEB_MCP_TOKEN` | Enable native DBX Web Streamable HTTP MCP with this bearer token |
|
||||
| `DBX_WEB_MCP_TOKEN_FILE` | Read the native DBX Web MCP token from a file; cannot be combined with `DBX_WEB_MCP_TOKEN` |
|
||||
| `DBX_WEB_MCP_ALLOWED_HOSTS` | Required comma-separated public Host authorities for native DBX Web MCP |
|
||||
| `DBX_WEB_MCP_ALLOWED_ORIGINS` | Comma-separated browser Origins allowed for native DBX Web MCP |
|
||||
| `DBX_MCP_TRANSPORT` | `stdio` (default) or `streamable-http` for the native server |
|
||||
| `DBX_MCP_HTTP_HOST` | Native HTTP bind address (default: `127.0.0.1`) |
|
||||
| `DBX_MCP_HTTP_PORT` | Native HTTP bind port (default: `5225`) |
|
||||
| `DBX_MCP_HTTP_PATH` | Native HTTP endpoint path (default: `/mcp`) |
|
||||
| `DBX_MCP_HTTP_TOKEN` | Bearer token required by native Streamable HTTP |
|
||||
| `DBX_MCP_HTTP_TOKEN_FILE` | Read the native HTTP bearer token from a file; cannot be combined with `DBX_MCP_HTTP_TOKEN` |
|
||||
| `DBX_MCP_HTTP_ALLOW_REMOTE` | Set to `1` together with `--http-allow-remote` before a non-loopback bind is allowed |
|
||||
| `DBX_MCP_HTTP_ALLOWED_HOSTS` | Required Host authority allowlist for a non-loopback native bind |
|
||||
| `DBX_MCP_HTTP_ALLOWED_ORIGINS` | Required browser Origin allowlist for a non-loopback native bind |
|
||||
| `DBX_MCP_ALLOW_WRITES` | Upgrade compatibility only: `0`/`false` keeps an unconfigured policy read-only |
|
||||
| `DBX_MCP_SCOPE_CONNECTION_ID` | Compatibility scope for one connection ID |
|
||||
| `DBX_MCP_SCOPE_CONNECTION_IDS` | Compatibility scope for multiple connection IDs |
|
||||
@@ -378,9 +433,10 @@ MCP 协议、连接读取、SQL 安全检查、Schema、Redis、MongoDB、Web
|
||||
|
||||
### 主要能力
|
||||
|
||||
- 10 个 MCP 工具
|
||||
- 15 个 MCP 工具,涵盖连接和数据库发现、Schema、SQL、Redis、会话、消息队列和 DBX 桌面集成
|
||||
- 不依赖 `better-sqlite3`,没有 Node 原生模块 ABI 问题
|
||||
- 支持本地 DBX、DBX Web 和 Docker
|
||||
- 可选 Streamable HTTP 传输,使用 Bearer Token 保护;stdio 仍为默认方式
|
||||
- 支持预编译原生二进制和离线运行
|
||||
- 支持常见 SQL、Redis、MongoDB 直连
|
||||
- 支持达梦、金仓、Oracle、DB2、Hive 等 Agent/JDBC 数据库
|
||||
@@ -460,16 +516,23 @@ MCP 配置:
|
||||
| 工具 | 说明 |
|
||||
| --- | --- |
|
||||
| `dbx_list_connections` | 列出当前 MCP 会话可见的连接 |
|
||||
| `dbx_list_databases` | 列出连接中可通过 MCP 访问的数据库,并遵守该连接的数据库范围 |
|
||||
| `dbx_add_connection` | 添加 DBX 连接配置 |
|
||||
| `dbx_duplicate_connection` | 复制一个连接及其完整配置 |
|
||||
| `dbx_remove_connection` | 删除 DBX 连接配置 |
|
||||
| `dbx_list_tables` | 列出表、视图、集合或消息队列 Topic |
|
||||
| `dbx_describe_table` | 获取字段和表结构 |
|
||||
| `dbx_get_schema_context` | 获取适合 AI 使用的紧凑 Schema 上下文 |
|
||||
| `dbx_execute_query` | 执行 SQL 或支持的 MongoDB Shell 命令,最多返回 100 行 |
|
||||
| `dbx_open_session` | 为 SQL 连接打开固定后端连接的有状态查询会话 |
|
||||
| `dbx_close_session` | 关闭会话并释放固定连接资源 |
|
||||
| `dbx_execute_redis_command` | 执行 Redis 命令 |
|
||||
| `dbx_send_message` | 向支持的消息队列 Topic 发送消息 |
|
||||
| `dbx_open_table` | 在 DBX 桌面端打开表 |
|
||||
| `dbx_execute_and_show` | 执行查询并在 DBX 桌面端展示结果 |
|
||||
|
||||
`dbx_list_databases` 只返回该连接 MCP 数据库范围内允许访问的名称。`dbx_send_message` 仅在 Server 构建时包含消息队列支持时可用。
|
||||
|
||||
### 本地数据目录
|
||||
|
||||
- macOS:`~/Library/Application Support/com.dbx.app/dbx.db`
|
||||
@@ -498,6 +561,40 @@ Web 模式不会读取本机 DBX 桌面存储,也不会暴露桌面 UI 工具
|
||||
|
||||
DBX Web 请求遵循标准系统代理环境变量:https 地址读取 `HTTPS_PROXY`/`https_proxy`,http 地址读取 `HTTP_PROXY`/`http_proxy`,`ALL_PROXY`/`all_proxy` 作为兜底,`NO_PROXY`/`no_proxy` 作为绕过列表。代理值为空或未设置时直连(不走代理)。需要认证的代理使用标准 `user:password@host:port` URL 形式,例如 `http://admin:admin123@127.0.0.1:7890`。如需为每次 DBX Web 请求附加自定义请求头(例如网关前的令牌认证),将 `DBX_WEB_HEADERS` 设置为 JSON 对象,键为请求头名称、值为字符串,例如 `{"Authorization":"Bearer <token>"}`,该头会附加到包括认证在内的每个请求。
|
||||
|
||||
### 原生 Streamable HTTP
|
||||
|
||||
原生 Server 默认使用 stdio。需要直接提供本机 Streamable HTTP 服务时,启动时传入 Bearer Token:
|
||||
|
||||
```bash
|
||||
DBX_MCP_HTTP_TOKEN=replace-with-a-long-random-secret dbx-mcp-server --http
|
||||
```
|
||||
|
||||
默认监听 `http://127.0.0.1:5225/mcp`。在支持 HTTP 的 MCP 客户端中填写该 URL,并携带 `Authorization: Bearer <token>`:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "http",
|
||||
"url": "http://127.0.0.1:5225/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer replace-with-a-long-random-secret"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
默认回环地址仅允许同一台电脑上的客户端访问。监听非回环地址时,必须同时设置 `DBX_MCP_HTTP_ALLOW_REMOTE=1`、传入 `--http-allow-remote`,并提供非空的 `DBX_MCP_HTTP_ALLOWED_HOSTS` 和 `DBX_MCP_HTTP_ALLOWED_ORIGINS` 白名单。Host authority 与浏览器 Origin 均应使用精确的公网值。
|
||||
|
||||
DBX Web 可以通过现有 Web 监听器提供原生 Streamable HTTP,无需额外开放第二个端口。设置 `DBX_WEB_MCP_TOKEN`(或 `DBX_WEB_MCP_TOKEN_FILE`)并配置公网 Host 白名单即可启用。容器映射为 `4225:4224` 时,端点为 `http://localhost:4225/mcp`:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
DBX_WEB_MCP_TOKEN: replace-with-a-long-random-secret
|
||||
DBX_WEB_MCP_ALLOWED_HOSTS: localhost:4225
|
||||
ports:
|
||||
- "4225:4224"
|
||||
```
|
||||
|
||||
客户端必须携带 `Authorization: Bearer <DBX_WEB_MCP_TOKEN>`。浏览器客户端还需要在 `DBX_WEB_MCP_ALLOWED_ORIGINS` 中填写精确 Origin。反向代理添加路径前缀时,设置 `DBX_PUBLIC_BASE_PATH`,端点会变为 `<base-path>/mcp`。原生 HTTP 与 `DBX_WEB_URL` 的 stdio 适配方式可以并存,并共享同一份 DBX 策略。
|
||||
|
||||
### Agent/JDBC 数据库
|
||||
|
||||
达梦、金仓KingbaseES、Oracle、DB2、Hive、Trino、Snowflake、SAP HANA 等数据库通过 DBX Java Agent/JDBC 基础设施运行,而不是通过 Node.js 数据库驱动运行。
|
||||
@@ -554,6 +651,19 @@ MongoDB 更新和删除在未启用完全访问时必须提供可验证有效的
|
||||
| `DBX_WEB_HEADERS` | DBX Web 请求附加的请求头 JSON 对象,例如 `{"Authorization":"Bearer token"}` |
|
||||
| `DBX_WEB_INSECURE_SKIP_VERIFY` | `1`/`true` 时跳过 TLS 证书验证(用于自签名后端) |
|
||||
| `DBX_WEB_CA_CERT` | DBX Web TLS 验证时信任的 PEM/DER CA 文件 |
|
||||
| `DBX_WEB_MCP_TOKEN` | 使用该 Bearer Token 启用原生 DBX Web Streamable HTTP MCP |
|
||||
| `DBX_WEB_MCP_TOKEN_FILE` | 从文件读取原生 DBX Web MCP Token;不能与 `DBX_WEB_MCP_TOKEN` 同时设置 |
|
||||
| `DBX_WEB_MCP_ALLOWED_HOSTS` | 原生 DBX Web MCP 必填:允许的公网 Host authority,逗号分隔 |
|
||||
| `DBX_WEB_MCP_ALLOWED_ORIGINS` | 原生 DBX Web MCP 的浏览器 Origin 白名单,逗号分隔 |
|
||||
| `DBX_MCP_TRANSPORT` | 原生 Server 传输方式:`stdio`(默认)或 `streamable-http` |
|
||||
| `DBX_MCP_HTTP_HOST` | 原生 HTTP 监听地址(默认:`127.0.0.1`) |
|
||||
| `DBX_MCP_HTTP_PORT` | 原生 HTTP 监听端口(默认:`5225`) |
|
||||
| `DBX_MCP_HTTP_PATH` | 原生 HTTP 端点路径(默认:`/mcp`) |
|
||||
| `DBX_MCP_HTTP_TOKEN` | 原生 Streamable HTTP 必填的 Bearer Token |
|
||||
| `DBX_MCP_HTTP_TOKEN_FILE` | 从文件读取原生 HTTP Bearer Token;不能与 `DBX_MCP_HTTP_TOKEN` 同时设置 |
|
||||
| `DBX_MCP_HTTP_ALLOW_REMOTE` | 与 `--http-allow-remote` 同时设置为允许监听非回环地址 |
|
||||
| `DBX_MCP_HTTP_ALLOWED_HOSTS` | 监听非回环地址时必填的 Host authority 白名单 |
|
||||
| `DBX_MCP_HTTP_ALLOWED_ORIGINS` | 监听非回环地址时必填的浏览器 Origin 白名单 |
|
||||
| `DBX_MCP_ALLOW_WRITES` | 仅用于升级兼容:`0`/`false` 使尚未配置的策略保持只读 |
|
||||
| `DBX_MCP_SCOPE_CONNECTION_ID` | 兼容旧配置:限制到指定连接 ID |
|
||||
| `DBX_MCP_SCOPE_CONNECTION_IDS` | 兼容旧配置:限制到多个连接 ID |
|
||||
|
||||
@@ -67,6 +67,7 @@ csv = "1.4.0"
|
||||
calamine = "0.30.1"
|
||||
zip = "4.6.1"
|
||||
dbx-core = { path = "../crates/dbx-core", default-features = false }
|
||||
dbx-mcp = { path = "../crates/dbx-mcp", default-features = false, features = ["duckdb-sidecar", "mq-admin"] }
|
||||
wry = { path = "../vendor/wry" }
|
||||
axum = { version = "0.8", features = ["ws"] }
|
||||
font-kit = "0.14.3"
|
||||
|
||||
@@ -7,7 +7,7 @@ use tokio::net::TcpListener;
|
||||
|
||||
use super::connection::AppState;
|
||||
|
||||
use dbx_core::storage::McpGlobalPolicy;
|
||||
use dbx_core::storage::{McpDatabaseScope, McpGlobalPolicy};
|
||||
|
||||
const BIND_ADDR: &str = "127.0.0.1:0";
|
||||
const MCP_BRIDGE_PORT_FILE: &str = "mcp-bridge-port";
|
||||
@@ -424,7 +424,7 @@ mod tests {
|
||||
read_only: false,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: None,
|
||||
query_timeout_secs: None,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(ensure_connection_in_mcp_scope(&all, "conn-1").is_ok());
|
||||
|
||||
@@ -432,7 +432,7 @@ mod tests {
|
||||
read_only: false,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: Some(vec!["conn-1".to_string()]),
|
||||
query_timeout_secs: None,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(ensure_connection_in_mcp_scope(&subset, "conn-1").is_ok());
|
||||
assert!(ensure_connection_in_mcp_scope(&subset, "conn-2").unwrap_err().starts_with("CONNECTION_OUT_OF_SCOPE:"));
|
||||
@@ -441,7 +441,7 @@ mod tests {
|
||||
read_only: false,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: Some(Vec::new()),
|
||||
query_timeout_secs: None,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(ensure_connection_in_mcp_scope(&none, "conn-1").is_err());
|
||||
}
|
||||
@@ -568,6 +568,35 @@ fn ensure_connection_in_mcp_scope(policy: &McpGlobalPolicy, connection_id: &str)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_database_in_mcp_scope(policy: &McpGlobalPolicy, connection_id: &str, database: &str) -> Result<(), String> {
|
||||
let Some(rule) = policy.connection_policies.iter().find(|rule| rule.connection_id == connection_id) else {
|
||||
return Ok(());
|
||||
};
|
||||
let allowed = match rule.database_scope {
|
||||
McpDatabaseScope::All => true,
|
||||
McpDatabaseScope::Selected => rule.allowed_databases.iter().any(|allowed| allowed == database),
|
||||
McpDatabaseScope::None => false,
|
||||
};
|
||||
if allowed {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"DATABASE_OUT_OF_SCOPE: database '{database}' is not allowed by DBX MCP settings for connection '{connection_id}'"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn effective_connection_execution_policy(policy: &McpGlobalPolicy, connection_id: &str) -> (bool, bool) {
|
||||
let Some(rule) = policy
|
||||
.connection_policies
|
||||
.iter()
|
||||
.find(|rule| rule.connection_id == connection_id && rule.execution_mode_configured)
|
||||
else {
|
||||
return (policy.read_only, policy.allow_dangerous_sql);
|
||||
};
|
||||
(policy.read_only || rule.read_only, policy.allow_dangerous_sql && !rule.read_only && rule.allow_dangerous_sql)
|
||||
}
|
||||
|
||||
async fn ensure_mcp_write_allowed(
|
||||
state: &Arc<AppState>,
|
||||
config: &crate::models::connection::ConnectionConfig,
|
||||
@@ -586,10 +615,12 @@ async fn ensure_mcp_write_allowed_with_risk(
|
||||
) -> Result<(), String> {
|
||||
let policy = load_mcp_policy(state).await?;
|
||||
ensure_connection_in_mcp_scope(&policy, &config.id)?;
|
||||
if policy.read_only {
|
||||
ensure_database_in_mcp_scope(&policy, &config.id, database)?;
|
||||
let (read_only, allow_dangerous_sql) = effective_connection_execution_policy(&policy, &config.id);
|
||||
if read_only {
|
||||
return Err(format!("MCP_READ_ONLY: DBX MCP read-only mode is enabled. {action} blocked."));
|
||||
}
|
||||
if dangerous && !policy.allow_dangerous_sql {
|
||||
if dangerous && !allow_dangerous_sql {
|
||||
return Err(format!("SQL_BLOCKED: High-risk operation '{action}' is disabled in DBX MCP settings."));
|
||||
}
|
||||
if config.read_only {
|
||||
@@ -611,6 +642,7 @@ pub(crate) async fn ensure_mcp_read_allowed_by_id(
|
||||
) -> Result<(), String> {
|
||||
let policy = load_mcp_policy(state).await?;
|
||||
ensure_connection_in_mcp_scope(&policy, connection_id)?;
|
||||
ensure_database_in_mcp_scope(&policy, connection_id, database)?;
|
||||
let configs = state.storage.load_connections().await.map_err(|e| format!("MCP_POLICY_UNAVAILABLE: {e}"))?;
|
||||
let config = configs
|
||||
.iter()
|
||||
@@ -643,6 +675,11 @@ async fn ensure_mcp_mongo_pipeline_target_allowed_by_id(
|
||||
database: &str,
|
||||
pipeline_json: &str,
|
||||
) -> Result<(), String> {
|
||||
let policy = load_mcp_policy(state).await?;
|
||||
ensure_connection_in_mcp_scope(&policy, connection_id)?;
|
||||
for target_database in mongo_pipeline_output_databases(pipeline_json, database)? {
|
||||
ensure_database_in_mcp_scope(&policy, connection_id, &target_database)?;
|
||||
}
|
||||
let configs = state.storage.load_connections().await.map_err(|e| format!("MCP_POLICY_UNAVAILABLE: {e}"))?;
|
||||
let config = configs
|
||||
.iter()
|
||||
@@ -656,6 +693,34 @@ async fn ensure_mcp_mongo_pipeline_target_allowed_by_id(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mongo_pipeline_output_databases(pipeline_json: &str, active_database: &str) -> Result<Vec<String>, String> {
|
||||
let stages = serde_json::from_str::<serde_json::Value>(pipeline_json)
|
||||
.ok()
|
||||
.and_then(|value| value.as_array().cloned())
|
||||
.ok_or_else(|| "QUERY_ERROR: MongoDB aggregate pipeline must be a JSON array.".to_string())?;
|
||||
let mut databases = Vec::new();
|
||||
for stage in stages {
|
||||
let Some(stage) = stage.as_object() else { continue };
|
||||
for key in ["$out", "$merge"] {
|
||||
let Some(target) = stage.get(key) else { continue };
|
||||
let database = match target {
|
||||
serde_json::Value::String(_) => active_database.to_string(),
|
||||
serde_json::Value::Object(target) => target
|
||||
.get("db")
|
||||
.or_else(|| {
|
||||
target.get("into").and_then(serde_json::Value::as_object).and_then(|into| into.get("db"))
|
||||
})
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or(active_database)
|
||||
.to_string(),
|
||||
_ => return Err("QUERY_ERROR: MongoDB aggregate output target must be a string or object.".to_string()),
|
||||
};
|
||||
databases.push(database);
|
||||
}
|
||||
}
|
||||
Ok(databases)
|
||||
}
|
||||
|
||||
async fn ensure_mcp_write_allowed_by_id_with_risk(
|
||||
state: &Arc<AppState>,
|
||||
connection_id: &str,
|
||||
@@ -920,12 +985,29 @@ async fn ensure_mcp_sql_allowed(
|
||||
) -> Result<(), String> {
|
||||
let policy = load_mcp_policy(state).await?;
|
||||
ensure_connection_in_mcp_scope(&policy, &config.id)?;
|
||||
ensure_database_in_mcp_scope(&policy, &config.id, database)?;
|
||||
if let Some(rule) = policy.connection_policies.iter().find(|rule| rule.connection_id == config.id) {
|
||||
if rule.database_scope == McpDatabaseScope::Selected
|
||||
&& dbx_core::production_safety::sql_references_disallowed_database(
|
||||
sql,
|
||||
&config.db_type,
|
||||
database,
|
||||
&rule.allowed_databases,
|
||||
)
|
||||
{
|
||||
return Err(
|
||||
"DATABASE_OUT_OF_SCOPE: SQL references a database that is not allowed by DBX MCP settings for this connection."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
ensure_mcp_sql_database_switch_allowed(config.db_type, sql)?;
|
||||
let is_write = dbx_core::query_execution_sql::is_write_sql_for_database(sql, config.db_type);
|
||||
if policy.read_only && is_write {
|
||||
let (read_only, allow_dangerous_sql) = effective_connection_execution_policy(&policy, &config.id);
|
||||
if read_only && is_write {
|
||||
return Err("MCP_READ_ONLY: DBX MCP read-only mode is enabled. SQL write blocked.".to_string());
|
||||
}
|
||||
if !policy.allow_dangerous_sql && dbx_core::sql_risk::is_dangerous_sql_for_database(sql, config.db_type) {
|
||||
if !allow_dangerous_sql && dbx_core::sql_risk::is_dangerous_sql_for_database(sql, config.db_type) {
|
||||
return Err("SQL_BLOCKED: High-risk SQL is disabled in DBX MCP settings.".to_string());
|
||||
}
|
||||
ensure_mcp_connection_sql_write_allowed(config, is_write)?;
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
fs,
|
||||
net::{IpAddr, SocketAddr},
|
||||
path::PathBuf,
|
||||
sync::{
|
||||
atomic::{AtomicU64, Ordering},
|
||||
Arc, Mutex,
|
||||
},
|
||||
};
|
||||
|
||||
use dbx_core::{connection::AppState, storage::McpHttpServerSettings};
|
||||
use dbx_mcp::{serve_streamable_http_on_listener, DbxBackend, HttpAuth, HttpRuntimeConfig, LocalBackend};
|
||||
use serde::Serialize;
|
||||
use tauri::State;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use uuid::Uuid;
|
||||
|
||||
const TOKEN_FILE_NAME: &str = "mcp-http-token";
|
||||
|
||||
/// Owns the optional in-process Streamable HTTP server. It is intentionally
|
||||
/// separate from `AppState`: this is desktop runtime state, not database data.
|
||||
pub struct McpHttpServerState {
|
||||
data_dir: PathBuf,
|
||||
/// Serializes lifecycle operations. In particular, a settings save must
|
||||
/// finish shutting down the old listener before it binds the replacement.
|
||||
operation_lock: tokio::sync::Mutex<()>,
|
||||
server: Mutex<Option<RunningServer>>,
|
||||
diagnostics: Arc<Mutex<McpHttpServerDiagnostics>>,
|
||||
supervisor: Mutex<Option<HealthSupervisor>>,
|
||||
next_supervisor_id: AtomicU64,
|
||||
}
|
||||
|
||||
struct RunningServer {
|
||||
settings: McpHttpServerSettings,
|
||||
cancellation: CancellationToken,
|
||||
task: tauri::async_runtime::JoinHandle<()>,
|
||||
}
|
||||
|
||||
struct HealthSupervisor {
|
||||
id: u64,
|
||||
cancellation: CancellationToken,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct McpHttpServerDiagnostics {
|
||||
last_error: Option<String>,
|
||||
logs: VecDeque<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct McpHttpServerStatus {
|
||||
pub enabled: bool,
|
||||
pub running: bool,
|
||||
pub endpoint: Option<String>,
|
||||
/// Returned only to the local desktop renderer so the user can copy the
|
||||
/// credential into an MCP client. It is never persisted in app settings.
|
||||
pub access_token: Option<String>,
|
||||
pub last_error: Option<String>,
|
||||
pub recent_logs: Vec<String>,
|
||||
}
|
||||
|
||||
impl McpHttpServerState {
|
||||
pub fn new(data_dir: PathBuf) -> Self {
|
||||
Self {
|
||||
data_dir,
|
||||
operation_lock: tokio::sync::Mutex::new(()),
|
||||
server: Mutex::new(None),
|
||||
diagnostics: Arc::new(Mutex::new(McpHttpServerDiagnostics::default())),
|
||||
supervisor: Mutex::new(None),
|
||||
next_supervisor_id: AtomicU64::new(1),
|
||||
}
|
||||
}
|
||||
|
||||
fn token_path(&self) -> PathBuf {
|
||||
self.data_dir.join(TOKEN_FILE_NAME)
|
||||
}
|
||||
|
||||
fn rotate_token(&self) -> Result<(), String> {
|
||||
let path = self.token_path();
|
||||
match fs::remove_file(&path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(format!("failed to rotate MCP HTTP token: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_or_create_token(&self) -> Result<String, String> {
|
||||
let path = self.token_path();
|
||||
if let Ok(token) = fs::read_to_string(&path) {
|
||||
let token = token.trim().to_string();
|
||||
if !token.is_empty() {
|
||||
return Ok(token);
|
||||
}
|
||||
}
|
||||
|
||||
fs::create_dir_all(&self.data_dir).map_err(|error| format!("failed to create MCP token directory: {error}"))?;
|
||||
let token = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple());
|
||||
fs::write(&path, &token).map_err(|error| format!("failed to save MCP HTTP token: {error}"))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))
|
||||
.map_err(|error| format!("failed to protect MCP HTTP token: {error}"))?;
|
||||
}
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
fn status(&self, settings: &McpHttpServerSettings) -> McpHttpServerStatus {
|
||||
let mut server = self.server.lock().unwrap_or_else(|error| error.into_inner());
|
||||
let running = server.as_ref().is_some_and(|server| !server.task.inner().is_finished());
|
||||
if !running {
|
||||
*server = None;
|
||||
}
|
||||
let endpoint = settings.enabled.then(|| endpoint_for_settings(settings)).flatten();
|
||||
let access_token = settings.enabled.then(|| self.load_or_create_token()).transpose().ok().flatten();
|
||||
let diagnostics = self.diagnostics.lock().unwrap_or_else(|error| error.into_inner());
|
||||
McpHttpServerStatus {
|
||||
enabled: settings.enabled,
|
||||
running,
|
||||
endpoint,
|
||||
access_token,
|
||||
last_error: diagnostics.last_error.clone(),
|
||||
recent_logs: diagnostics.logs.iter().cloned().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn record_error(&self, error: String) {
|
||||
let mut diagnostics = self.diagnostics.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
diagnostics.last_error = Some(error.clone());
|
||||
push_log(&mut diagnostics, format!("ERROR {error}"));
|
||||
}
|
||||
|
||||
fn cancel_health_supervisor(&self) {
|
||||
let supervisor = self.supervisor.lock().unwrap_or_else(|error| error.into_inner()).take();
|
||||
if let Some(supervisor) = supervisor {
|
||||
supervisor.cancellation.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
fn claim_health_supervisor(&self) -> Option<(u64, CancellationToken)> {
|
||||
let mut supervisor = self.supervisor.lock().unwrap_or_else(|error| error.into_inner());
|
||||
if supervisor.is_some() {
|
||||
return None;
|
||||
}
|
||||
let id = self.next_supervisor_id.fetch_add(1, Ordering::Relaxed);
|
||||
let cancellation = CancellationToken::new();
|
||||
*supervisor = Some(HealthSupervisor { id, cancellation: cancellation.clone() });
|
||||
Some((id, cancellation))
|
||||
}
|
||||
|
||||
fn release_health_supervisor(&self, id: u64) {
|
||||
let mut supervisor = self.supervisor.lock().unwrap_or_else(|error| error.into_inner());
|
||||
if supervisor.as_ref().is_some_and(|supervisor| supervisor.id == id) {
|
||||
*supervisor = None;
|
||||
}
|
||||
}
|
||||
|
||||
async fn stop_locked(&self) {
|
||||
let running_server = {
|
||||
let mut server = self.server.lock().unwrap_or_else(|error| error.into_inner());
|
||||
server.take()
|
||||
};
|
||||
if let Some(server) = running_server {
|
||||
server.cancellation.cancel();
|
||||
// The listener lives inside the Axum task. Cancelling and waiting
|
||||
// for that task ensures the socket is actually released before a
|
||||
// replacement listener tries to bind the same address and port.
|
||||
let mut task = server.task;
|
||||
if tokio::time::timeout(std::time::Duration::from_secs(3), &mut task).await.is_err() {
|
||||
task.abort();
|
||||
let _ = task.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn stop(&self) {
|
||||
let _operation = self.operation_lock.lock().await;
|
||||
self.stop_locked().await;
|
||||
}
|
||||
|
||||
async fn start(&self, app_state: Arc<AppState>, settings: &McpHttpServerSettings) -> Result<(), String> {
|
||||
let _operation = self.operation_lock.lock().await;
|
||||
let config = self.runtime_config(settings)?;
|
||||
let previous_settings = self
|
||||
.server
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.as_ref()
|
||||
.map(|server| server.settings.clone());
|
||||
match self.start_with_config_locked(app_state.clone(), settings, config).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) => {
|
||||
if let Some(previous_settings) = previous_settings {
|
||||
match self.runtime_config(&previous_settings).map(|config| (previous_settings, config)) {
|
||||
Ok((previous_settings, config)) => {
|
||||
if let Err(restore_error) =
|
||||
self.start_with_config_locked(app_state, &previous_settings, config).await
|
||||
{
|
||||
return Err(format!("{error}; additionally failed to restore the previous MCP HTTP service: {restore_error}"));
|
||||
}
|
||||
}
|
||||
Err(restore_error) => {
|
||||
return Err(format!("{error}; additionally failed to restore the previous MCP HTTP service: {restore_error}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_config(&self, settings: &McpHttpServerSettings) -> Result<HttpRuntimeConfig, String> {
|
||||
validate_settings(settings)?;
|
||||
let token = self.load_or_create_token()?;
|
||||
let ip = settings.host.parse::<IpAddr>().map_err(|_| "MCP HTTP host must be an IP address".to_string())?;
|
||||
let bind_addr = SocketAddr::new(ip, settings.port);
|
||||
let allowed_hosts = if ip.is_loopback() {
|
||||
vec!["localhost".to_string(), "127.0.0.1".to_string(), "::1".to_string()]
|
||||
} else {
|
||||
settings.allowed_hosts.clone()
|
||||
};
|
||||
let auth = HttpAuth::new(token, settings.allowed_origins.clone(), ip.is_loopback())?;
|
||||
Ok(HttpRuntimeConfig::new(bind_addr, settings.path.clone(), auth, allowed_hosts))
|
||||
}
|
||||
|
||||
async fn start_with_config_locked(
|
||||
&self,
|
||||
app_state: Arc<AppState>,
|
||||
settings: &McpHttpServerSettings,
|
||||
config: HttpRuntimeConfig,
|
||||
) -> Result<(), String> {
|
||||
self.stop_locked().await;
|
||||
// Bind before we claim success in the UI. This makes an occupied port
|
||||
// or an invalid socket setup a synchronous settings error instead of
|
||||
// a misleading short-lived "running" status.
|
||||
let bind_addr = config.bind_addr();
|
||||
let listener = tokio::net::TcpListener::bind(bind_addr)
|
||||
.await
|
||||
.map_err(|error| format!("failed to bind MCP HTTP service at {bind_addr}: {error}"))?;
|
||||
let cancellation = CancellationToken::new();
|
||||
let diagnostics = self.diagnostics.clone();
|
||||
let backend: Arc<dyn DbxBackend> = Arc::new(LocalBackend::from_app_state(app_state, self.data_dir.clone()));
|
||||
let shutdown = cancellation.clone();
|
||||
let task = tauri::async_runtime::spawn(async move {
|
||||
let result = serve_streamable_http_on_listener(backend, config, shutdown.clone(), listener).await;
|
||||
if let Err(error) = result {
|
||||
let mut diagnostics = diagnostics.lock().unwrap_or_else(|error| error.into_inner());
|
||||
let message = format!("MCP HTTP service stopped unexpectedly: {error}");
|
||||
diagnostics.last_error = Some(message.clone());
|
||||
push_log(&mut diagnostics, format!("ERROR {message}"));
|
||||
} else if !shutdown.is_cancelled() {
|
||||
let mut diagnostics = diagnostics.lock().unwrap_or_else(|error| error.into_inner());
|
||||
let message = "MCP HTTP service stopped unexpectedly".to_string();
|
||||
diagnostics.last_error = Some(message.clone());
|
||||
push_log(&mut diagnostics, format!("ERROR {message}"));
|
||||
}
|
||||
});
|
||||
*self.server.lock().unwrap_or_else(|error| error.into_inner()) =
|
||||
Some(RunningServer { settings: settings.clone(), cancellation, task });
|
||||
{
|
||||
let mut diagnostics = self.diagnostics.lock().unwrap_or_else(|error| error.into_inner());
|
||||
diagnostics.last_error = None;
|
||||
push_log(&mut diagnostics, "MCP HTTP service started".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn endpoint_for_settings(settings: &McpHttpServerSettings) -> Option<String> {
|
||||
let host = settings.host.trim();
|
||||
let authority = match host {
|
||||
"0.0.0.0" | "::" => format_client_authority(
|
||||
settings.allowed_hosts.first().map(|host| host.trim()).filter(|host| !host.is_empty())?,
|
||||
settings.port,
|
||||
),
|
||||
host if host.contains(':') => format!("[{host}]:{}", settings.port),
|
||||
host => format!("{host}:{}", settings.port),
|
||||
};
|
||||
Some(format!("http://{authority}{}", settings.path))
|
||||
}
|
||||
|
||||
fn format_client_authority(authority: &str, port: u16) -> String {
|
||||
if authority.starts_with('[') {
|
||||
return if authority.rsplit_once(':').is_some_and(|(_, port)| port.parse::<u16>().is_ok()) {
|
||||
authority.to_string()
|
||||
} else {
|
||||
format!("{authority}:{port}")
|
||||
};
|
||||
}
|
||||
if let Ok(ip) = authority.parse::<IpAddr>() {
|
||||
return if ip.is_ipv6() { format!("[{authority}]:{port}") } else { format!("{authority}:{port}") };
|
||||
}
|
||||
if authority.rsplit_once(':').is_some_and(|(_, port)| port.parse::<u16>().is_ok()) {
|
||||
authority.to_string()
|
||||
} else {
|
||||
format!("{authority}:{port}")
|
||||
}
|
||||
}
|
||||
|
||||
fn push_log(diagnostics: &mut McpHttpServerDiagnostics, line: String) {
|
||||
const MAX_LOG_LINES: usize = 100;
|
||||
diagnostics.logs.push_back(line);
|
||||
while diagnostics.logs.len() > MAX_LOG_LINES {
|
||||
diagnostics.logs.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for McpHttpServerState {
|
||||
fn drop(&mut self) {
|
||||
if let Some(supervisor) = self.supervisor.get_mut().unwrap_or_else(|error| error.into_inner()).take() {
|
||||
supervisor.cancellation.cancel();
|
||||
}
|
||||
if let Some(server) = self.server.get_mut().unwrap_or_else(|error| error.into_inner()).take() {
|
||||
server.cancellation.cancel();
|
||||
server.task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_settings(settings: &McpHttpServerSettings) -> Result<(), String> {
|
||||
let ip = settings.host.parse::<IpAddr>().map_err(|_| "MCP HTTP host must be an IP address".to_string())?;
|
||||
if settings.port == 0 {
|
||||
return Err("MCP HTTP port must be between 1 and 65535".to_string());
|
||||
}
|
||||
if settings.path == "/"
|
||||
|| !settings.path.starts_with('/')
|
||||
|| settings.path.ends_with('/')
|
||||
|| settings.path.contains('?')
|
||||
|| settings.path.contains('#')
|
||||
{
|
||||
return Err("MCP HTTP path must be an absolute path such as /mcp".to_string());
|
||||
}
|
||||
if !ip.is_loopback() {
|
||||
if !settings.allow_remote {
|
||||
return Err("non-loopback MCP HTTP binding requires remote access to be explicitly enabled".to_string());
|
||||
}
|
||||
if settings.allowed_hosts.is_empty() || settings.allowed_origins.is_empty() {
|
||||
return Err("remote MCP HTTP binding requires allowed hosts and allowed origins".to_string());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_mcp_http_server_settings(state: State<'_, Arc<AppState>>) -> Result<McpHttpServerSettings, String> {
|
||||
state.storage.load_mcp_http_server_settings().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_mcp_http_server_settings(
|
||||
settings: McpHttpServerSettings,
|
||||
state: State<'_, Arc<AppState>>,
|
||||
service: State<'_, Arc<McpHttpServerState>>,
|
||||
) -> Result<McpHttpServerStatus, String> {
|
||||
let previous_settings = state.storage.load_mcp_http_server_settings().await?;
|
||||
if settings.enabled {
|
||||
service.start(state.inner().clone(), &settings).await?;
|
||||
} else {
|
||||
service.stop().await;
|
||||
}
|
||||
if let Err(error) = state.storage.save_mcp_http_server_settings(&settings).await {
|
||||
let restore_result = if previous_settings.enabled {
|
||||
service.start(state.inner().clone(), &previous_settings).await
|
||||
} else {
|
||||
service.stop().await;
|
||||
Ok(())
|
||||
};
|
||||
if let Err(restore_error) = restore_result {
|
||||
return Err(format!(
|
||||
"{error}; additionally failed to restore the previous MCP HTTP service: {restore_error}"
|
||||
));
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
if settings.enabled {
|
||||
spawn_health_supervisor(state.inner().clone(), service.inner().clone());
|
||||
} else {
|
||||
service.cancel_health_supervisor();
|
||||
}
|
||||
Ok(service.status(&settings))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mcp_http_server_status(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
service: State<'_, Arc<McpHttpServerState>>,
|
||||
) -> Result<McpHttpServerStatus, String> {
|
||||
let settings = state.storage.load_mcp_http_server_settings().await?;
|
||||
let status = service.status(&settings);
|
||||
if settings.enabled && !status.running {
|
||||
if let Err(error) = service.start(state.inner().clone(), &settings).await {
|
||||
service.record_error(format!("automatic restart failed: {error}"));
|
||||
}
|
||||
}
|
||||
Ok(service.status(&settings))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn rotate_mcp_http_server_token(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
service: State<'_, Arc<McpHttpServerState>>,
|
||||
) -> Result<McpHttpServerStatus, String> {
|
||||
let settings = state.storage.load_mcp_http_server_settings().await?;
|
||||
service.rotate_token()?;
|
||||
if settings.enabled {
|
||||
service.start(state.inner().clone(), &settings).await?;
|
||||
}
|
||||
Ok(service.status(&settings))
|
||||
}
|
||||
|
||||
pub async fn start_if_enabled(state: Arc<AppState>, service: Arc<McpHttpServerState>) {
|
||||
match state.storage.load_mcp_http_server_settings().await {
|
||||
Ok(settings) if settings.enabled => {
|
||||
if let Err(error) = service.start(state.clone(), &settings).await {
|
||||
log::warn!("Failed to restore MCP HTTP service: {error}");
|
||||
service.record_error(format!("failed to restore MCP HTTP service: {error}"));
|
||||
}
|
||||
spawn_health_supervisor(state, service);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(error) => log::warn!("Failed to load MCP HTTP settings: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The service is optional, but once a user explicitly enables it it should
|
||||
/// not require opening Settings to recover from a transient child-process
|
||||
/// failure. The supervisor deliberately lives only in the desktop process:
|
||||
/// Web deployments are managed by their container/process supervisor instead.
|
||||
fn spawn_health_supervisor(state: Arc<AppState>, service: Arc<McpHttpServerState>) {
|
||||
let Some((supervisor_id, cancellation)) = service.claim_health_supervisor() else {
|
||||
return;
|
||||
};
|
||||
tauri::async_runtime::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancellation.cancelled() => break,
|
||||
_ = tokio::time::sleep(std::time::Duration::from_secs(15)) => {}
|
||||
}
|
||||
let settings = match state.storage.load_mcp_http_server_settings().await {
|
||||
Ok(settings) => settings,
|
||||
Err(error) => {
|
||||
service.record_error(format!("MCP HTTP health supervisor could not load settings: {error}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !settings.enabled {
|
||||
break;
|
||||
}
|
||||
if !service.status(&settings).running {
|
||||
if let Err(error) = service.start(state.clone(), &settings).await {
|
||||
service.record_error(format!("automatic restart failed: {error}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
service.release_health_supervisor(supervisor_id);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{endpoint_for_settings, McpHttpServerState};
|
||||
use dbx_core::storage::McpHttpServerSettings;
|
||||
|
||||
#[test]
|
||||
fn endpoint_uses_a_client_reachable_authority() {
|
||||
let settings = McpHttpServerSettings {
|
||||
enabled: true,
|
||||
host: "0.0.0.0".to_string(),
|
||||
port: 5225,
|
||||
path: "/mcp".to_string(),
|
||||
allow_remote: true,
|
||||
allowed_hosts: vec!["mcp.example.test:5225".to_string()],
|
||||
allowed_origins: vec!["https://client.example.test".to_string()],
|
||||
};
|
||||
assert_eq!(endpoint_for_settings(&settings).as_deref(), Some("http://mcp.example.test:5225/mcp"));
|
||||
|
||||
let hostname_without_port =
|
||||
McpHttpServerSettings { allowed_hosts: vec!["mcp.example.test".to_string()], ..settings.clone() };
|
||||
assert_eq!(endpoint_for_settings(&hostname_without_port).as_deref(), Some("http://mcp.example.test:5225/mcp"));
|
||||
|
||||
let remote_ipv6 = McpHttpServerSettings { allowed_hosts: vec!["2001:db8::1".to_string()], ..settings.clone() };
|
||||
assert_eq!(endpoint_for_settings(&remote_ipv6).as_deref(), Some("http://[2001:db8::1]:5225/mcp"));
|
||||
|
||||
let ipv6 =
|
||||
McpHttpServerSettings { host: "::1".to_string(), allow_remote: false, allowed_hosts: vec![], ..settings };
|
||||
assert_eq!(endpoint_for_settings(&ipv6).as_deref(), Some("http://[::1]:5225/mcp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_supervisor_cannot_clear_a_replacement() {
|
||||
let service = McpHttpServerState::new(std::env::temp_dir());
|
||||
let (first_id, _) = service.claim_health_supervisor().expect("first supervisor");
|
||||
service.cancel_health_supervisor();
|
||||
let (second_id, _) = service.claim_health_supervisor().expect("replacement supervisor");
|
||||
|
||||
service.release_health_supervisor(first_id);
|
||||
assert_ne!(first_id, second_id);
|
||||
assert!(service.claim_health_supervisor().is_none());
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ pub mod keychain;
|
||||
pub mod list_sql_files;
|
||||
pub mod mcp;
|
||||
pub mod mcp_bridge;
|
||||
pub mod mcp_http_server;
|
||||
pub mod mongo_cmd;
|
||||
#[cfg(feature = "mq-admin")]
|
||||
pub mod mq_cmd;
|
||||
|
||||
@@ -1617,6 +1617,12 @@ pub fn run() {
|
||||
}));
|
||||
let state = Arc::new(state);
|
||||
app.manage(state.clone());
|
||||
let mcp_http_server = Arc::new(commands::mcp_http_server::McpHttpServerState::new(data_dir.clone()));
|
||||
app.manage(mcp_http_server.clone());
|
||||
let mcp_http_state = state.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
commands::mcp_http_server::start_if_enabled(mcp_http_state, mcp_http_server).await;
|
||||
});
|
||||
app.manage(commands::redis_pubsub_server::start_pubsub_server(state.clone()));
|
||||
app.manage(commands::saved_sql::SavedSqlStorageState { data_dir: data_dir.clone() });
|
||||
app.manage(commands::external_sql::ExternalSqlOpenState::default());
|
||||
@@ -1735,6 +1741,10 @@ pub fn run() {
|
||||
commands::app_settings::save_pinned_tree_node_ids,
|
||||
commands::app_settings::load_mcp_global_policy,
|
||||
commands::app_settings::save_mcp_global_policy,
|
||||
commands::mcp_http_server::load_mcp_http_server_settings,
|
||||
commands::mcp_http_server::save_mcp_http_server_settings,
|
||||
commands::mcp_http_server::mcp_http_server_status,
|
||||
commands::mcp_http_server::rotate_mcp_http_server_token,
|
||||
commands::app_settings::load_editor_settings,
|
||||
commands::app_settings::save_editor_settings,
|
||||
commands::app_settings::load_open_tabs_state,
|
||||
|
||||
Reference in New Issue
Block a user